diff --git a/app/(site)/(catalogs)/[slug]/content/saas-tools/article.mdx b/app/(site)/(catalogs)/[slug]/content/saas-tools/article.mdx index 3bf6930e..75b77256 100644 --- a/app/(site)/(catalogs)/[slug]/content/saas-tools/article.mdx +++ b/app/(site)/(catalogs)/[slug]/content/saas-tools/article.mdx @@ -9,8 +9,13 @@ Find the question you're asking. | What made MRR go up or down this month? | [MRR waterfall calculator](/mrr-waterfall-calculator) | | How much does each customer pay on average? | [ARPU and ARPA calculator](/arpu-arpa-calculator) | | How many customers did I lose? | [Customer churn calculator](/customer-churn-calculator) | +| What is my monthly churn rate as a yearly rate, or the other way round? | [Churn rate converter](/churn-rate-converter) | | How much revenue did I lose, or keep, from existing customers? | [Revenue churn calculator](/revenue-churn-calculator), [NRR calculator](/net-revenue-retention-calculator) | | How much of my revenue is gross profit? | [SaaS gross margin](/saas-gross-margin-calculator) | | How much MRR do I need to cover my costs? | [Break-even MRR](/break-even-mrr-calculator) | +| How many customers do I need for my MRR goal, and when will I get there? | [MRR goal calculator](/mrr-goal-calculator) | +| What should my annual plan cost, and does the discount pay off? | [Annual plan discount calculator](/annual-plan-discount-calculator) | +| How many free trials turn into paying customers? | [Free trial conversion calculator](/free-trial-conversion-calculator) | | What does a new customer cost, and is it worth it? | [Customer acquisition cost](/customer-acquisition-cost-calculator), [customer lifetime value](/customer-lifetime-value-calculator), [LTV to CAC](/ltv-cac-calculator), [CAC payback](/cac-payback-calculator) | | How much cash am I burning for each dollar of new ARR? | [Burn multiple calculator](/burn-multiple-calculator) | +| How long will my cash last? | [Startup runway calculator](/startup-runway-calculator) | diff --git a/app/(site)/(catalogs)/[slug]/content/saas-tools/intro.mdx b/app/(site)/(catalogs)/[slug]/content/saas-tools/intro.mdx index 1c446320..655cb1c0 100644 --- a/app/(site)/(catalogs)/[slug]/content/saas-tools/intro.mdx +++ b/app/(site)/(catalogs)/[slug]/content/saas-tools/intro.mdx @@ -1 +1 @@ -Calculators for subscription businesses: recurring revenue, churn and retention, margins, and what it costs to win a customer. Enter your own figures. Not sure which metric answers your question? Find it in the table below the tools. +Calculators for subscription businesses: recurring revenue and growth goals, churn and retention, pricing, margins, cash runway, and what it costs to win a customer. Enter your own figures. Not sure which metric answers your question? Find it in the table below the tools. diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/annual-plan-discount-calculator.module.css b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/annual-plan-discount-calculator.module.css new file mode 100644 index 00000000..e55bd069 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/annual-plan-discount-calculator.module.css @@ -0,0 +1,144 @@ +.workbench { + background: var(--color-surface); + border-block: 1px solid var(--color-border-strong); + grid-column: 1 / -1; + margin-inline: calc(-1 * var(--container-gutter)); + min-width: 0; + padding: var(--space-7) var(--container-gutter); + width: calc(100% + var(--container-gutter) + var(--container-gutter)); +} + +.content, +.form, +.results, +.offer, +.fees { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +.content { + gap: var(--space-7); + margin-inline: auto; + max-width: 66rem; +} + +.offer, +.fees { + gap: var(--space-5); +} + +.priceRow, +.pair { + align-items: start; + display: grid; + gap: var(--space-5); + min-width: 0; +} + +.priceRow { + grid-template-columns: minmax(8rem, 10rem) minmax(0, 22rem); +} + +.pair { + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-width: 48rem; +} + +.currency { + min-width: 0; +} + +.offerField, +.presetField { + max-width: 22rem; + min-width: 0; +} + +.priceRow > *, +.pair > *, +.offer > * { + min-width: 0; +} + +.feeNote, +.assumptions { + color: var(--color-text-muted); + font-size: var(--text-small); + line-height: var(--line-body); + margin: 0; + max-width: 48rem; +} + +.results { + border-top: 1px solid var(--color-border); + min-width: 0; + overflow-wrap: anywhere; + padding-top: var(--space-6); +} + +.results :global(table) { + min-width: 0; +} + +.results :global(th), +.results :global(td), +.results :global(caption) { + overflow-wrap: anywhere; +} + +.detailLines, +.threshold, +.rowLabel { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.thresholdLine { + color: var(--color-text-muted); + font-size: var(--text-small); + line-height: var(--line-body); +} + +.cellStack { + display: grid; + gap: var(--space-1); + justify-items: end; + min-width: 0; +} + +.cellNote { + color: var(--color-text-muted); + font-size: var(--text-small); + font-weight: var(--weight-regular); +} + +@media (max-width: 760px) { + .workbench { + padding-block: var(--space-5); + } + + .content { + gap: var(--space-6); + } + + .priceRow, + .pair { + grid-template-columns: minmax(0, 1fr); + } + + .currency { + max-width: 12rem; + } + + .offerField, + .presetField { + max-width: none; + } + + .cellStack { + justify-items: start; + } +} diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/annual-plan-discount-calculator.tsx b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/annual-plan-discount-calculator.tsx new file mode 100644 index 00000000..9947b932 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/annual-plan-discount-calculator.tsx @@ -0,0 +1,568 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode, type RefObject } from "react"; +import { UiLiveChart } from "@/app/ui/charts/chart-client"; +import type { UiChartSpec } from "@/app/ui/charts/chart-option"; +import { UiButton, UiChoiceGroup, UiDisclosure, UiSelect, UiTextField, uiOption } from "@/app/ui/controls"; +import { UiActionRow, UiDataTable, UiKeyValueList, UiPrimaryResult, UiStatus } from "@/app/ui/display"; +import { + ANNUAL_PLAN_FEE_PRESETS, + calculateAnnualPlanDiscount, + type AnnualPlanErrors, + type AnnualPlanFeeAmounts, + type AnnualPlanFeePreset, + type AnnualPlanFieldKey, + type AnnualPlanInput, + type AnnualPlanMonths, + type AnnualPlanOfferMode, + type AnnualPlanPercent, + type AnnualPlanValues, +} from "@/lib/annual-plan-discount-calculator/engine"; +import { SAAS_ECONOMICS_CURRENCIES, type SaasEconomicsCurrency } from "@/lib/saas-economics/shared"; +import styles from "./annual-plan-discount-calculator.module.css"; + +type StatusMessage = { text: string; tone: "neutral" | "danger" }; + +const CHANGED = "Inputs changed. Compare again."; +const PRESET_CHANGED = "Fee preset changed to Custom fee."; +const CHECKED = "Published US rate, checked 10 Oct 2026."; +const OFFER_FIELDS: Record = { + annualPrice: "annualPrice", + discount: "discountPercent", + monthsFree: "monthsFree", +}; +const FOCUS_ORDER: AnnualPlanFieldKey[] = [ + "currency", "monthlyPrice", "offerMode", "annualPrice", "discountPercent", "monthsFree", + "monthlyChurnPercent", "annualRenewalPercent", "feePreset", "feePercent", "feeFixed", +]; +const FEE_KEYS: AnnualPlanFieldKey[] = ["feePreset", "feePercent", "feeFixed"]; + +function emptyInput(): AnnualPlanInput { + return { + currency: "USD", + monthlyPrice: "", + offerMode: "discount", + annualPrice: "", + discountPercent: "", + monthsFree: "", + monthlyChurnPercent: "", + annualRenewalPercent: "", + feePreset: "none", + feePercent: "", + feeFixed: "", + }; +} + +function presetById(id: string): AnnualPlanFeePreset | null { + return ANNUAL_PLAN_FEE_PRESETS.find((preset) => preset.id === id) ?? null; +} + +function abs(value: bigint): bigint { + return value < 0n ? -value : value; +} + +function formatInteger(value: bigint): string { + return abs(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +function currencySymbol(currency: SaasEconomicsCurrency): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value ?? currency; +} + +function formatMoney(cents: bigint, currency: SaasEconomicsCurrency): string { + const magnitude = abs(cents); + const fraction = (magnitude % 100n).toString().padStart(2, "0"); + return `${cents < 0n ? "−" : ""}${currencySymbol(currency)}${formatInteger(magnitude / 100n)}.${fraction}`; +} + +/** A difference always carries its sign, so the direction never depends on color. */ +function signedMoney(cents: bigint, currency: SaasEconomicsCurrency): string { + return cents > 0n ? `+${formatMoney(cents, currency)}` : formatMoney(cents, currency); +} + +/** Hundredths of a percent, as entered: 3% or 2.5%. */ +function formatHundredths(value: bigint): string { + const fraction = (abs(value) % 100n).toString().padStart(2, "0").replace(/0+$/, ""); + return `${value < 0n ? "−" : ""}${formatInteger(value / 100n)}${fraction ? `.${fraction}` : ""}`; +} + +function formatTenths(value: bigint): string { + return `${value < 0n ? "−" : ""}${formatInteger(value / 10n)}.${abs(value) % 10n}`; +} + +/** Magnitude of a percent, for sentences that say "more" or "off" themselves. */ +function percentSize(percent: AnnualPlanPercent): string { + return percent.nearZero ? "under 0.1%" : `${formatTenths(abs(percent.tenths))}%`; +} + +function formatPercent(percent: AnnualPlanPercent): string { + if (percent.nearZero) return percent.exact.numerator < 0n ? "Under −0.1%" : "Under 0.1%"; + return `${formatTenths(percent.tenths)}%`; +} + +function formatMonths(months: AnnualPlanMonths): string { + return months.exact ? formatInteger(months.tenths / 10n) : formatTenths(months.tenths); +} + +function ordinal(value: bigint): string { + const last = value % 10n; + const lastTwo = value % 100n; + const suffix = lastTwo >= 11n && lastTwo <= 13n ? "th" : last === 1n ? "st" : last === 2n ? "nd" : last === 3n ? "rd" : "th"; + return `${formatInteger(value)}${suffix}`; +} + +function sameNumber(draft: string, value: string): boolean { + return draft.trim() !== "" && Number(draft) === Number(value); +} + +function feeDraftSummary(input: AnnualPlanInput): string { + if (input.feePreset === "none") return "None"; + const preset = presetById(input.feePreset); + const valid = /^\d+(?:\.\d{1,2})?$/.test(input.feePercent.trim()) && /^\d+(?:\.\d{1,2})?$/.test(input.feeFixed.trim()); + const name = preset?.label ?? "Custom fee"; + if (!valid) return name; + const currency = SAAS_ECONOMICS_CURRENCIES.includes(input.currency as SaasEconomicsCurrency) ? input.currency as SaasEconomicsCurrency : "USD"; + const [whole, fraction = ""] = input.feeFixed.trim().split("."); + const fixed = formatMoney(BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0")), currency); + const edited = preset && !(sameNumber(input.feePercent, presetPercent(preset)) && sameNumber(input.feeFixed, presetFixed(preset))); + return `${name} · ${Number(input.feePercent)}% + ${fixed}${edited ? " (edited)" : ""}`; +} + +function presetPercent(preset: AnnualPlanFeePreset): string { + return formatHundredths(preset.percentHundredths); +} + +function presetFixed(preset: AnnualPlanFeePreset): string { + return `${preset.fixedCents / 100n}.${(preset.fixedCents % 100n).toString().padStart(2, "0")}`; +} + +function OfferLead({ resultRef, values }: { resultRef: RefObject; values: AnnualPlanValues }) { + const { currency, offer, offerMode } = values; + const surcharge = offer.customerSavingsCents < 0n; + const twelve = `12 payments of ${formatMoney(offer.monthlyPriceCents, currency)}`; + const parts: string[] = []; + if (surcharge) { + if (offerMode !== "annualPrice") parts.push(`${percentSize(offer.discount)} more than ${twelve}`); + parts.push(`Customer pays ${formatMoney(abs(offer.customerSavingsCents), currency)} more a year${offerMode === "annualPrice" ? ` than ${twelve}` : ""}`); + } else { + if (offerMode === "monthsFree") parts.push(`${percentSize(offer.discount)} off ${twelve}`); + else { + const months = formatMonths(offer.monthsFree); + parts.push(`${months} ${months === "1" ? "month" : "months"} free`); + } + parts.push(`Customer saves ${formatMoney(offer.customerSavingsCents, currency)} a year${offerMode === "discount" ? ` against ${twelve}` : ""}`); + } + const leadIsDiscount = offerMode === "annualPrice"; + return ( + + {parts.join(" · ")} + {offer.annualPriceRounded ? Rounded to the nearest cent. : null} + + )} + inputRef={resultRef} + label={leadIsDiscount ? (surcharge ? "Annual surcharge" : "Annual discount") : "Annual price"} + value={leadIsDiscount + ? (surcharge ? `${percentSize(offer.discount)} more` : formatPercent(offer.discount)) + : formatMoney(offer.annualPriceCents, currency)} + /> + ); +} + +function ValueLines({ lines, value }: { lines: string[]; value: string }) { + return ( + + {value} + {lines.map((line) => {line})} + + ); +} + +function breakEvenLabel(percent: AnnualPlanPercent | null): string { + if (!percent) return "unknown"; + return percent.exact.numerator < 0n ? `none, annual would need to cost ${percentSize(percent)} more` : formatPercent(percent); +} + +function Thresholds({ values }: { values: AnnualPlanValues }) { + const { comparison, currency, offer } = values; + const equivalent = offer.invoiceEquivalent; + const paymentLine = `The ${ordinal(equivalent.wholePayments)} monthly payment ${equivalent.months.exact ? "equals" : "passes"} ${formatMoney(offer.annualPriceCents, currency)}.`; + + let breakEven: { label: string; lines: string[]; value: string }; + if (!comparison) { + breakEven = { label: "Break-even discount", lines: ["Needed to compare what each plan collects."], value: "Add monthly churn" }; + } else { + const [year1, year2, year3] = comparison.horizons; + const point = year1.breakEvenDiscount!; + const negative = point.exact.numerator < 0n; + const gap = abs(year1.differenceCents ?? 0n); + const yours = offer.customerSavingsCents < 0n + ? "Your annual price is above 12 monthly payments." + : `Your ${formatPercent(offer.discount)} is ${year1.annualVsMonthly === "more" ? "below" : year1.annualVsMonthly === "less" ? "above" : "equal to"} it.`; + const yearOne = year1.annualVsMonthly === "same" + ? "In year one, both plans collect the same on average." + : `In year one, annual collects ${formatMoney(gap, currency)} ${year1.annualVsMonthly === "more" ? "more" : "less"} than monthly, on average.`; + const lines = negative + ? [`Annual would need to cost ${percentSize(point)} more than 12 payments to match.`, yearOne] + : [`${yours} ${yearOne}`]; + if (comparison.annualRenewalHundredths !== null) { + lines.push(`Over 24 months: ${breakEvenLabel(year2.breakEvenDiscount)}. Over 36 months: ${breakEvenLabel(year3.breakEvenDiscount)}, at ${formatHundredths(comparison.annualRenewalHundredths)}% renewal.`); + } + breakEven = { + label: `Break-even discount at ${formatHundredths(comparison.monthlyChurnHundredths)}% churn`, + lines, + value: negative ? "None" : formatPercent(point), + }; + } + + return ( + }, + { label: breakEven.label, value: }, + ]} + /> + ); +} + +function amountCell(cents: bigint, fee: AnnualPlanFeeAmounts | null, currency: SaasEconomicsCurrency): ReactNode { + if (!fee) return formatMoney(cents, currency); + return ( + + {formatMoney(cents, currency)} + {formatMoney(fee.feesCents, currency)} fees + + ); +} + +function ComparisonTable({ values }: { values: AnnualPlanValues }) { + const { comparison, currency, fee, offer, upfront } = values; + const rows: { id: string; cells: Record }[] = [ + { + id: "signup", + cells: { + item: "Charged at signup", + monthly: amountCell(upfront.monthly.grossCents, upfront.monthly.fee, currency), + annual: amountCell(upfront.annual.grossCents, upfront.annual.fee, currency), + difference: signedMoney(upfront.differenceCents, currency), + }, + }, + { + id: "mrr", + cells: { + item: ( + + MRR + Annual price ÷ 12, not cash + + ), + monthly: formatMoney(offer.mrr.monthlyPlanCents, currency), + annual: formatMoney(offer.mrr.annualPlan.cents, currency), + difference: signedMoney(offer.mrr.differenceCents, currency), + }, + }, + ]; + if (comparison) { + for (const horizon of comparison.horizons) { + rows.push({ + id: `billings-${horizon.months}`, + cells: { + item: `Average collected by month ${horizon.months}`, + monthly: formatMoney(horizon.monthly.grossCents, currency), + annual: horizon.annual ? formatMoney(horizon.annual.grossCents, currency) : Needs renewal rate, + difference: horizon.differenceCents === null ? "—" : signedMoney(horizon.differenceCents, currency), + }, + }); + } + if (fee) { + for (const horizon of comparison.horizons) { + const monthlyFee = horizon.monthly.fee!; + const annualFee = horizon.annual?.fee ?? null; + rows.push({ + id: `net-${horizon.months}`, + cells: { + item: `After fees by month ${horizon.months}`, + monthly: amountCell(monthlyFee.netCents, monthlyFee, currency), + annual: annualFee ? amountCell(annualFee.netCents, annualFee, currency) : Needs renewal rate, + difference: horizon.netDifferenceCents === null ? "—" : signedMoney(horizon.netDifferenceCents, currency), + }, + }); + } + } + } + return ( + + ); +} + +function RunningTotalChart({ values }: { values: AnnualPlanValues }) { + const { comparison, currency } = values; + const spec = useMemo(() => { + if (!comparison) return null; + const withRenewal = comparison.annualRenewalHundredths !== null; + const totals = comparison.runningTotals.filter((point) => withRenewal || point.month <= 12); + const usd = currency === "USD"; + return { + caption: withRenewal + ? "Annual jumps when customers renew at months 13 and 25." + : "Months 1 to 12. Add an annual renewal rate to see months 13 to 36.", + format: usd ? "currency" : "number", + kind: "line", + series: [ + { id: "monthly", label: "Monthly plan", points: totals.map((point) => [point.month, Number(point.monthlyCents) / 100] as const) }, + { id: "annual", label: "Annual plan", points: totals.map((point) => [point.month, Number(point.annualCents ?? 0n) / 100] as const) }, + ], + title: `Average collected per customer, running total${usd ? "" : ` in ${currency}`}`, + xFormat: "number", + }; + }, [comparison, currency]); + return spec ? : null; +} + +function Assumptions({ values }: { values: AnnualPlanValues }) { + const { comparison, currency, fee } = values; + const sentences: string[] = []; + if (comparison) { + sentences.push(`Monthly customers pay at signup, then ${formatHundredths(comparison.monthlyChurnHundredths)}% cancel before each later payment.`); + sentences.push(comparison.annualRenewalHundredths === null + ? "Annual customers pay at signup. Add a renewal rate to see later years." + : `Annual customers pay at signup, and ${formatHundredths(comparison.annualRenewalHundredths)}% renew each year.`); + } + if (fee) { + const preset = presetById(fee.preset); + const name = preset ? `${preset.label}${fee.edited ? ", edited" : ""}` : "custom fee"; + sentences.push(`Fees are ${formatHundredths(fee.percentHundredths)}% + ${formatMoney(fee.fixedCents, currency)} per successful charge (${name}). They don't change MRR.`); + } + sentences.push("Before refunds, taxes, and failed payments. Not profit."); + return

{sentences.join(" ")}

; +} + +function AnnualPlanResults({ resultRef, values }: { resultRef: RefObject; values: AnnualPlanValues }) { + return ( +
+ + + + + +
+ ); +} + +export function AnnualPlanDiscountCalculator() { + const [input, setInput] = useState(emptyInput); + const [errors, setErrors] = useState({}); + const [result, setResult] = useState(null); + const [status, setStatus] = useState(null); + const [feesOpen, setFeesOpen] = useState(false); + const focusRequest = useRef(null); + const formRef = useRef(null); + const resultRef = useRef(null); + const usd = input.currency === "USD"; + const offerMode = input.offerMode as AnnualPlanOfferMode; + const offerField = OFFER_FIELDS[offerMode] ?? "discountPercent"; + const selectedPreset = presetById(input.feePreset); + + useEffect(() => { + const key = focusRequest.current; + if (!key) return; + focusRequest.current = null; + if (key === "result") { + resultRef.current?.focus(); + return; + } + formRef.current?.querySelector(`[data-focus-key="${key}"]`)?.focus(); + }); + + function change(update: (current: AnnualPlanInput) => AnnualPlanInput, message?: string) { + const hadResult = result !== null; + setInput(update); + setErrors({}); + setResult(null); + if (message) setStatus({ text: message, tone: "neutral" }); + else if (hadResult) setStatus({ text: CHANGED, tone: "neutral" }); + else if (status?.text !== CHANGED) setStatus(null); + } + + function setField(key: keyof AnnualPlanInput, value: string) { + change((current) => ({ ...current, [key]: value })); + } + + function changeCurrency(value: string) { + if (!SAAS_ECONOMICS_CURRENCIES.includes(value as SaasEconomicsCurrency)) return; + const demote = value !== "USD" && presetById(input.feePreset) !== null; + change((current) => ({ ...current, currency: value, feePreset: demote ? "custom" : current.feePreset }), demote ? PRESET_CHANGED : undefined); + } + + function changePreset(value: string) { + const preset = presetById(value); + change((current) => preset + ? { ...current, feePreset: value, feePercent: presetPercent(preset), feeFixed: presetFixed(preset) } + : { ...current, feePreset: value }); + } + + function submit(event: FormEvent) { + event.preventDefault(); + setStatus(null); + try { + const calculation = calculateAnnualPlanDiscount(input); + if (calculation.status === "invalid") { + setErrors(calculation.errors); + setResult(null); + if (calculation.errors.form) setStatus({ text: calculation.errors.form, tone: "danger" }); + const first = FOCUS_ORDER.find((key) => calculation.errors[key]); + if (first) { + if (FEE_KEYS.includes(first)) setFeesOpen(true); + focusRequest.current = first; + } + return; + } + setErrors({}); + setResult(calculation.values); + focusRequest.current = "result"; + } catch { + setErrors({}); + setResult(null); + setStatus({ text: "Couldn't calculate. Try again.", tone: "danger" }); + } + } + + function reset() { + setInput(emptyInput()); + setErrors({}); + setResult(null); + setFeesOpen(false); + setStatus({ text: "Form reset.", tone: "neutral" }); + focusRequest.current = "monthlyPrice"; + } + + const offerCopy = { + annualPrice: { label: "Annual price", suffix: input.currency }, + discountPercent: { label: "Annual discount", suffix: "%" }, + monthsFree: { label: "Months free", suffix: "months" }, + }[offerField]; + + const presetOptions = [ + uiOption("none", "None"), + ...(usd ? ANNUAL_PLAN_FEE_PRESETS.map((preset) => uiOption(preset.id, preset.label)) : []), + uiOption("custom", "Custom fee"), + ]; + + function textField(key: keyof AnnualPlanInput, label: string, suffix: string, options: { description?: string; required?: boolean } = {}) { + return ( + setField(key, value)} + required={options.required} + spellCheck={false} + suffix={suffix} + value={input[key]} + /> + ); + } + + return ( +
+
+
+
+
+ uiOption(currency, currency))} + required + value={input.currency} + /> +
+ {textField("monthlyPrice", "Monthly price", input.currency, { required: true })} +
+
+ setField("offerMode", value)} + options={[uiOption("discount", "Discount"), uiOption("monthsFree", "Months free"), uiOption("annualPrice", "Annual price")]} + required + value={input.offerMode} + /> +
+ {textField(offerField, offerCopy.label, offerCopy.suffix, { required: true })} +
+
+
+ {textField("monthlyChurnPercent", "Monthly churn (optional)", "%", { + description: "Share of paying monthly customers who cancel each month. Leave blank to see the price only.", + })} + {textField("annualRenewalPercent", "Annual renewal rate (optional)", "%", { + description: "Share of annual customers who renew at year end. Not the same as monthly churn.", + })} +
+ +
+
+ +
+ {input.feePreset !== "none" ? ( + <> +
+ {textField("feePercent", "Fee percent", "%", { required: true })} + {textField("feeFixed", "Fixed fee per charge", input.currency, { required: true })} +
+

+ {selectedPreset ? ( + <> + {selectedPreset.scope} {CHECKED}{" "} + Current rates + + ) : "Percent of each charge, plus a fixed amount per successful charge."} +

+ + ) : null} +
+
+ + Compare plans + + + + {status ? {status.text} : null} +
+ {result ? : null} +
+
+ ); +} diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.spec.json b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.spec.json new file mode 100644 index 00000000..590628af --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.spec.json @@ -0,0 +1,13 @@ +{ + "id": "annual-plan-discount-calculator--tool-spot", + "type": "toolSpot", + "takeaway": "An annual plan is a year of monthly payments with some months given away.", + "source": "conceptual figure, no external data", + "dataAsOf": "2026-10-10", + "width": 140, + "height": 110, + "seed": 127, + "data": { + "motif": "annualDiscount" + } +} diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.svg b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.svg new file mode 100644 index 00000000..4c1ad7a0 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.svg @@ -0,0 +1,26 @@ + +An annual plan is a year of monthly payments with some months given away. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.ts b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.ts new file mode 100644 index 00000000..e959a29c --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.ts @@ -0,0 +1,28 @@ +// generated by scripts/stack127/marker-art/generate.mjs. do not edit. +// edit the sibling .spec.json and rerun: npm run stack127:marker-art +export const annualPlanDiscountCalculatorArt = ` +An annual plan is a year of monthly payments with some months given away. + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/article.mdx b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/article.mdx new file mode 100644 index 00000000..c6fa5032 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/article.mdx @@ -0,0 +1,116 @@ +## How do you work out an annual plan discount? + +Compare the annual price with 12 monthly payments. The gap, as a share of those 12 payments, is the discount. Months free is the same gap counted in months. + + + +Annual price = 12 × monthly price × (1 − discount) + +Discount = 1 − annual price ÷ (12 × monthly price) + +Months free = 12 × discount + + + +On a $20 monthly plan, 12 payments come to $240: + +| Offer | Annual price | Discount | Months free | +| --- | --- | --- | --- | +| 1 month free | $220.00 | 8.3% | 1 | +| 10% off | $216.00 | 10.0% | 1.2 | +| 15% off | $204.00 | 15.0% | 1.8 | +| 2 months free | $200.00 | 16.7% | 2 | +| 20% off | $192.00 | 20.0% | 2.4 | +| 3 months free | $180.00 | 25.0% | 3 | + +## Is 2 months free the same as 20% off? + +No. 2 months free is 2 ÷ 12, which is 16.7% off. 20% off is the same as 2.4 months free. On a $20 plan, that's $200 a year against $192. + +## What are the two break-even numbers? + +"Break even" can answer two different questions, so the calculator gives each its own name. + +| Name | Question it answers | $20 a month, $192 a year, 3% churn | +| --- | --- | --- | +| Annual price in monthly payments | How many monthly payments add up to the annual price? | 9.6. The 10th monthly payment passes $192. | +| Break-even discount | At what discount does annual collect as much as monthly, once some monthly customers cancel? | 15.0%. At 20% off, annual collects $12.11 less in year one. | + +9.6 and 10 are both right. 9.6 is the exact count, and 10 is the first whole payment that gets there. This first number is about price only, so churn doesn't change it. + +## How does churn change the result? + +Monthly customers can cancel any month, so the monthly plan usually collects less than 12 full payments a year. The annual plan collects its whole price at signup. + +The first monthly payment comes at signup. Before each later one, your churn share of the remaining customers cancels. + + + +Average payments = 1 + (1 − churn) + (1 − churn)² + …, with n terms + +Average collected = monthly price × average payments + + + +At 3% churn, 100 monthly customers make about 1,021 payments in their first year, or 10.21 each. On a $20 plan, that's $204.11 per customer. + +Higher churn raises the break-even discount. At 6% churn, the same plan averages $174.69 in year one. The $200 annual plan then collects $25.31 more, and the break-even discount is 27.2%. + +## Why is the annual renewal rate separate from monthly churn? + +The calculator assumes annual customers decide whether to stay when their year ends, so it gives them their own number: the share who renew. Refunds that end a plan early aren't modeled. + +Renewal decides months 24 and 36. Take a $20 plan with 2 months free and 3% churn. At 70% renewal, annual is $5.98 behind monthly by month 36. At 50% renewal, it's $93.98 behind, and no discount closes the gap. Annual would have to cost 5.7% more than 12 payments to match. + + + +3% monthly churn doesn't mean 97% of annual customers renew. It doesn't mean 69% either. That's the share of monthly customers still paying after 12 months, which the [churn rate converter](/churn-rate-converter) works out for any rate. Annual buyers are a different group, and the calculator treats renewal as their one decision each year. Use a renewal rate from your own annual customers, or try a range. + + + +## Is annual plan MRR the same as the upfront payment? + +No. MRR (monthly recurring revenue) counts an annual plan as its price ÷ 12. A $200 annual plan adds $16.67 to MRR, even though you get $200 in cash at signup. + +[Stripe](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29) and [Paddle](https://www.paddle.com/help/profitwell-metrics/measure/subscription-metrics/mrr) both count it this way, and Stripe notes that MRR isn't cash flow. The $200 pays for a year of service you still owe. So toward an MRR goal, each annual customer counts as $16.67. The [MRR goal calculator](/mrr-goal-calculator) shows how many customers a goal takes. + +## Do processor fees make annual billing cheaper? + +A little. Most processors charge a fixed fee on every charge, and an annual customer is charged once a year instead of 12 times. + +Year-one fees for one customer who stays all year, 12 charges of $20 against one charge of $200, at published US rates: + +| Processor rate | Monthly plan | Annual plan | Fee saved | +| --- | --- | --- | --- | +| Stripe card, 2.9% + $0.30 | $10.56 | $6.10 | $4.46 | +| Stripe card and Billing, 3.6% + $0.30 | $12.24 | $7.50 | $4.74 | +| Paddle, 5% + $0.50 | $18.00 | $10.50 | $7.50 | +| Lemon Squeezy subscription, 5.5% + $0.50 | $19.20 | $11.50 | $7.70 | + +The fee saved is small next to the $40 discount, but it can tip a close result. In the usage example, adding the Stripe card fee moves month 36 from $5.98 behind to $0.20 ahead. + + + +Checked 10 Oct 2026: [Stripe pricing](https://stripe.com/pricing) (card 2.9% + $0.30, Billing 0.7% of volume), [Paddle pricing](https://www.paddle.com/pricing) (5% + $0.50), and [Lemon Squeezy fees](https://docs.lemonsqueezy.com/help/getting-started/fees) (5% + $0.50, plus 0.5% on subscription payments). Lemon Squeezy also adds 1.5% for payments from outside the US and 1.5% for PayPal, which the preset leaves out. Your own rate can differ by country, card, currency, and plan. + + + +## What happens when you refund an annual plan? + +The calculator shows money before refunds. An annual refund can mean paying back months the customer prepaid, and the fee may not come back. [Stripe](https://support.stripe.com/questions/understanding-fees-for-refunded-payments) keeps the original processing fee when you refund a card payment. [Lemon Squeezy](https://docs.lemonsqueezy.com/help/payments/refunds-chargebacks) takes the refund, less its platform fee, out of your next payout. [Paddle's refund page](https://www.paddle.com/help/manage/your-customers/how-do-i-issue-refunds) doesn't say whether its fee comes back, so check your account terms. + +## What annual discount should you offer? + +The calculator doesn't pick one. No source shows a discount that suits every product, because it depends on your churn, renewals, and buyers. + +The break-even discount shows where annual stops collecting as much as monthly for your numbers. It can't tell you whether a discount brings in more customers, or how many will pick annual. If you don't know your churn or renewal rate yet, try a few values and watch how much the answer moves. + +## What this calculator doesn't cover + +It follows one customer on each plan. It leaves out taxes, failed payments, chargebacks, currency conversion, and the cost of running your product, so no figure here is profit. + +Next steps: + +- [MRR calculator](/mrr-calculator): add up MRR across monthly and annual plans. +- [Customer lifetime value calculator](/customer-lifetime-value-calculator): what one customer is worth after serving costs. +- [Platform fees](/platform-fees): other charges from the platforms you sell on. diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/intro.mdx b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/intro.mdx new file mode 100644 index 00000000..12589b56 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/intro.mdx @@ -0,0 +1,3 @@ +See an annual offer as a price, a discount, and months free. Then compare what it collects from one customer against the same plan billed monthly. + +You need your monthly price and the annual offer you have in mind. To compare over 12, 24, and 36 months, add your monthly churn, an annual renewal rate, and your payment fees. diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/usage.mdx b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/usage.mdx new file mode 100644 index 00000000..ea5c693e --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/content/usage.mdx @@ -0,0 +1,27 @@ +## What to enter + +Monthly price is what one customer pays for one month, before tax. + +Enter the annual offer in the form you have it. For "2 months free", choose `Months free` and type `2`. For an annual price above 12 monthly payments, choose `Annual price`. + +Monthly churn is the share of your paying monthly customers who cancel each month. If 3 of 100 cancel, that's 3%. Count customers, not revenue. Your billing dashboard or the [customer churn calculator](/customer-churn-calculator) can give you this. Leave it blank to see the price only. + +Annual renewal rate is the share of annual customers who renew when their year is up. If 70 of 100 renew, that's 70%. It's a separate guess, not 100 minus monthly churn. No annual customers yet? Try a low and a high value. Leave it blank to stop the annual plan at month 12. + +The fee presets are published US rates. Change the two fee fields to match your own account. + +## How to read the result + +The top line is the number you didn't type, plus what the customer saves. + +`Annual price in monthly payments` is how many monthly payments add up to the annual price. `Break-even discount` is the discount where the annual plan collects as much as the monthly plan, once some monthly customers cancel. + +The table follows one customer on each plan. The `Average collected` rows are averages over many customers, so one customer's payments can differ from them. The chart shows the same totals month by month. + + + +The annual price is $200.00, which is 16.7% off. The customer saves $40.00 a year, and exactly 10 monthly payments equal the annual price. + +At 3% churn, the break-even discount is 15.0%. Your 16.7% is above it, so annual collects a little less: $200.00 by month 12, against $204.11 for monthly. By month 36, the gap is $5.98. + + diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/layout.tsx b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/layout.tsx new file mode 100644 index 00000000..90001e54 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/layout.tsx @@ -0,0 +1,6 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/annual-plan-discount-calculator"); +export const generateMetadata = registry.generateMetadata; +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/page.tsx b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/page.tsx new file mode 100644 index 00000000..26df7840 --- /dev/null +++ b/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/page.tsx @@ -0,0 +1,25 @@ +import { Tag } from "lucide-react"; +import { ToolPageShell } from "@/app/ui/tools/tool-page-shell"; +import { ToolPageArticle, ToolPageHeader } from "@/app/ui/site/tool-page-content"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { getRequiredToolPage } from "@/lib/tool-pages"; +import { AnnualPlanDiscountCalculator } from "./annual-plan-discount-calculator"; +import Article from "./content/article.mdx"; +import Intro from "./content/intro.mdx"; +import Usage from "./content/usage.mdx"; + +export default async function AnnualPlanDiscountCalculatorPage() { + const toolPage = await getRequiredToolPage("annual-plan-discount-calculator"); + return ( + + + } intro={} toolPage={toolPage} /> + + + +
+ + + + ); +} diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.spec.json b/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.spec.json new file mode 100644 index 00000000..4f68de49 --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.spec.json @@ -0,0 +1,13 @@ +{ + "id": "catalog--churn-rate-converter", + "type": "toolSpot", + "takeaway": "Customers lost add up more slowly each period because each period starts with fewer customers, so multiplying one period's loss overstates the total.", + "source": "Conceptual figure, no external data.", + "dataAsOf": "2026-10-10", + "width": 140, + "height": 110, + "seed": 12759, + "data": { + "motif": "churnCompounding" + } +} diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.svg b/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.svg new file mode 100644 index 00000000..82182ed9 --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.svg @@ -0,0 +1,24 @@ + +Customers lost add up more slowly each period because each period starts with fewer customers, so multiplying one period's loss overstates the total. + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.ts b/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.ts new file mode 100644 index 00000000..c7b96ab7 --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter.ts @@ -0,0 +1,26 @@ +// generated by scripts/stack127/marker-art/generate.mjs. do not edit. +// edit the sibling .spec.json and rerun: npm run stack127:marker-art +export const churnRateConverterArt = ` +Customers lost add up more slowly each period because each period starts with fewer customers, so multiplying one period's loss overstates the total. + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/churn-rate-converter.module.css b/app/(site)/(tools)/(saas)/churn-rate-converter/churn-rate-converter.module.css new file mode 100644 index 00000000..bcbae2ab --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/churn-rate-converter.module.css @@ -0,0 +1,102 @@ +.workbench { + background: var(--color-surface); + border-block: 1px solid var(--color-border-strong); + grid-column: 1 / -1; + margin-inline: calc(-1 * var(--container-gutter)); + min-width: 0; + padding: var(--space-7) var(--container-gutter); + width: calc(100% + var(--container-gutter) + var(--container-gutter)); +} + +.content { + display: grid; + gap: var(--space-7); + margin-inline: auto; + max-width: 66rem; + min-width: 0; +} + +.form, +.results { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +.fieldGrid { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + min-width: 0; +} + +.fieldGrid > * { + min-width: 0; +} + +.results { + border-top: 1px solid var(--color-border); + overflow-wrap: anywhere; + padding-top: var(--space-6); +} + +.entered { + white-space: nowrap; +} + +.enteredTag { + color: var(--color-text-muted); + font-size: var(--text-small); +} + +.region { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.regionTitle { + color: var(--color-text-muted); + font-family: inherit; + font-size: var(--text-small); + font-weight: inherit; + line-height: var(--line-body); + margin: 0; +} + +.notes { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.notes p { + color: var(--color-text-muted); + font-size: var(--text-small); + line-height: var(--line-body); + margin: 0; +} + +.visuallyHidden { + block-size: 1px; + clip-path: inset(50%); + inline-size: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; +} + +@media (max-width: 760px) { + .workbench { + padding-block: var(--space-5); + } + + .content { + gap: var(--space-6); + } + + .fieldGrid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/churn-rate-converter.tsx b/app/(site)/(tools)/(saas)/churn-rate-converter/churn-rate-converter.tsx new file mode 100644 index 00000000..cb00d6ec --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/churn-rate-converter.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useEffect, useRef, useState, type FormEvent, type RefObject } from "react"; +import { UiButton, UiChoiceGroup, UiTextField, uiOption } from "@/app/ui/controls"; +import { UiActionRow, UiAllocationBar, UiDataTable, UiKeyValueList, UiPrimaryResult, UiStatus } from "@/app/ui/display"; +import { + CHURN_PERIODS, + convertChurnRate, + type ChurnConversion, + type ChurnLifetime, + type ChurnLinearEstimate, + type ChurnPercent, + type ChurnPeriod, + type ChurnRateErrors, +} from "@/lib/churn-rate-converter/engine"; +import styles from "./churn-rate-converter.module.css"; + +const CHANGED = "Inputs changed. Convert again."; +const RESET = "Converter reset."; + +const PERIOD_ADJECTIVE: Record = { month: "monthly", quarter: "quarterly", year: "annual" }; +const PERIOD_TITLE: Record = { month: "Monthly", quarter: "Quarterly", year: "Annual" }; + +function isPeriod(value: string): value is ChurnPeriod { + return CHURN_PERIODS.includes(value as ChurnPeriod); +} + +function withCommas(digits: string) { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +/** Hundredths of a percent to a fixed two-decimal percent, such as 4596 to 45.96%. */ +function fixedPercent(hundredths: number) { + const whole = Math.floor(hundredths / 100); + const fraction = String(hundredths % 100).padStart(2, "0"); + return `${withCommas(String(whole))}.${fraction}%`; +} + +/** The entered rate as people typed it, without padded zeros: 500 to 5%, 425 to 4.25%. */ +function enteredPercent(hundredths: number) { + const whole = Math.floor(hundredths / 100); + const fraction = String(hundredths % 100).padStart(2, "0").replace(/0+$/, ""); + return `${whole}${fraction ? `.${fraction}` : ""}%`; +} + +function formatPercent(value: ChurnPercent) { + if (value.roundedToZero) return "Under 0.01%"; + if (value.nearlyAll) return "Over 99.99%"; + return fixedPercent(value.hundredths); +} + +function formatLinear(value: ChurnLinearEstimate) { + return value.roundedToZero ? "Under 0.01%" : fixedPercent(value.hundredths); +} + +function formatLifetime(lifetime: ChurnLifetime) { + if (lifetime.kind === "notEstimable") return "Not estimable at 0% churn"; + const whole = Math.floor(lifetime.tenths / 10); + const tenth = lifetime.tenths % 10; + const amount = `${withCommas(String(whole))}${tenth ? `.${tenth}` : ""}`; + return `${amount} ${lifetime.period}${lifetime.tenths === 10 ? "" : "s"}`; +} + +export function ChurnRateConverter() { + const [isHydrated, setIsHydrated] = useState(false); + const [rate, setRate] = useState(""); + const [period, setPeriod] = useState("month"); + const [errors, setErrors] = useState({}); + const [result, setResult] = useState(null); + const [status, setStatus] = useState(""); + const [unexpectedFailure, setUnexpectedFailure] = useState(false); + const formRef = useRef(null); + const resultRef = useRef(null); + + useEffect(() => { + setIsHydrated(true); + }, []); + + function focusField(field: "rate" | "period") { + requestAnimationFrame(() => { + formRef.current?.querySelector(`[data-focus-key="${field}"]`)?.focus(); + }); + } + + function clearForEdit() { + const hadResult = result !== null; + setResult(null); + setErrors({}); + setUnexpectedFailure(false); + setStatus((current) => { + if (hadResult) return CHANGED; + return current === RESET ? "" : current; + }); + } + + function submit(event: FormEvent) { + event.preventDefault(); + setStatus(""); + setUnexpectedFailure(false); + + try { + const calculation = convertChurnRate({ rate, period }); + if (calculation.status === "invalid") { + setErrors(calculation.errors); + setResult(null); + focusField(calculation.errors.rate ? "rate" : "period"); + return; + } + + setErrors({}); + setResult(calculation.values); + requestAnimationFrame(() => resultRef.current?.focus()); + } catch { + setResult(null); + setErrors({}); + setUnexpectedFailure(true); + } + } + + function reset() { + setRate(""); + setPeriod("month"); + setErrors({}); + setResult(null); + setUnexpectedFailure(false); + setStatus(RESET); + focusField("rate"); + } + + return ( +
+
+
+
+ { + // The field already shows a % suffix, so drop a typed or pasted one. + setRate(value.replace(/\s*%\s*$/, "")); + clearForEdit(); + }} + placeholder="5" + required + spellCheck={false} + suffix="%" + value={rate} + /> + { + if (!isPeriod(value)) return; + setPeriod(value); + clearForEdit(); + }} + options={CHURN_PERIODS.map((id) => uiOption(id, PERIOD_TITLE[id]))} + required + value={period} + /> +
+ + + Convert churn rate + + + + + {status ? {status} : null} + {unexpectedFailure + ? The rate couldn't be converted. Try again. + : null} +
+ + {result ? : null} +
+
+ ); +} + +function ChurnRateResults({ result, resultRef }: { result: ChurnConversion; resultRef: RefObject }) { + const { source, rows, leadPeriod, yearOf100, lifetime } = result; + const lead = rows.find((row) => row.period === leadPeriod)!; + const exactEnds = source.hundredths === 0 || source.hundredths === 10000; + + function formatOf100(value: number | bigint) { + const count = Number(value); + if (count === 0 && (yearOf100.lostUnderOne || yearOf100.remainingUnderOne)) return "fewer than 1"; + return exactEnds ? String(count) : `about ${count}`; + } + + return ( +
+ + + ({ + id: row.period, + cells: { + period: PERIOD_TITLE[row.period], + churn: row.isSource + ? {formatPercent(row.churn)} (entered) + : formatPercent(row.churn), + retention: formatPercent(row.retention), + linear: row.linearEstimate + ? formatLinear(row.linearEstimate) + : <>Not applicable, + }, + }))} + /> + +
+

Out of 100 customers, after one year

+ +
+ + + +
+

Assumes the same share of your remaining customers leaves every {source.period}.

+ {source.period === "year" + ?

Annual-plan customers usually leave only at renewal, not each month.

+ : null} +
+
+ ); +} diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/content/article.mdx b/app/(site)/(tools)/(saas)/churn-rate-converter/content/article.mdx new file mode 100644 index 00000000..ef3bf436 --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/content/article.mdx @@ -0,0 +1,59 @@ +## How do you convert monthly churn to annual churn? + +Multiply the share of customers who stay each month by itself 12 times, then subtract from 1. + + + +Annual churn = 1 - (1 - monthly churn)^12 + + + +Other periods work the same way: the power 4 takes quarterly to yearly, and 3 takes monthly to quarterly. Going back uses a root, such as the 12th root for yearly to monthly. + +This assumes the same share of the customers you still have leaves every period. [ChartMogul's churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate) uses the same rule: 5% a month is about 46% a year. + +## Why not multiply monthly churn by 12? + +Because each month you lose a share of a shrinking group. Start with 100 customers at 5% a month. You lose 5 in month one, 4.75 of the 95 left in month two, and 4.51 of the 90.25 left in month three. After 12 months about 46 are gone, not 60. + +Multiplying always overstates yearly churn, more so as churn rises. At 10% a month, × 12 says 120%, more customers than you had. Dividing goes wrong the other way: 20% a year ÷ 12 is 1.67% a month, but the real monthly equivalent is 1.84%. + +## Monthly to annual churn table + +| Monthly churn | Annual churn | Monthly × 12 | +| --- | --- | --- | +| 1% | 11.36% | 12% | +| 2% | 21.53% | 24% | +| 3% | 30.62% | 36% | +| 5% | 45.96% | 60% | +| 7% | 58.14% | 84% | +| 10% | 71.76% | 120% | +| 15% | 85.78% | 180% | + +Annual figures assume the monthly rate holds all year. + +## Is annualized churn the same as your real annual churn? + +No. Annualized churn is what a steady monthly rate adds up to over a year. Real losses differ: new customers often leave faster than older ones, and churn moves with season, plan, and customer mix. + +If you tracked one group of customers for a full year, or have renewal results, trust those more. [ChartMogul's cohort analysis](https://help.chartmogul.com/article/161-cohort-analysis) shows how retention changes as customers age. + +Yearly plans are the clearest case. Those customers usually decide once, when the plan renews. If 20% don't renew, the monthly equivalent is 1.84%, but that doesn't mean 1.84% can cancel each month. [ChartMogul on billing cycles](https://chartmogul.com/saas-metrics/billing-cycles/) explains why. The [annual plan discount calculator](/annual-plan-discount-calculator) compares the two plans with a separate renewal rate for yearly customers. + +## How long does an average customer stay? + +Divide 1 by your churn rate, in the period you entered. At 5% monthly churn it's 20 months. At 20% annual churn it's 5 years. + +It's a rough estimate from the same steady-rate model, not real tenure, and there's no answer at 0% churn. The [customer lifetime value calculator](/customer-lifetime-value-calculator) uses it to estimate what a customer is worth. The [MRR goal calculator](/mrr-goal-calculator) uses monthly churn to show how many customers you can keep. Both need churn for the right period, which this tool gives you. + +## Can you convert revenue churn or negative churn? + +Not here. This tool counts customers. Revenue churn counts revenue lost when customers cancel or move to a cheaper plan, so one big customer counts for more than a small one. + +Net revenue churn also subtracts revenue from upgrades, so it can drop below 0%. That means revenue from existing customers grew, not that customers came back, so it has no customer lifetime or "out of 100 customers" view. [Stripe's revenue churn guide](https://stripe.com/resources/more/revenue-churn-101-how-to-calculate-it-and-why-it-matters) explains both. For revenue, use the [revenue churn calculator](/revenue-churn-calculator) or the [net revenue retention calculator](/net-revenue-retention-calculator). + +## How do you compare your churn with a benchmark? + +Convert to the same period first, and check that the benchmark counts customers, not revenue. A 4% monthly rate and a 5% to 7% yearly benchmark can look close, but 4% a month is about 39% a year. + +This tool doesn't rate churn as good or bad. Published ranges change with price, contract length, and customer type, so compare with your own past numbers first. diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/content/intro.mdx b/app/(site)/(tools)/(saas)/churn-rate-converter/content/intro.mdx new file mode 100644 index 00000000..457d339a --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/content/intro.mdx @@ -0,0 +1,3 @@ +This monthly to annual churn rate calculator takes one customer churn rate and shows it as a monthly, quarterly, and yearly rate. It works both ways, so a yearly rate gives you a monthly one too. + +You need one churn rate and the period it covers. If you only have customer counts, get the rate from the [customer churn calculator](/customer-churn-calculator) first. diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/content/usage.mdx b/app/(site)/(tools)/(saas)/churn-rate-converter/content/usage.mdx new file mode 100644 index 00000000..0ce50116 --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/content/usage.mdx @@ -0,0 +1,19 @@ +## What to enter + +Enter your customer churn rate: the share of customers you had at the start of a period who were gone by its end. Use the number your billing tool or dashboard shows. + +Then pick the period that rate covers. Check your report first. If you enter a quarterly rate as monthly, every result comes out far too high. + +## How to read the result + + + +Annualized churn is 45.96%, so 54.04% of customers are still with you after a year. Quarterly churn is 14.26%. Out of 100 customers, about 46 leave in a year and about 54 stay. Average lifetime is 20 months. + + + + +What you'd get by just multiplying or dividing, such as 5% × 12 = 60%. It's there to compare, not to use. +The model's expected count. A real group of 100 can lose a few more or fewer. +How long a customer stays on average, in the period you entered. It's a rough model estimate, not your customers' real tenure. + diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/layout.tsx b/app/(site)/(tools)/(saas)/churn-rate-converter/layout.tsx new file mode 100644 index 00000000..3f9f741f --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/layout.tsx @@ -0,0 +1,6 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/churn-rate-converter"); +export const generateMetadata = registry.generateMetadata; +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(tools)/(saas)/churn-rate-converter/page.tsx b/app/(site)/(tools)/(saas)/churn-rate-converter/page.tsx new file mode 100644 index 00000000..763b265c --- /dev/null +++ b/app/(site)/(tools)/(saas)/churn-rate-converter/page.tsx @@ -0,0 +1,25 @@ +import { CalendarSync } from "lucide-react"; +import { ToolPageShell } from "@/app/ui/tools/tool-page-shell"; +import { ToolPageArticle, ToolPageHeader } from "@/app/ui/site/tool-page-content"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { getRequiredToolPage } from "@/lib/tool-pages"; +import { ChurnRateConverter } from "./churn-rate-converter"; +import Article from "./content/article.mdx"; +import Intro from "./content/intro.mdx"; +import Usage from "./content/usage.mdx"; + +export default async function ChurnRateConverterPage() { + const toolPage = await getRequiredToolPage("churn-rate-converter"); + return ( + + + } intro={} toolPage={toolPage} /> + + + +
+ + + + ); +} diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.spec.json b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.spec.json new file mode 100644 index 00000000..fd6100c1 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.spec.json @@ -0,0 +1,13 @@ +{ + "id": "free-trial-conversion-calculator--tool-spot", + "type": "toolSpot", + "takeaway": "From a group that started together, a few paid before the window closed.", + "source": "conceptual figure, no external data", + "dataAsOf": "2026-10-10", + "width": 140, + "height": 110, + "seed": 127, + "data": { + "motif": "trialConversion" + } +} diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.svg b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.svg new file mode 100644 index 00000000..061af05d --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.svg @@ -0,0 +1,23 @@ + +From a group that started together, a few paid before the window closed. + + + + + + + + + + + + + + + + + + + + + diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.ts b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.ts new file mode 100644 index 00000000..0d421a29 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator.ts @@ -0,0 +1,25 @@ +// generated by scripts/stack127/marker-art/generate.mjs. do not edit. +// edit the sibling .spec.json and rerun: npm run stack127:marker-art +export const freeTrialConversionCalculatorArt = ` +From a group that started together, a few paid before the window closed. + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/article.mdx b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/article.mdx new file mode 100644 index 00000000..9c09e2b3 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/article.mdx @@ -0,0 +1,74 @@ +## How is free trial conversion rate calculated? + +Take one cohort. Divide the accounts in it that paid within your window by all the accounts that started in it. Both numbers must come from the same group. [ChartMogul's trial-to-paid chart](https://help.chartmogul.com/article/219-chart-trial-to-paid-conversion-rate) counts the same way. + + + +Rate = accounts in the cohort that paid within the window ÷ accounts that started in the cohort × 100 + + + +A common mistake is dividing this month's payments by this month's starts. Someone who starts a trial on August 25 may pay in September, and that count misses them. + + + +6 of them paid during August. By day 30 of each person's trial, 20 had paid. + +August payments ÷ August starts gives 6%. The cohort rate is 20%, because the 14 who paid in September started in August. + + + +The 20% is final only once someone who started on August 31 has had all 30 days. Until then, the tool marks it `Provisional`. + +## What counts as a paid conversion? + +An account converts when its first payment goes through. These don't count: + +- A card saved at signup. It isn't a payment until the charge succeeds. +- A pending payment or a failed first invoice. + +In Stripe, check that the first invoice after the trial was paid, not only that the [subscription](https://docs.stripe.com/api/subscriptions/object) moved out of `trialing`. Count accounts, not subscriptions: a customer with two plans counts once. + +## Why doesn't this match Stripe's trial conversion rate? + +[Stripe Billing's trial conversion rate](https://support.stripe.com/questions/calculating-trial-conversion-rate-in-billing?locale=en-GB) compares trials that converted in the last 30 days with trials that ended in the last 30 days. Those aren't the same people, so Stripe notes its rate can go above 100%. + +That number helps you spot a recent change. This calculator answers a different question: of the people who started together, how many paid? + +## What is a good free trial conversion rate? + +There's no single good rate. Asking for a card upfront makes a big difference. Your product, price, trial length, and where signups come from matter too. These dated sources split their figures by trial type. + +| Source | Free trial, no card | Free trial, card upfront | Free plan | +| --- | --- | --- | --- | +| ChartMogul, Growth Unhinged, and ProductLed | Median 4 to 6%. Top quarter 10 to 15%. | Median 25 to 35%. Top quarter 50 to 60%. | Median 3 to 5%. Top quarter 8 to 12%. | +| First Page Sage | 17.4 to 18.2% | 48.8 to 51% | 2.6 to 2.8% | +| Recurly | 12% | 39% | Not reported | + +The median is the middle product in the survey. A product at the top-quarter figure did better than three in four others. + +Who each source measured: + +- [ChartMogul, Growth Unhinged, and ProductLed 2026 SaaS Conversion Report](https://chartmogul.com/reports/saas-conversion-report/): a January 2026 survey of 200 software products, self-reported. A typical respondent had $1m to $10m in ARR. All models counted conversions within six months. +- [First Page Sage](https://firstpagesage.com/seo-blog/saas-free-trial-conversion-rate-benchmarks/), updated September 2025: 86 of the agency's clients from 2022 to 2025, split by organic and paid traffic. Converted means one paid month. +- [Recurly State of Subscriptions](https://recurly.com/resources/report/state-of-subscriptions-acquisition/), 2022 data: 22 million trials on 803 subscription sites, not only SaaS. Trials that asked for a card averaged 29 days. Trials that didn't averaged 39 days. Recurly counted subscriptions, not accounts, so one account could count more than once. + + + +Each source counts conversion its own way, so they don't agree. None of these sources reports figures for small indie SaaS by trial type and follow-up window. + + + +A higher rate with a card upfront doesn't tell you which setup brings more customers. Fewer people may start when you ask for a card, so compare how many paid, not only the rate. + +## How is a free plan different from a free trial? + +A free plan has no end date, so people can upgrade months after they sign up. Use a longer window, such as 180 days, and don't compare a free plan rate with a trial rate. 180 days matches the six months the ChartMogul report used. + +## Is new MRR the same as revenue? + +No. New MRR is the monthly recurring amount these customers added, not the cash you collected. [ChartMogul's MRR definition](https://chartmogul.com/saas-metrics/mrr/) counts a $120 annual plan as $10 a month and leaves out one-time fees, such as setup charges. + +The ARR run rate is new MRR × 12. It isn't what you'll earn this year, because some of these customers will cancel. The tool counts only customers who paid and puts no price on those who didn't. + +To total MRR across all your plans, use the [MRR calculator](/mrr-calculator). The [customer acquisition cost calculator](/customer-acquisition-cost-calculator) shows what each new paying customer cost to win. The [customer churn calculator](/customer-churn-calculator) shows how many of them stay. diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/intro.mdx b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/intro.mdx new file mode 100644 index 00000000..99b07019 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/intro.mdx @@ -0,0 +1,3 @@ +See what share of the people who started a free trial, or a free plan, paid within a set number of days. The calculator also tells you if the group is old enough to trust. + +Before you start, count how many accounts started in a month and how many of those same accounts have paid. Add a price to see the new MRR they brought in. diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/usage.mdx b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/usage.mdx new file mode 100644 index 00000000..13c3c1fe --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/content/usage.mdx @@ -0,0 +1,33 @@ +## What to enter + +Pick the option that matches how people start. If they enter a card before the trial begins, pick `Free trial, card upfront`, even if you charge only at the end. + +A cohort is everyone who started in the same period, usually one month. Get the starts from your billing tool or your signup table. For paid, count only accounts from that same cohort whose first payment went through. + +The follow-up window is how many days each account gets to pay, counted from the day it started. For a 14-day trial, a 30-day window also catches people who pay a little late. Use the same window every time you compare. + +Cohort age is how many days have passed since the last account in the cohort started. An August cohort counted on October 15 is 45 days old. + +For new MRR, enter recurring prices only, without setup fees. With annual plans, pick `Monthly and annual plans` and enter the full yearly price. The tool divides it by 12. + +## How to read the result + +`Complete` means every account in the cohort had the full window to pay. `Provisional` means some still have days left, so the rate can still go up. + +With several cohorts, the main rate is the total paid divided by the total starts of the complete cohorts. It isn't the average of each row's rate. A cohort with days left still shows in the table, but stays out of the main rate as long as the complete cohorts have at least one start. If they have none, the tool uses all cohorts and marks the rate `Provisional`. + +With one cohort, the result also shows how many didn't pay within the window. Some may still pay later. + + + +| Cohort | Starts | Paid | Age | Rate | +| --- | --- | --- | --- | --- | +| July | 400 | 60 | 76 days | 15.00% | +| August | 500 | 70 | 45 days | 14.00% | +| September | 450 | 30 | 15 days | 6.67%, 15 days left | + +The main rate is 14.44%: 130 paid out of 900 starts in July and August. September's 6.67% isn't ready to compare yet. Its last starts have had only 15 of their 30 days. + +At an average price of $29 a month, the 160 paid customers from all three months add $4,640.00 of new MRR. September's 30 count here, because they've already paid. + + diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/free-trial-conversion-calculator.module.css b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/free-trial-conversion-calculator.module.css new file mode 100644 index 00000000..75bc50b7 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/free-trial-conversion-calculator.module.css @@ -0,0 +1,195 @@ +.workbench { + background: var(--color-surface); + border-block: 1px solid var(--color-border-strong); + grid-column: 1 / -1; + margin-inline: calc(-1 * var(--container-gutter)); + min-width: 0; + padding: var(--space-7) var(--container-gutter); + width: calc(100% + var(--container-gutter) + var(--container-gutter)); +} + +.content, +.form, +.results { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +.content { + gap: var(--space-7); + margin-inline: auto; + max-width: 66rem; +} + +.group, +.resultGroup, +.mrr { + display: grid; + gap: var(--space-4); + min-width: 0; +} + +.group h2, +.resultGroup h2 { + font-family: var(--font-display); + font-size: var(--text-h3); + font-weight: var(--weight-bold); + line-height: var(--line-heading); + margin: 0; +} + +.groupTitle { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.helper { + color: var(--color-text-muted); + font-size: var(--text-small); + line-height: var(--line-body); + margin: 0; +} + +.window { + max-width: 22rem; + min-width: 0; +} + +.cohortList :global([data-repeatable-row]) { + align-items: start; +} + +/* The shared tooltip uses max-content. A long cohort name then widens the page. */ +.cohortList :global([data-repeatable-row]) > :global(button[aria-label])::after { + content: none; + overflow: hidden; + white-space: normal; + width: 16rem; +} + +.cohortList :global([data-repeatable-row]) > :global(button[aria-label]):hover::after, +.cohortList :global([data-repeatable-row]) > :global(button[aria-label]):focus-visible::after { + content: attr(aria-label); +} + +/* The remove action lines up with the inputs, below the visible labels. */ +.cohortList :global([data-repeatable-row]) > :global(button) { + margin-top: calc(var(--text-body) * var(--line-ui) + var(--space-2)); +} + +.cohort { + align-items: start; + border: 0; + display: grid; + gap: var(--space-4); + grid-template-columns: minmax(0, 1.2fr) repeat(3, minmax(0, 1fr)); + margin: 0; + min-width: 0; + padding: 0; +} + +.cohort > * { + min-width: 0; +} + +.visuallyHidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.mrrSettings { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: minmax(8rem, 10rem) minmax(0, 1fr); + min-width: 0; +} + +.mrrSettings > * { + min-width: 0; +} + +.fieldGrid { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-width: 0; +} + +.fieldGrid > * { + min-width: 0; +} + +.results { + border-top: 1px solid var(--color-border); + overflow-wrap: anywhere; + padding-top: var(--space-6); +} + +.results :global(th), +.results :global(td) { + overflow-wrap: anywhere; +} + +@media (min-width: 761px) { + .results :global(table) { + table-layout: fixed; + width: 100%; + } +} + +.valueLines { + display: grid; + gap: var(--space-1); +} + +@media (max-width: 760px) { + .workbench { + padding-block: var(--space-5); + } + + .content { + gap: var(--space-6); + } + + .window { + max-width: none; + } + + .mrrSettings, + .fieldGrid { + grid-template-columns: minmax(0, 1fr); + } + + .currency { + max-width: 12rem; + } + + .cohortList :global([data-repeatable-row]) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-4); + } + + .cohortList :global([data-repeatable-row]:last-child) { + border-bottom: 0; + padding-bottom: 0; + } + + .cohortList :global([data-repeatable-row]) > :global(button) { + margin-top: 0; + } + + /* One column keeps the long "Paid within N days" label from pushing its input out of line. */ + .cohort { + gap: var(--space-3); + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/free-trial-conversion-calculator.tsx b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/free-trial-conversion-calculator.tsx new file mode 100644 index 00000000..6fb718d5 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/free-trial-conversion-calculator.tsx @@ -0,0 +1,599 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useRef, useState, type FormEvent, type RefObject } from "react"; +import { UiButton, UiCheckbox, UiChoiceGroup, UiRepeatableList, UiSelect, UiTextField, uiOption } from "@/app/ui/controls"; +import { + UiActionRow, + UiDataTable, + UiKeyValueList, + UiPrimaryResult, + UiStatus, + UiStatusWord, +} from "@/app/ui/display"; +import { + TRIAL_MAX_COHORTS, + calculateFreeTrialConversion, + type FreeTrialConversionErrors, + type FreeTrialConversionInput, + type FreeTrialConversionValues, + type TrialCohortField, + type TrialCohortInput, + type TrialConversionRate, + type TrialFieldKey, + type TrialModel, + type TrialMrrMethod, +} from "@/lib/free-trial-conversion-calculator/engine"; +import { SAAS_ECONOMICS_CURRENCIES, type SaasEconomicsCurrency } from "@/lib/saas-economics/shared"; +import { PUBLISHED_FIGURES } from "./published-figures"; +import styles from "./free-trial-conversion-calculator.module.css"; + +type StatusMessage = { text: string; tone: "neutral" | "danger" }; +type CohortDraft = TrialCohortInput & { id: string }; +type FormFields = Omit; + +const CHANGED = "Inputs changed. Calculate again."; + +const MODEL_OPTIONS = [ + uiOption("trialNoCard", "Free trial, no card", "They try it without entering a card."), + uiOption("trialCardRequired", "Free trial, card upfront", "They enter a card to start, and they're charged when the trial ends unless they cancel."), + uiOption("freemium", "Free plan", "They sign up for a free plan with no end date."), +]; + +function emptyCohort(id: string): CohortDraft { + return { id, label: "", starts: "", paid: "", ageDays: "" }; +} + +function emptyFields(): FormFields { + return { + model: "trialNoCard", + windowDays: "", + includeMrr: false, + currency: "USD", + mrrMethod: "average", + averageMonthly: "", + monthlyCustomers: "", + monthlyPrice: "", + annualCustomers: "", + annualPrice: "", + }; +} + +/* ---------------- formatting ---------------- */ + +const integerFormat = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); + +function formatInteger(value: bigint | number): string { + return integerFormat.format(value); +} + +function currencySymbol(currency: SaasEconomicsCurrency): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value ?? currency; +} + +function formatMoney(cents: bigint, currency: SaasEconomicsCurrency): string { + const fraction = (cents % 100n).toString().padStart(2, "0"); + return `${currencySymbol(currency)}${formatInteger(cents / 100n)}.${fraction}`; +} + +function formatRate(rate: TrialConversionRate): string { + if (rate.kind === "notAvailable") return "Not available"; + if (rate.roundedToZero) return "Under 0.01%"; + const fraction = (rate.hundredths % 100n).toString().padStart(2, "0"); + return `${formatInteger(rate.hundredths / 100n)}.${fraction}%`; +} + +function days(count: bigint | number): string { + return `${formatInteger(count)} ${count === 1n || count === 1 ? "day" : "days"}`; +} + +function plural(count: number, one: string, many: string): string { + return `${formatInteger(count)} ${count === 1 ? one : many}`; +} + +function cohortName(label: string, index: number): string { + return label.trim() || `Cohort ${index + 1}`; +} + +function windowLabel(raw: string): string | null { + const text = raw.trim(); + if (!/^\d+$/.test(text)) return null; + const value = Number(text); + return value >= 1 && value <= 730 ? days(value) : null; +} + +function nouns(model: TrialModel) { + return model === "freemium" + ? { count: "Free signups", column: "Signups", lower: "free signups", lead: "Free plan to paid", none: "No signups", figures: "Published rates for free plans" } + : { count: "Trial starts", column: "Starts", lower: "trial starts", lead: "Free trial to paid", none: "No starts", figures: model === "trialCardRequired" ? "Published rates for trials with a card upfront" : "Published rates for trials with no card" }; +} + +/* ---------------- results ---------------- */ + +function leadLines(values: FreeTrialConversionValues): { detail: string; maturity: { word: string; tone: "neutral" | "warning"; text: string } | null } { + const { combined, cohorts, model } = values; + const noun = nouns(model); + const window = days(values.windowDays); + const single = cohorts.length === 1; + const included = combined.includedIndexes.length; + + let detail: string; + if (combined.rate.kind === "notAvailable") { + detail = `${noun.none} in ${single ? "this cohort" : "these cohorts"}, so there's no rate.`; + } else if (single) { + detail = `${formatInteger(combined.paid)} of ${formatInteger(combined.starts)} ${noun.lower} paid within ${window}. ${formatInteger(combined.notPaid)} didn't.`; + } else if (combined.basis === "complete") { + detail = `${formatInteger(combined.paid)} of ${formatInteger(combined.starts)} ${noun.lower} in ${plural(included, "complete cohort", "complete cohorts")} paid within ${window}.`; + if (combined.excludedMaturing > 0) { + detail += combined.excludedMaturing === 1 + ? " 1 newer cohort is left out until its window ends." + : ` ${formatInteger(combined.excludedMaturing)} newer cohorts are left out until their windows end.`; + } + } else { + detail = `${formatInteger(combined.paid)} of ${formatInteger(combined.starts)} ${noun.lower} across ${plural(included, "cohort", "cohorts")} paid within ${window}.`; + } + + if (combined.rate.kind === "notAvailable") return { detail, maturity: null }; + if (combined.status === "complete") { + return { + detail, + maturity: { + word: "Complete", + tone: "neutral", + text: single ? `Every account had the full ${window}.` : `Every account in these cohorts had the full ${window}.`, + }, + }; + } + const first = cohorts[0]; + const text = single && first.maturity.status === "maturing" + ? `The last accounts to start have ${formatInteger(first.maturity.daysLeft)} more ${first.maturity.daysLeft === 1n ? "day" : "days"} to pay. This rate can still rise.` + : `No cohort with starts has had the full ${window} yet.`; + return { detail, maturity: { word: "Provisional", tone: "warning", text } }; +} + +function TrialResults({ resultRef, values }: { resultRef: RefObject; values: FreeTrialConversionValues }) { + const noun = nouns(values.model); + const lead = leadLines(values); + const several = values.cohorts.length > 1; + const label = several && values.combined.basis === "complete" ? `${noun.lead}, complete cohorts` : noun.lead; + const figures = PUBLISHED_FIGURES.filter((source) => source.figures[values.model]); + const mrr = values.newMrr; + const anyMaturing = values.cohorts.some((cohort) => cohort.maturity.status === "maturing"); + + const mrrRows = mrr ? [ + { label: "New MRR", value: formatMoney(mrr.mrrCents, mrr.currency) }, + { + label: "How", + value: mrr.method === "average" + ? `${plural(Number(mrr.paidCustomers), "paid customer", "paid customers")} × ${formatMoney(mrr.averageMonthlyCents ?? 0n, mrr.currency)}` + : `${formatInteger(mrr.monthlyPlan?.customers ?? 0n)} monthly × ${formatMoney(mrr.monthlyPlan?.priceCents ?? 0n, mrr.currency)} + ${formatInteger(mrr.annualPlan?.customers ?? 0n)} annual × ${formatMoney(mrr.annualPlan?.annualPriceCents ?? 0n, mrr.currency)} ÷ 12`, + }, + { + label: "ARR run rate", + value: ( + + {formatMoney(mrr.runRateArrCents, mrr.currency)} + New MRR × 12. Not what you'll earn this year. + + ), + }, + ] : []; + const mrrNotes = mrr ? [ + mrr.mrrIsRounded ? "Rounded to the cent." : null, + anyMaturing ? "Includes customers from cohorts that still have days left." : null, + ].filter((note): note is string => note !== null) : []; + + return ( +
+ + {lead.detail} + {lead.maturity ? ( + + {lead.maturity.word}{" "} + {lead.maturity.text} + + ) : null} + + )} + inputRef={resultRef} + label={label} + value={formatRate(values.combined.rate)} + /> + + {several ? ( +
+

By cohort

+ ({ + id: String(cohort.index), + cells: { + cohort: cohortName(cohort.label, cohort.index), + starts: formatInteger(cohort.starts), + paid: formatInteger(cohort.paid), + rate: formatRate(cohort.rate), + status: cohort.maturity.status === "complete" + ? Complete + : {`${days(cohort.maturity.daysLeft)} left`}, + }, + }))} + /> +
+ ) : null} + + {mrr ? ( +
+

New MRR from these customers

+ + {mrrNotes.length > 0 ?

{mrrNotes.join(" ")}

: null} +
+ ) : null} + +
+

{noun.figures}

+ ({ + id: source.id, + cells: { + source: ( + + {source.name} + {source.date} + + ), + measured: source.measured, + reported: source.figures[values.model], + }, + }))} + /> +

+ Rough guides, not targets. Each source counts conversion its own way. None of these sources reports figures for small indie SaaS by trial type and follow-up window. +

+
+
+ ); +} + +/* ---------------- form ---------------- */ + +export function FreeTrialConversionCalculator() { + const [cohorts, setCohorts] = useState(() => [emptyCohort("cohort-1")]); + const [fields, setFields] = useState(emptyFields); + const [errors, setErrors] = useState({}); + const [result, setResult] = useState(null); + const [status, setStatus] = useState(null); + const nextCohortId = useRef(2); + const formRef = useRef(null); + const resultRef = useRef(null); + + const model = fields.model as TrialModel; + const noun = nouns(model); + const paidLabel = windowLabel(fields.windowDays); + const atLimit = cohorts.length >= TRIAL_MAX_COHORTS; + const split = fields.mrrMethod === "split"; + + function clearForEdit(announce?: string) { + const hadResult = result !== null; + setErrors({}); + setResult(null); + if (hadResult) setStatus({ text: CHANGED, tone: "neutral" }); + else if (announce) setStatus({ text: announce, tone: "neutral" }); + else if (status?.text !== CHANGED) setStatus(null); + } + + function updateField(field: K, value: FormFields[K]) { + setFields((current) => ({ ...current, [field]: value })); + clearForEdit(); + } + + function updateCohort(id: string, field: TrialCohortField, value: string) { + setCohorts((current) => current.map((cohort) => cohort.id === id ? { ...cohort, [field]: value } : cohort)); + clearForEdit(); + } + + function focusKey(key: string) { + requestAnimationFrame(() => { + formRef.current?.querySelector(`[data-focus-key="${key}"]`)?.focus(); + }); + } + + function addCohort() { + if (atLimit) return; + const cohort = emptyCohort(`cohort-${nextCohortId.current++}`); + setCohorts((current) => current.length >= TRIAL_MAX_COHORTS ? current : [...current, cohort]); + clearForEdit(`Cohort ${cohorts.length + 1} added.`); + } + + function removeCohort(id: string) { + const index = cohorts.findIndex((cohort) => cohort.id === id); + const name = index >= 0 ? cohortName(cohorts[index].label, index) : "Cohort"; + if (cohorts.length === 1) { + // The list always keeps one row, so removing the only cohort clears it. + const cohort = emptyCohort(`cohort-${nextCohortId.current++}`); + setCohorts([cohort]); + clearForEdit(`${name} cleared.`); + focusKey("cohorts.0.label"); + return; + } + setCohorts((current) => current.filter((cohort) => cohort.id !== id)); + clearForEdit(`${name} removed.`); + } + + function focusFirstError(nextErrors: FreeTrialConversionErrors) { + const order: TrialFieldKey[] = [ + "model", + "windowDays", + ...cohorts.flatMap((_, index) => (["label", "starts", "paid", "ageDays"] as const) + .map((field) => `cohorts.${index}.${field}` as const)), + "cohorts", + "currency", + "mrrMethod", + ...(split + ? ["monthlyCustomers", "monthlyPrice", "annualCustomers", "annualPrice"] as const + : ["averageMonthly"] as const), + ]; + const first = order.find((key) => nextErrors[key]); + if (!first) return; + if (first === "cohorts") { + requestAnimationFrame(() => formRef.current?.querySelector("[data-repeatable-add]")?.focus()); + } else { + focusKey(first); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + setStatus(null); + try { + const calculation = calculateFreeTrialConversion({ + ...fields, + cohorts: cohorts.map(({ label, starts, paid, ageDays }) => ({ label, starts, paid, ageDays })), + }); + if (calculation.status === "invalid") { + setErrors(calculation.errors); + setResult(null); + if (calculation.errors.form) setStatus({ text: calculation.errors.form, tone: "danger" }); + focusFirstError(calculation.errors); + return; + } + setErrors({}); + setResult(calculation.values); + requestAnimationFrame(() => resultRef.current?.focus()); + } catch { + setErrors({}); + setResult(null); + setStatus({ text: "Couldn't calculate. Try again.", tone: "danger" }); + } + } + + function reset() { + setCohorts([emptyCohort(`cohort-${nextCohortId.current++}`)]); + setFields(emptyFields()); + setErrors({}); + setResult(null); + setStatus({ text: "Form reset.", tone: "neutral" }); + focusKey("model"); + } + + const listNote = errors.cohorts ?? (atLimit ? `Up to ${TRIAL_MAX_COHORTS} cohorts` : undefined); + + function moneyField(field: "averageMonthly" | "monthlyPrice" | "annualPrice", label: string) { + return ( + updateField(field, value)} + required + spellCheck={false} + suffix={fields.currency} + value={fields[field]} + /> + ); + } + + function countField(field: "monthlyCustomers" | "annualCustomers", label: string) { + return ( + updateField(field, value)} + required + spellCheck={false} + value={fields[field]} + /> + ); + } + + return ( +
+
+
+ { + if (!MODEL_OPTIONS.some((option) => option.id === value)) return; + updateField("model", value); + }} + options={MODEL_OPTIONS} + required + value={fields.model} + /> + +
+ updateField("windowDays", value)} + required + spellCheck={false} + suffix="days" + value={fields.windowDays} + /> +
+ +
+
+

Cohorts

+

A cohort is everyone who started in the same period, such as one month. Count each account once. Paid means the first payment went through. A card on file isn't a payment.

+
+
+ `Remove ${cohortName(cohort.label, index)}`} + renderItem={(cohort, index) => ( +
+ {cohortName(cohort.label, index)} + updateCohort(cohort.id, "label", value)} + placeholder="Aug 2026" + value={cohort.label} + /> + updateCohort(cohort.id, "starts", value)} + required + spellCheck={false} + value={cohort.starts} + /> + updateCohort(cohort.id, "paid", value)} + required + spellCheck={false} + value={cohort.paid} + /> + updateCohort(cohort.id, "ageDays", value)} + required + spellCheck={false} + suffix="days" + value={cohort.ageDays} + /> +
+ )} + /> +
+
+ +
+ updateField("includeMrr", selected)}> + Add new MRR from these customers + + {fields.includeMrr ? ( +
+
+
+ { + if (!SAAS_ECONOMICS_CURRENCIES.includes(value as SaasEconomicsCurrency)) return; + updateField("currency", value); + }} + options={SAAS_ECONOMICS_CURRENCIES.map((currency) => uiOption(currency, currency))} + required + value={fields.currency} + /> +
+ { + if (value !== "average" && value !== "split") return; + updateField("mrrMethod", value satisfies TrialMrrMethod); + }} + options={[uiOption("average", "One average amount"), uiOption("split", "Monthly and annual plans")]} + required + value={fields.mrrMethod} + /> +
+ {split ? ( + <> +
+ {countField("monthlyCustomers", "Monthly-plan customers")} + {moneyField("monthlyPrice", "Average monthly price")} +
+
+ {countField("annualCustomers", "Annual-plan customers")} + {moneyField("annualPrice", "Average annual price")} +
+ + ) : ( +
{moneyField("averageMonthly", "Average monthly price")}
+ )} +

Recurring price only. Leave out setup and other one-time fees.

+
+ ) : null} +
+ + + Calculate conversion + + + + {status ? {status.text} : null} + + {result ? : null} +
+
+ ); +} diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/layout.tsx b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/layout.tsx new file mode 100644 index 00000000..437613be --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/layout.tsx @@ -0,0 +1,6 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/free-trial-conversion-calculator"); +export const generateMetadata = registry.generateMetadata; +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/page.tsx b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/page.tsx new file mode 100644 index 00000000..b0bd5fbe --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/page.tsx @@ -0,0 +1,25 @@ +import { Hourglass } from "lucide-react"; +import { ToolPageShell } from "@/app/ui/tools/tool-page-shell"; +import { ToolPageArticle, ToolPageHeader } from "@/app/ui/site/tool-page-content"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { getRequiredToolPage } from "@/lib/tool-pages"; +import { FreeTrialConversionCalculator } from "./free-trial-conversion-calculator"; +import Article from "./content/article.mdx"; +import Intro from "./content/intro.mdx"; +import Usage from "./content/usage.mdx"; + +export default async function FreeTrialConversionCalculatorPage() { + const toolPage = await getRequiredToolPage("free-trial-conversion-calculator"); + return ( + + + } intro={} toolPage={toolPage} /> + + + +
+ + + + ); +} diff --git a/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/published-figures.ts b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/published-figures.ts new file mode 100644 index 00000000..13f70c60 --- /dev/null +++ b/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/published-figures.ts @@ -0,0 +1,50 @@ +import type { TrialModel } from "@/lib/free-trial-conversion-calculator/engine"; + +// Copied from content_docs/free-trial-conversion-calculator/research.md, checked 2026-10-10. +// Each source counts conversion its own way, so these are directional only. +export type PublishedFigureSource = { + id: string; + name: string; + url: string; + date: string; + measured: string; + figures: Partial>; +}; + +export const PUBLISHED_FIGURES: readonly PublishedFigureSource[] = [ + { + id: "chartmogul-2026", + name: "ChartMogul, Growth Unhinged, and ProductLed", + url: "https://chartmogul.com/reports/saas-conversion-report/", + date: "Survey, January 2026", + measured: "200 software products, self-reported. A typical respondent had $1m to $10m in ARR. B2B, consumer, and hybrid. All models counted conversions within six months.", + figures: { + trialNoCard: "Median 4 to 6%. Top quarter 10 to 15%.", + trialCardRequired: "Median 25 to 35%. Top quarter 50 to 60%.", + freemium: "Median 3 to 5%. Top quarter 8 to 12%.", + }, + }, + { + id: "first-page-sage-2025", + name: "First Page Sage", + url: "https://firstpagesage.com/seo-blog/saas-free-trial-conversion-rate-benchmarks/", + date: "Updated September 2025", + measured: "86 of the agency's clients, 2022 to 2025. 71% B2B. Converted means one paid month.", + figures: { + trialNoCard: "18.2% from organic, 17.4% from paid ads.", + trialCardRequired: "48.8% from organic, 51% from paid ads.", + freemium: "2.6% from organic, 2.8% from paid ads.", + }, + }, + { + id: "recurly-2022", + name: "Recurly", + url: "https://recurly.com/resources/report/state-of-subscriptions-acquisition/", + date: "2022 data", + measured: "22 million trials on 803 subscription sites, not only SaaS. Average trials ran 29 days with a card and 39 without. Recurly counted subscriptions, not accounts, so one account could count more than once.", + figures: { + trialNoCard: "12% average across sites.", + trialCardRequired: "39% average across sites.", + }, + }, +]; diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.spec.json b/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.spec.json new file mode 100644 index 00000000..29c292e8 --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.spec.json @@ -0,0 +1,13 @@ +{ + "id": "catalog--mrr-goal-calculator", + "type": "toolSpot", + "takeaway": "A monthly climb of paying customers meets a set revenue target.", + "source": "Conceptual figure, no external data.", + "dataAsOf": "2026-10-10", + "width": 140, + "height": 110, + "seed": 12761, + "data": { + "motif": "goalFlag" + } +} diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.svg b/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.svg new file mode 100644 index 00000000..0fc2d0a2 --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.svg @@ -0,0 +1,29 @@ + +A monthly climb of paying customers meets a set revenue target. + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.ts b/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.ts new file mode 100644 index 00000000..434afee0 --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.ts @@ -0,0 +1,31 @@ +// generated by scripts/stack127/marker-art/generate.mjs. do not edit. +// edit the sibling .spec.json and rerun: npm run stack127:marker-art +export const mrrGoalCalculatorArt = ` +A monthly climb of paying customers meets a set revenue target. + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/article.mdx b/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/article.mdx new file mode 100644 index 00000000..353a4bea --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/article.mdx @@ -0,0 +1,112 @@ +## How many customers do you need for $10k MRR? + +Divide your monthly goal by your monthly price, then round up. At $29 a month, $10,000 MRR takes 345 paying customers. + + + +Customers needed = monthly goal ÷ monthly price, rounded up + + + +Your price changes the count more than anything else: + +| Monthly price | Customers for $10,000 MRR | +| --- | --- | +| $9 | 1,112 | +| $12 | 834 | +| $29 | 345 | +| $49 | 205 | +| $99 | 102 | +| $299 | 34 | + +Check whether your goal is MRR or ARR. $10,000 ARR is $833.33 a month, which is 29 customers at $29, not 345. + +The count assumes every customer pays the price you enter. With several plans, it's only as good as your average. If your plan mix shifts, run it again with the new average. + +## How does churn slow you down? + +Churn takes a share of all your customers every month, so you lose more as you grow. Each month, the tool keeps the customers who stayed and adds the new ones. + + + +Next month's customers = customers × (1 − monthly churn) + new customers + + + +At 15 new customers a month, with no churn you reach 345 customers in month 23. At 3% churn, the same pace takes until month 39. + +New customers join at the end of the month, so they can't cancel in the month they join. + +## Why is my goal out of reach? + +Your goal is out of reach when you'd lose as many customers each month as you add before you get there. Your customer count stops growing at new customers a month divided by monthly churn. The tool calls this the ceiling. + + + +Ceiling = new customers a month ÷ monthly churn + + + + + +15 ÷ 3% = 500. At 500 customers, 3% churn removes 15 a month, the same 15 you add. The count creeps toward 500 and never passes it. + +A $10,000 goal at $29 needs 345, below 500, so you get there. A $15,000 goal needs 518, above 500, so waiting longer won't help at this pace. + + + +When that happens, the tool shows the smallest pace that works. For the $15,000 goal it's 16 new customers a month, which raises the ceiling to about 533. That makes the goal possible, not quick: at 16 a month you'd reach 518 in month 117, nearly 10 years out. To aim for a set month, use `Pace for a deadline`. + +Lower churn also raises the ceiling. At 2.5% churn, the same 15 a month levels off at 600. + +## Are customers at goal and new customers a month the same? + +No. They're three different numbers. For $10,000 MRR at $29 with 3% churn: + +| Number | What it means | Example | +| --- | --- | --- | +| Customers at goal | Paying customers you need at the same time | 345 | +| New customers a month | Customers who start paying each month, before anyone cancels | 15 | +| Lost each month at goal | Customers who cancel each month once you're there | About 11 | + +Don't add the monthly losses to the 345. You need 345 customers at once. Replacing the 11 who leave is part of your monthly pace, not part of the total. + +## Should you plan from a growth rate or from new customers? + +Use new customers and churn when you can count both. Use the growth rate when you only see your total MRR change each month. + +Net MRR growth already includes cancellations, downgrades, and upgrades. The tool never takes churn off it again, which is why the growth method has no churn field. + +At $1,000 MRR and 10% net growth a month, you reach $10,000 in month 25. To get there by month 24, you need 10.07% a month. + +A growth rate needs a starting MRR above 0, because 10% of nothing is nothing. If you're starting from scratch, use new customers and churn. + +A steady percent also means adding more dollars each month as MRR grows. At 10%, month 1 adds $100 and month 24 adds almost $900. + +## How much traffic does your goal need? + +Divide the new customers you need each month by both conversion rates. + + + +Visitors a month = new customers a month ÷ (visitor to signup × signup to paid) + + + +To reach $10,000 MRR at $29 in 12 months with 4% churn, you need 36 new customers a month. At 3% visitor to signup and 15% signup to paid, that's 240 signups and 8,000 visitors a month. + +Some calculators show 7,919 visitors for the same inputs, because they use a fractional pace of 35.63 customers a month. You can't add part of a customer, and 35 a month gets you there in month 13, not 12. So this tool rounds the pace up to 36 whole customers first, then works out the traffic, which gives 8,000. + +Use rates from the same period and the same kind of visitor. A rate from a launch week or one big referral may not hold month after month. + +## How long does it take to reach $10k MRR? + +It depends on your price, pace, and churn, so there's no typical time. Use your own figures, not someone else's. + +Run your numbers twice: once with a pace you've already hit, and once with the pace you're planning for. The gap between the two dates tells you more than any average. + +## Is $10k MRR the same as $10k income? + +No. MRR is the monthly value of your active subscriptions. [Stripe's MRR guide](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29) says it isn't accounting revenue. It isn't cash either: a yearly plan paid up front still adds only a twelfth of its price to MRR each month. + +MRR also comes before payment fees, hosting, tools, and taxes. The [break-even MRR calculator](/break-even-mrr-calculator) finds the MRR that covers your costs. To see how long the cash you have will last, use the [startup runway calculator](/startup-runway-calculator). diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/intro.mdx b/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/intro.mdx new file mode 100644 index 00000000..40cc6d0b --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/intro.mdx @@ -0,0 +1,3 @@ +Enter a revenue goal and your monthly price to see how many paying customers you need. Add your new customers and churn, or your MRR growth, to see when you'd get there, or the pace a deadline needs. + +The timeline and traffic steps are optional. Leave them blank to get the customer count alone. diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/usage.mdx b/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/usage.mdx new file mode 100644 index 00000000..e247353e --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/content/usage.mdx @@ -0,0 +1,34 @@ +## What to enter + +Set the goal per month (MRR) or per year (ARR). $10,000 a year is $833.33 a month, so it needs about a twelfth of the customers. + +For the price, use your plan's monthly price. With several plans, use the average: current MRR divided by paying customers. The [ARPU and ARPA calculator](/arpu-arpa-calculator) works it out, and the [MRR calculator](/mrr-calculator) turns yearly and quarterly plans into monthly amounts. + +For the timeline, pick the method that matches what you track: + +| You know | Pick | +| --- | --- | +| New paying customers and cancellations each month | `New customers and churn` | +| Only your total MRR each month | `MRR growth rate` | + +- **New paying customers a month:** everyone who started paying, before anyone cancels. An average of the last three months is steadier than one month. +- **Monthly customer churn:** customers who cancelled in a month, divided by paying customers at its start. The [customer churn calculator](/customer-churn-calculator) finds it, and the [churn rate converter](/churn-rate-converter) turns a yearly rate into a monthly one. Don't use revenue churn. +- **Net monthly MRR growth:** this month's MRR minus last month's, divided by last month's. Cancellations are already in it. + +`Pace for a deadline` works backward. Enter the months you have, and the tool finds the new customers a month, or the growth rate, you need. + +For `Traffic needed`, enter the share of visitors who sign up and the share of signups who pay. A trial start counts as a signup. The [free trial conversion calculator](/free-trial-conversion-calculator) gives your signup-to-paid rate. + +## How to read the result + +`Paying customers needed` rounds up, since you can't have part of a customer. Those customers bring in a little more than the goal. + +Month 1 is one month from today. `Lost each month at goal` is how many customers cancel each month once you're there. That's also how many new ones you need each month just to stay at the goal. + + + +You need 345 paying customers, who bring in $10,005.00 MRR. + +Starting from 0, with 15 new customers a month and 3% monthly churn, you reach 345 in month 39, about 3 years 3 months from now. At the goal you lose about 11 customers a month. `Ceiling at this pace` is 500, the most customers this pace and churn can ever hold. + + diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/layout.tsx b/app/(site)/(tools)/(saas)/mrr-goal-calculator/layout.tsx new file mode 100644 index 00000000..996c5be7 --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/layout.tsx @@ -0,0 +1,6 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/mrr-goal-calculator"); +export const generateMetadata = registry.generateMetadata; +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/mrr-goal-calculator.module.css b/app/(site)/(tools)/(saas)/mrr-goal-calculator/mrr-goal-calculator.module.css new file mode 100644 index 00000000..6f0f7d97 --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/mrr-goal-calculator.module.css @@ -0,0 +1,157 @@ +.workbench { + background: var(--color-surface); + border-block: 1px solid var(--color-border-strong); + grid-column: 1 / -1; + margin-inline: calc(-1 * var(--container-gutter)); + min-width: 0; + padding: var(--space-7) var(--container-gutter); + width: calc(100% + var(--container-gutter) + var(--container-gutter)); +} + +.content, +.form, +.results { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +.content { + gap: var(--space-7); + margin-inline: auto; + max-width: 66rem; +} + +.group, +.resultGroup { + display: grid; + gap: var(--space-4); + min-width: 0; +} + +.group h2, +.resultGroup h2 { + font-family: var(--font-display); + font-size: var(--text-h3); + font-weight: var(--weight-bold); + line-height: var(--line-heading); + margin: 0; +} + +.optional { + color: var(--color-text-muted); + font-family: var(--font-sans); + font-size: var(--text-body); + font-weight: var(--weight-regular); +} + +.groupHeader { + align-items: end; + display: grid; + gap: var(--space-5); + grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem); +} + +.goalRow, +.fieldGrid, +.choices, +.timelineGrid { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-width: 0; +} + +.timelineGrid[data-count="3"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.goalRow > *, +.fieldGrid > *, +.choices > *, +.timelineGrid > * { + min-width: 0; +} + +.results { + border-top: 1px solid var(--color-border); + overflow-wrap: anywhere; + padding-top: var(--space-6); +} + +.timelineHeading { + display: grid; + gap: var(--space-1); +} + +.sub, +.lines p { + margin: 0; +} + +.sub { + color: var(--color-text-muted); +} + +.lines { + display: grid; + gap: var(--space-2); + max-width: var(--measure-prose); +} + +.note { + color: var(--color-text-muted); + line-height: var(--line-body); + margin: 0; +} + +/* A 600-month table scrolls inside the disclosure instead of stretching the page. */ +.monthTable { + max-height: 28rem; + overflow-y: auto; +} + +.markedMonth { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-1) var(--space-2); +} + +.marker { + color: var(--color-text-muted); + font-size: var(--text-small); + font-weight: var(--weight-bold); +} + +.formula { + color: var(--color-text); + font-family: var(--font-mono); + font-size: var(--text-small); + line-height: var(--line-body); + margin: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + .workbench { + padding-block: var(--space-5); + } + + .content { + gap: var(--space-6); + } + + .groupHeader, + .goalRow, + .fieldGrid, + .choices, + .timelineGrid, + .timelineGrid[data-count="3"] { + grid-template-columns: minmax(0, 1fr); + } + + .groupHeader .currency { + max-width: 12rem; + } +} diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/mrr-goal-calculator.tsx b/app/(site)/(tools)/(saas)/mrr-goal-calculator/mrr-goal-calculator.tsx new file mode 100644 index 00000000..10d08fdd --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/mrr-goal-calculator.tsx @@ -0,0 +1,752 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useMemo, useRef, useState, type FormEvent, type ReactNode, type RefObject } from "react"; +import { UiLiveChart } from "@/app/ui/charts/chart-client"; +import type { UiChartSpec } from "@/app/ui/charts/chart-option"; +import { UiButton, UiChoiceGroup, UiDisclosure, UiSelect, UiTextField, uiOption } from "@/app/ui/controls"; +import { + UiActionRow, + UiDataTable, + UiKeyValueList, + UiMetricGroup, + UiPrimaryResult, + UiProgressBar, + UiStatus, + type UiMetric, +} from "@/app/ui/display"; +import { + calculateMrrGoal, + type MrrGoalCustomerTimeline, + type MrrGoalErrors, + type MrrGoalFieldKey, + type MrrGoalGrowthTimeline, + type MrrGoalInput, + type MrrGoalTraffic, + type MrrGoalValues, +} from "@/lib/mrr-goal-calculator/engine"; +import { SAAS_ECONOMICS_CURRENCIES, type SaasEconomicsCurrency } from "@/lib/saas-economics/shared"; +import styles from "./mrr-goal-calculator.module.css"; + +type StatusMessage = { text: string; tone: "neutral" | "danger" }; +type TextKey = Exclude; + +const CHANGED = "Inputs changed. Calculate again."; + +function emptyFields(): MrrGoalInput { + return { + currency: "USD", + goalAmount: "", + goalPeriod: "month", + monthlyArpa: "", + method: "customers", + solveFor: "date", + currentCustomers: "", + newCustomersPerMonth: "", + churnPercent: "", + currentMrr: "", + growthPercent: "", + deadlineMonths: "", + visitorToSignupPercent: "", + signupToPaidPercent: "", + }; +} + +/** The timeline fields the engine reads for each method and solve mode, in reading order. */ +function timelineFields(fields: MrrGoalInput): TextKey[] { + if (fields.method === "growth") { + return fields.solveFor === "pace" ? ["currentMrr", "deadlineMonths"] : ["currentMrr", "growthPercent"]; + } + return fields.solveFor === "pace" + ? ["currentCustomers", "churnPercent", "deadlineMonths"] + : ["currentCustomers", "newCustomersPerMonth", "churnPercent"]; +} + +/* ---------------- formatting ---------------- */ + +function currencySymbol(currency: SaasEconomicsCurrency): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value ?? currency; +} + +function formatInteger(value: bigint | number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); +} + +function formatMoney(cents: bigint, currency: SaasEconomicsCurrency): string { + const negative = cents < 0n; + const magnitude = negative ? -cents : cents; + const fraction = (magnitude % 100n).toString().padStart(2, "0"); + return `${negative ? "−" : ""}${currencySymbol(currency)}${formatInteger(magnitude / 100n)}.${fraction}`; +} + +function formatPercent(hundredths: bigint): string { + const negative = hundredths < 0n; + const magnitude = negative ? -hundredths : hundredths; + const fraction = (magnitude % 100n).toString().padStart(2, "0").replace(/0+$/, ""); + return `${negative ? "−" : ""}${formatInteger(magnitude / 100n)}${fraction ? `.${fraction}` : ""}%`; +} + +function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +/** "about 3 years 3 months" for spans of a year or more. */ +function monthSpan(months: number): string | null { + if (months < 12) return null; + const years = Math.floor(months / 12); + const rest = months % 12; + return `About ${plural(years, "year")}${rest ? ` ${plural(rest, "month")}` : ""}`; +} + +function ceilingText(ceiling: { floor: bigint; isWhole: boolean }): string { + return `${ceiling.isWhole ? "" : "about "}${formatInteger(ceiling.floor)}`; +} + +/* ---------------- results ---------------- */ + +type TimelineCopy = { + heading: string; + sub?: string | null; + lines: string[]; + metrics: UiMetric[]; + /** The month the table marks, and its label. */ + marker: { month: number; label: string } | null; + caption: string; +}; + +function customerCopy(values: MrrGoalValues, timeline: MrrGoalCustomerTimeline): TimelineCopy { + const needed = values.goal.customersNeeded; + const { ceiling, outcome } = timeline; + const churn = timeline.churnHundredths > 0n; + const remaining = { id: "remaining", label: "Customers still needed", value: formatInteger(timeline.remainingCustomers) }; + const lost = churn ? [{ id: "lost", label: "Lost each month at goal", value: formatInteger(timeline.lostPerMonthAtGoal) }] : []; + const ceilingMetric = ceiling ? [{ id: "ceiling", label: "Ceiling at this pace", value: ceilingText(ceiling) }] : []; + const pace = formatInteger(timeline.pace); + const caption = timeline.solveFor === "pace" + ? `At ${pace} new customers a month. Months from today.` + : "Months from today."; + + switch (outcome.kind) { + case "reached": + return { + heading: `Reached in month ${outcome.month}`, + sub: monthSpan(outcome.month), + lines: [], + metrics: [remaining, ...lost, ...ceilingMetric], + marker: { month: outcome.month, label: "Goal reached" }, + caption, + }; + case "alreadyThere": + return { + heading: "You're already there", + lines: outcome.fallsBelowInMonth !== null && ceiling + ? [`At this pace, you drop below the goal in month ${outcome.fallsBelowInMonth}. Your customer count levels off at ${ceilingText(ceiling)}.`] + : [], + metrics: [...lost], + marker: outcome.fallsBelowInMonth !== null ? { month: outcome.fallsBelowInMonth, label: "Drops below goal" } : null, + caption, + }; + case "unreachable": + if (outcome.reason === "noNewCustomers") { + return { + heading: "Out of reach at this pace", + lines: [`With no new customers and no churn, the count stays at ${formatInteger(timeline.currentCustomers)}.`], + metrics: [remaining], + marker: null, + caption, + }; + } + return { + heading: "Out of reach at this pace", + lines: [ + `With ${pace} new customers a month and ${formatPercent(timeline.churnHundredths)} churn, ${ + ceiling && ceiling.isWhole && ceiling.floor === needed + ? `you get close to ${formatInteger(needed)} customers but never reach it.` + : `you level off at ${ceiling ? ceilingText(ceiling) : "a lower count"} customers, short of ${formatInteger(needed)}.` + }`, + `With at least ${formatInteger(outcome.paceToReach)} new customers a month, you'd get there eventually. To aim for a set month, use Pace for a deadline.`, + ], + metrics: [remaining, ...lost], + marker: null, + caption, + }; + case "beyondHorizon": + return { + heading: "Not within 50 years", + lines: ["At this pace, the goal is more than 600 months away."], + metrics: [remaining, ...lost, ...ceilingMetric], + marker: null, + caption, + }; + case "paceForDeadline": { + const deadline = outcome.deadlineMonths; + if (outcome.alreadyThere && timeline.pace === 0n) { + return { + heading: "You're already there", + sub: `You stay at ${formatInteger(needed)} or more through month ${deadline} with no new customers.`, + lines: [], + metrics: [...lost], + marker: { month: deadline, label: "Deadline" }, + caption, + }; + } + return { + heading: `Add ${pace} new customers a month`, + sub: outcome.alreadyThere + ? `to still have ${formatInteger(needed)} at month ${deadline}` + : `to reach ${formatInteger(needed)} by month ${deadline}`, + lines: [], + metrics: [remaining, ...lost], + marker: { month: deadline, label: "Deadline" }, + caption, + }; + } + } +} + +function growthCopy(values: MrrGoalValues, timeline: MrrGoalGrowthTimeline): TimelineCopy { + const { currency, goal } = values; + const { outcome } = timeline; + const goalMrr = formatMoney(goal.monthlyGoalCents, currency); + const remaining = timeline.remainingMrrCents > 0n + ? [{ id: "remaining", label: "MRR still needed", value: formatMoney(timeline.remainingMrrCents, currency) }] + : []; + const rate = formatPercent(timeline.growthHundredths); + const caption = timeline.solveFor === "pace" ? `At ${rate} a month. Months from today.` : "Months from today."; + + switch (outcome.kind) { + case "reached": + return { + heading: `Reached in month ${outcome.month}`, + sub: monthSpan(outcome.month), + lines: [], + metrics: remaining, + marker: { month: outcome.month, label: "Goal reached" }, + caption, + }; + case "alreadyThere": + return { heading: "You're already there", lines: [], metrics: [], marker: null, caption }; + case "unreachable": + return { + heading: "Out of reach at this rate", + lines: [outcome.reason === "noGrowth" ? "MRR doesn't grow at 0% a month." : `MRR shrinks ${formatPercent(-timeline.growthHundredths)} a month.`], + metrics: remaining, + marker: null, + caption, + }; + case "beyondHorizon": + return { + heading: "Not within 50 years", + lines: ["At this rate, the goal is more than 600 months away."], + metrics: remaining, + marker: null, + caption, + }; + case "rateForDeadline": + if (outcome.alreadyThere) { + return { + heading: "You're already there", + sub: `Current MRR already meets ${goalMrr}.`, + lines: [], + metrics: [], + marker: null, + caption, + }; + } + return { + heading: `Grow MRR ${rate} a month`, + sub: `to reach ${goalMrr} MRR by month ${outcome.deadlineMonths}`, + lines: [], + metrics: remaining, + marker: { month: outcome.deadlineMonths, label: "Deadline" }, + caption, + }; + case "aboveMaxRate": + return { + heading: "Needs more than 1,000% a month", + sub: `to reach ${goalMrr} MRR by month ${outcome.deadlineMonths}`, + lines: ["Try a later deadline."], + metrics: remaining, + marker: null, + caption, + }; + } +} + +function chartSpec(values: MrrGoalValues, copy: TimelineCopy): UiChartSpec | null { + const timeline = values.timeline; + if (!timeline || timeline.path.length < 2) return null; + // Already at the goal with nothing to watch for: a chart would only show the line staying above it. + if (timeline.outcome.kind === "alreadyThere" && !("fallsBelowInMonth" in timeline.outcome && timeline.outcome.fallsBelowInMonth !== null)) return null; + if (timeline.method === "customers") { + const needed = Number(values.goal.customersNeeded); + const points = timeline.path.map((point) => [point.month, Number(point.customers)] as const); + if (points.every(([, y]) => y === points[0][1])) return null; + const series = [ + { id: "customers", label: "Paying customers", points }, + { id: "needed", label: "Customers needed", points: timeline.path.map((point) => [point.month, needed] as const) }, + ]; + if (timeline.outcome.kind === "unreachable" && timeline.outcome.reason === "ceiling" && timeline.ceiling) { + const ceiling = Number(timeline.ceiling.floor); + series.push({ id: "ceiling", label: "Ceiling at this pace", points: timeline.path.map((point) => [point.month, ceiling] as const) }); + } + return { caption: copy.caption, format: "number", height: 280, kind: "line", series, title: "Paying customers by month", xFormat: "number" }; + } + const points = timeline.path.map((point) => [point.month, Number(point.mrrCents) / 100] as const); + if (points.every(([, y]) => y === points[0][1])) return null; + const goal = Number(values.goal.monthlyGoalCents) / 100; + // The shared chart formats currency as US dollars, so other currencies use plain numbers and name the code. + const usd = values.currency === "USD"; + return { + caption: copy.caption, + format: usd ? "currency" : "number", + height: 280, + kind: "line", + series: [ + { id: "mrr", label: "MRR", points }, + { id: "goal", label: "Goal", points: timeline.path.map((point) => [point.month, goal] as const) }, + ], + title: usd ? "MRR by month" : `MRR by month, in ${values.currency}`, + xFormat: "number", + }; +} + +function trafficLines(traffic: MrrGoalTraffic): { metrics: UiMetric[]; note: string | null } { + if (traffic.kind === "noFiniteTraffic") { + const stage = traffic.stage === "signupToPaid" ? "signup-to-paid" : "visitor-to-signup"; + return { metrics: [], note: `At 0% ${stage}, no amount of traffic adds paying customers.` }; + } + const metrics: UiMetric[] = [ + { id: "signups", label: "Signups a month", value: formatInteger(traffic.signupsPerMonth) }, + { id: "visitors", label: "Visitors a month", value: formatInteger(traffic.visitorsPerMonth) }, + ]; + if (traffic.visitorsToGoal !== null && traffic.months !== null) { + metrics.push({ id: "total", label: `Visitors over ${plural(traffic.months, "month")}`, value: formatInteger(traffic.visitorsToGoal) }); + } + return { metrics, note: null }; +} + +function MonthTable({ copy, values }: { copy: TimelineCopy; values: MrrGoalValues }) { + const timeline = values.timeline!; + const { currency } = values; + const monthCell = (month: number): ReactNode => copy.marker?.month === month + ? {month}{copy.marker.label} + : month; + if (timeline.method === "customers") { + return ( + ({ + id: String(point.month), + cells: { month: monthCell(point.month), customers: formatInteger(point.customers), mrr: formatMoney(point.mrrCents, currency) }, + }))} + /> + ); + } + return ( + ({ + id: String(point.month), + cells: { month: monthCell(point.month), mrr: formatMoney(point.mrrCents, currency) }, + }))} + /> + ); +} + +function assumptions(values: MrrGoalValues): { label: string; value: string }[] { + const { currency, goal, timeline, traffic } = values; + const rows = [ + { + label: "Goal", + value: goal.period === "month" + ? `${formatMoney(goal.enteredCents, currency)} a month (MRR)` + : `${formatMoney(goal.enteredCents, currency)} a year (ARR)`, + }, + { label: "Monthly price or ARPA", value: formatMoney(values.monthlyArpaCents, currency) }, + ]; + if (timeline?.method === "customers") { + rows.push({ label: "Current paying customers", value: formatInteger(timeline.currentCustomers) }); + if (timeline.solveFor === "date") rows.push({ label: "New customers a month", value: formatInteger(timeline.pace) }); + rows.push({ label: "Monthly customer churn", value: formatPercent(timeline.churnHundredths) }); + } + if (timeline?.method === "growth") { + rows.push({ label: "Current MRR", value: formatMoney(timeline.currentMrrCents, currency) }); + if (timeline.solveFor === "date") rows.push({ label: "Net monthly MRR growth", value: formatPercent(timeline.growthHundredths) }); + } + if (timeline && (timeline.outcome.kind === "paceForDeadline" || timeline.outcome.kind === "rateForDeadline" || timeline.outcome.kind === "aboveMaxRate")) { + rows.push({ label: "Deadline", value: `Month ${timeline.outcome.deadlineMonths}` }); + } + if (traffic?.kind === "finite") { + rows.push( + { label: "Visitor to signup", value: formatPercent(traffic.visitorToSignupHundredths) }, + { label: "Signup to paid", value: formatPercent(traffic.signupToPaidHundredths) }, + ); + } + return rows; +} + +function MrrGoalResults({ resultRef, values }: { resultRef: RefObject; values: MrrGoalValues }) { + const { currency, goal, timeline, traffic } = values; + const needed = formatInteger(goal.customersNeeded); + const arpa = formatMoney(values.monthlyArpaCents, currency); + const mrrAt = formatMoney(goal.mrrAtCustomersNeededCents, currency); + const detail = goal.period === "month" + ? `At ${arpa} a month, ${needed} customers bring in ${mrrAt} MRR.` + : `${formatMoney(goal.enteredCents, currency)} ARR is ${formatMoney(goal.monthlyGoalCents, currency)} a month. At ${arpa}, ${needed} customers bring in ${mrrAt} MRR.`; + + // Keyed on the result alone, so unrelated form updates don't redraw the chart or reset its toggles. + const { copy, spec } = useMemo(() => { + const view = values.timeline + ? values.timeline.method === "customers" ? customerCopy(values, values.timeline) : growthCopy(values, values.timeline) + : null; + return { copy: view, spec: view ? chartSpec(values, view) : null }; + }, [values]); + + let progress: ReactNode = null; + if (timeline?.method === "customers" && timeline.currentCustomers > 0n && timeline.currentCustomers < goal.customersNeeded) { + progress = ( + + ); + } else if (timeline?.method === "growth" && timeline.currentMrrCents < goal.monthlyGoalCents) { + progress = ( + + ); + } + + const trafficView = traffic ? trafficLines(traffic) : null; + + return ( +
+ + + {copy ? ( +
+
+

{copy.heading}

+ {copy.sub ?

{copy.sub}

: null} +
+ {copy.lines.length ? ( +
+ {copy.lines.map((line) =>

{line}

)} +
+ ) : null} + {copy.metrics.length ? : null} + {progress} + {spec ? : null} +
+ ) : ( +

Add your current numbers to see when you'd reach it.

+ )} + + {trafficView ? ( +
+

Traffic for this pace

+ {trafficView.metrics.length ? : null} + {trafficView.note ?

{trafficView.note}

: null} +
+ ) : null} + + {copy && timeline && spec ? ( + +
+ +
+
+ ) : null} + +
+

What the answer uses

+ + {timeline ? ( +

+ {timeline.method === "customers" + ? "Next month = customers × (1 − churn) + new customers." + : "Next month = MRR × (1 + growth)."} +

+ ) : null} +
+
+ ); +} + +/* ---------------- form ---------------- */ + +export function MrrGoalCalculator() { + const [fields, setFields] = useState(emptyFields); + const [errors, setErrors] = useState({}); + const [result, setResult] = useState(null); + const [status, setStatus] = useState(null); + const [trafficOpen, setTrafficOpen] = useState(false); + const formRef = useRef(null); + const resultRef = useRef(null); + + const customers = fields.method === "customers"; + const visible = timelineFields(fields); + const trafficSet = fields.visitorToSignupPercent.trim() && fields.signupToPaidPercent.trim() + ? `${fields.visitorToSignupPercent.trim()}% to signup, ${fields.signupToPaidPercent.trim()}% to paid` + : undefined; + + function clearForEdit() { + const hadResult = result !== null; + setErrors({}); + setResult(null); + if (hadResult) setStatus({ text: CHANGED, tone: "neutral" }); + else if (status?.text !== CHANGED) setStatus(null); + } + + function update(field: K, value: MrrGoalInput[K]) { + setFields((current) => ({ ...current, [field]: value })); + clearForEdit(); + } + + function focusKey(key: string) { + // Two frames, so a disclosure opened in the same update has rendered its fields. + requestAnimationFrame(() => requestAnimationFrame(() => { + formRef.current?.querySelector(`[data-focus-key="${key}"]`)?.focus(); + })); + } + + function focusFirstError(next: MrrGoalErrors) { + const order: MrrGoalFieldKey[] = [ + "currency", "goalAmount", "goalPeriod", "monthlyArpa", "method", "solveFor", + ...visible, "visitorToSignupPercent", "signupToPaidPercent", "traffic", + ]; + const first = order.find((key) => next[key]); + if (!first) return; + if (first === "visitorToSignupPercent" || first === "signupToPaidPercent" || first === "traffic") { + setTrafficOpen(true); + focusKey(first === "traffic" ? "visitorToSignupPercent" : first); + return; + } + focusKey(first); + } + + function submit(event: FormEvent) { + event.preventDefault(); + setStatus(null); + try { + const calculation = calculateMrrGoal(fields); + if (calculation.status === "invalid") { + setErrors(calculation.errors); + setResult(null); + if (calculation.errors.form) setStatus({ text: calculation.errors.form.message, tone: "danger" }); + focusFirstError(calculation.errors); + return; + } + setErrors({}); + setResult(calculation.values); + requestAnimationFrame(() => resultRef.current?.focus()); + } catch { + setErrors({}); + setResult(null); + setStatus({ text: "Couldn't calculate. Try again.", tone: "danger" }); + } + } + + function reset() { + setFields(emptyFields()); + setErrors({}); + setResult(null); + setTrafficOpen(false); + setStatus({ text: "Form reset.", tone: "neutral" }); + focusKey("goalAmount"); + } + + const error = (key: MrrGoalFieldKey) => errors[key]?.message ?? null; + + function textField(key: TextKey, props: { label: string; suffix?: string; description?: string; decimal?: boolean }) { + return ( + update(key, value)} + spellCheck={false} + suffix={props.suffix} + value={fields[key]} + /> + ); + } + + const timelineInputs: Record = { + currentCustomers: textField("currentCustomers", { label: "Current paying customers", decimal: false }), + newCustomersPerMonth: textField("newCustomersPerMonth", { + label: "New paying customers a month", + decimal: false, + description: "Everyone who starts paying, before any cancel.", + }), + churnPercent: textField("churnPercent", { + label: "Monthly customer churn", + suffix: "%", + description: "Share of paying customers who cancel each month. Not lost revenue.", + }), + currentMrr: textField("currentMrr", { label: "Current MRR", suffix: fields.currency }), + growthPercent: textField("growthPercent", { + label: "Net monthly MRR growth", + suffix: "%", + description: "Change in total MRR from last month. Cancellations are already in it.", + }), + deadlineMonths: textField("deadlineMonths", { label: "Deadline in months", decimal: false }), + }; + + return ( +
+
+
+
+
+

Goal

+
+ { + if (!SAAS_ECONOMICS_CURRENCIES.includes(value as SaasEconomicsCurrency)) return; + update("currency", value); + }} + options={SAAS_ECONOMICS_CURRENCIES.map((currency) => uiOption(currency, currency))} + required + value={fields.currency} + /> +
+
+
+ update("goalAmount", value)} + placeholder="10000" + required + spellCheck={false} + suffix={fields.currency} + value={fields.goalAmount} + /> + update("goalPeriod", value)} + options={[uiOption("month", "Per month (MRR)"), uiOption("year", "Per year (ARR)")]} + required + value={fields.goalPeriod} + /> +
+
+ update("monthlyArpa", value)} + required + spellCheck={false} + suffix={fields.currency} + value={fields.monthlyArpa} + /> +
+
+ +
+

Timeline (optional)

+
+ update("method", value)} + options={[uiOption("customers", "New customers and churn"), uiOption("growth", "MRR growth rate")]} + value={fields.method} + /> + update("solveFor", value)} + options={[uiOption("date", "When I reach it"), uiOption("pace", "Pace for a deadline")]} + value={fields.solveFor} + /> +
+
+ {visible.map((key) => timelineInputs[key])} +
+
+ + {customers ? ( + +
+ update("visitorToSignupPercent", value)} + spellCheck={false} + suffix="%" + value={fields.visitorToSignupPercent} + /> + update("signupToPaidPercent", value)} + spellCheck={false} + suffix="%" + value={fields.signupToPaidPercent} + /> +
+
+ ) : null} + + + Calculate goal + + + + {status ? {status.text} : null} +
+ {result ? : null} +
+
+ ); +} diff --git a/app/(site)/(tools)/(saas)/mrr-goal-calculator/page.tsx b/app/(site)/(tools)/(saas)/mrr-goal-calculator/page.tsx new file mode 100644 index 00000000..937e8c6c --- /dev/null +++ b/app/(site)/(tools)/(saas)/mrr-goal-calculator/page.tsx @@ -0,0 +1,25 @@ +import { Goal } from "lucide-react"; +import { ToolPageShell } from "@/app/ui/tools/tool-page-shell"; +import { ToolPageArticle, ToolPageHeader } from "@/app/ui/site/tool-page-content"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { getRequiredToolPage } from "@/lib/tool-pages"; +import { MrrGoalCalculator } from "./mrr-goal-calculator"; +import Article from "./content/article.mdx"; +import Intro from "./content/intro.mdx"; +import Usage from "./content/usage.mdx"; + +export default async function MrrGoalCalculatorPage() { + const toolPage = await getRequiredToolPage("mrr-goal-calculator"); + return ( + + + } intro={} toolPage={toolPage} /> + + + +
+ + + + ); +} diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.spec.json b/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.spec.json new file mode 100644 index 00000000..125eb9a5 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.spec.json @@ -0,0 +1,13 @@ +{ + "id": "startup-runway-calculator--tool-spot", + "type": "toolSpot", + "takeaway": "Get airborne, covering your costs, before the runway of cash ends.", + "source": "conceptual figure, no external data", + "dataAsOf": "2026-10-10", + "width": 140, + "height": 110, + "seed": 211, + "data": { + "motif": "runwayLiftoff" + } +} diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.svg b/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.svg new file mode 100644 index 00000000..801f0fb9 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.svg @@ -0,0 +1,23 @@ + +Get airborne, covering your costs, before the runway of cash ends. + + + + + + + + + + + + + + + + + + + + + diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.ts b/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.ts new file mode 100644 index 00000000..1dd41e1b --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.ts @@ -0,0 +1,25 @@ +// generated by scripts/stack127/marker-art/generate.mjs. do not edit. +// edit the sibling .spec.json and rerun: npm run stack127:marker-art +export const startupRunwayCalculatorArt = ` +Get airborne, covering your costs, before the runway of cash ends. + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/content/article.mdx b/app/(site)/(tools)/(saas)/startup-runway-calculator/content/article.mdx new file mode 100644 index 00000000..e2a029a0 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/content/article.mdx @@ -0,0 +1,58 @@ +## How is startup runway calculated? + +Runway is the cash you have divided by the cash you lose each month. + + + +Net burn = cash paid out − cash collected + +Runway in months = cash now ÷ net burn + + + +Gross burn is everything you pay out in a month. Net burn is gross burn minus what customers paid you, as [Carta defines them](https://carta.com/learn/resources/burn-rate-calculator/). For example, $20,000 ÷ ($6,000 − $3,000) is 6.6 months, rounded down. + +A positive net burn means your cash is shrinking. Zero means you're at break-even, so the balance holds. A negative net burn means customers pay you more than you spend, and the calculator shows it as net cash in. + +The forecast works one month at a time: last month's balance, plus cash collected, minus cash paid out, plus or minus any planned change. In the month cash runs out, money counts as spread evenly, which gives the decimal. + +## Why does growth start in month 2? + +Month 1 is the normal month you described, so the forecast starts from the numbers you actually have today. Growth changes things from month 2 on. Starting it in month 1 would assume a change that hasn't happened yet, which can make the runway look longer or shorter than it is. + +## Should you use MRR or cash collected? + +Use cash collected. Runway depends on when money reaches your account, and MRR spreads it out. + +[Stripe's example](https://stripe.com/resources/more/revenue-recognition-for-subscription-based-businesses-explained) is a customer who pays $1,200 upfront for a year. That counts as $100 of revenue a month, but all $1,200 arrives the day they pay. Enter $100 a month and you'd spread out cash that comes in one go. Enter $1,200 every month and you'd count it twelve times. + +So keep yearly payments out of the monthly figure, including when you average recent months. A payment you've already received is part of your cash now. Add the next one as a planned change in the month you expect it, if you're confident of it. That way each payment is counted once. If you're still pricing a yearly plan, the [annual plan discount calculator](/annual-plan-discount-calculator) shows what a discount costs you and how much cash it brings in upfront. + +## Should you count your own pay? + +Yes. Your pay is a business cost. Without it, the runway shows how long the business lasts, not how long you can keep working on it. + +If you don't pay yourself yet but plan to, add it as a planned change: money out, every month, from the month you'll start. + +## What is a good runway for a bootstrapped startup? + +There's no single number. Most runway targets online come from fundraising advice, where runway means time left to raise the next round. For a bootstrapped business, the better question is whether cash lasts until customers pay enough to cover your costs. The forecast answers that. + +Paul Graham calls this being [default alive](https://paulgraham.com/aord.html): if your costs stay the same and revenue keeps growing at its recent rate, do you become profitable before the money runs out? To try it here, enter your recent monthly revenue growth, leave expense growth at 0%, and add no planned changes. The calculator compares monthly cash in with cash out. It doesn't measure accounting profit, and it doesn't label your result. + +To see how long your growth rate takes to reach a revenue target, such as the MRR that covers your costs, use the [MRR goal calculator](/mrr-goal-calculator). + +## Why does another calculator show a different runway? + +Usually it does one of these: + +- Asks for MRR instead of cash. Yearly plans make the two very different. +- Starts growth in month 1, which can add a month or more. +- Rounds to the nearest tenth. This one rounds down, so it never overstates. +- Shows "infinite" runway when cash stops falling. This one looks 60 months ahead and tells you when there's no cash-out in that time. A forecast further out is mostly guesswork. + +## What this calculator doesn't cover + +It's a monthly model of business cash. It leaves out late payments, loans, interest, fundraising, and personal savings. Churn isn't a separate input, so build it into the revenue growth rate. If new sales add about 8% a month and cancellations take away 3%, enter about 5%. + +To find the MRR that covers your costs, use the [break-even MRR calculator](/break-even-mrr-calculator). The [burn multiple calculator](/burn-multiple-calculator) shows how much cash you burn for each dollar of new ARR, and the [SaaS metrics calculator](/saas-metrics-calculator) gives a one-month picture of MRR and retention. diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/content/intro.mdx b/app/(site)/(tools)/(saas)/startup-runway-calculator/content/intro.mdx new file mode 100644 index 00000000..1b647a69 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/content/intro.mdx @@ -0,0 +1,3 @@ +This calculator shows how many months your business cash will last at today's numbers, and the month it runs out. + +Have three figures ready: the cash in your business account, the cash customers pay you in a typical month, and everything you pay out in a month. Add revenue growth, a hire, or a yearly bill to see if customer cash covers your costs before the money ends. diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/content/usage.mdx b/app/(site)/(tools)/(saas)/startup-runway-calculator/content/usage.mdx new file mode 100644 index 00000000..c5c1c4b6 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/content/usage.mdx @@ -0,0 +1,45 @@ +## What to enter + + +Business cash you can spend today. Not personal savings, unused credit, or money you haven't received. +What customers pay into your account in a normal month. Use cash, not MRR. Leave out yearly payments, and add the next one as a planned change, so it's counted once. Leave out loans and investment. +Everything that left the account in a typical month, including your own pay, payment fees, and taxes. The calculator adds no fees of its own. + + +If your months vary, use an average. [Pilot suggests](https://pilot.com/blog/burn-rate-calculation-startup-runway-calculator) at least three months. + +## Adding a forecast + +Growth rates are per month and compound: 10% a month is about 214% a year. A blank rate means 0%. A rate is a guess, so try a slower one too. Growth starts in month 2. + +Planned changes are for costs and income that jump instead of growing. Month 1 is the first month from today, and amounts don't grow. + +| Event | Type | Repeats | Example | +| --- | --- | --- | --- | +| A hire | Money out | Every month | $2,000 from month 4 | +| A yearly renewal | Money out | Every 12 months | $1,200 from month 3 | +| A tax bill | Money out | Once | $5,000 in month 6 | +| A signed contract | Money in | Once | $6,000 in month 2 | + +Only add money in you're sure of. A planned change can't lower a cost, so enter a cut you've already made in cash paid out. + +## How to read the result + +Today's runway assumes nothing changes. The forecast runway sits beside it. Runway rounds down, so 6.6 months means cash runs out during month 7. + +| Forecast result | What it means | +| --- | --- | +| A number of months | Cash runs out in that month. The detail says if income catches up later, or if a planned payment caused it. | +| Covers costs from month N | Income covers costs from month N. No cash-out in the next 60 months. | +| Covers costs every month | Income covers costs from month 1. No cash-out in the next 60 months. | +| More than 60 months | No cash-out in the next 60 months, but income still doesn't cover costs. | + +If cash runs out before income catches up, you also see the extra cash you'd need today to reach break-even. + + + +You have $20,000, collect $3,000 a month, and pay out $6,000, including $2,500 to yourself. Cash lasts 6.6 months and runs out in month 7. + +With 6% monthly revenue growth and a $1,200 renewal every 12 months from month 3, cash lasts 8.4 months and runs out in month 9. Income covers costs from month 13. $2,590.17 more today would get you there, since the balance is lowest in month 12. + + diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/layout.tsx b/app/(site)/(tools)/(saas)/startup-runway-calculator/layout.tsx new file mode 100644 index 00000000..bbfc04c4 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/layout.tsx @@ -0,0 +1,6 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/startup-runway-calculator"); +export const generateMetadata = registry.generateMetadata; +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/page.tsx b/app/(site)/(tools)/(saas)/startup-runway-calculator/page.tsx new file mode 100644 index 00000000..b4fe2c1e --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/page.tsx @@ -0,0 +1,25 @@ +import { PlaneTakeoff } from "lucide-react"; +import { ToolPageShell } from "@/app/ui/tools/tool-page-shell"; +import { ToolPageArticle, ToolPageHeader } from "@/app/ui/site/tool-page-content"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { getRequiredToolPage } from "@/lib/tool-pages"; +import { StartupRunwayCalculator } from "./startup-runway-calculator"; +import Article from "./content/article.mdx"; +import Intro from "./content/intro.mdx"; +import Usage from "./content/usage.mdx"; + +export default async function StartupRunwayCalculatorPage() { + const toolPage = await getRequiredToolPage("startup-runway-calculator"); + return ( + + + } intro={} toolPage={toolPage} /> + + + +
+ + + + ); +} diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/startup-runway-calculator.module.css b/app/(site)/(tools)/(saas)/startup-runway-calculator/startup-runway-calculator.module.css new file mode 100644 index 00000000..bfb356d6 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/startup-runway-calculator.module.css @@ -0,0 +1,261 @@ +.workbench { + background: var(--color-surface); + border-block: 1px solid var(--color-border-strong); + grid-column: 1 / -1; + margin-inline: calc(-1 * var(--container-gutter)); + min-width: 0; + padding: var(--space-7) var(--container-gutter); + width: calc(100% + var(--container-gutter) + var(--container-gutter)); +} + +.content, +.form, +.results, +.answer { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +.content { + gap: var(--space-7); + margin-inline: auto; + max-width: 66rem; +} + +.group, +.resultGroup, +.changes { + display: grid; + gap: var(--space-4); + min-width: 0; +} + +.group h2, +.resultGroup h2 { + font-family: var(--font-display); + font-size: var(--text-h3); + font-weight: var(--weight-bold); + line-height: var(--line-heading); + margin: 0; +} + +.optional { + color: var(--color-text-muted); + font-family: var(--font-sans); + font-size: var(--text-body); + font-weight: var(--weight-regular); +} + +.groupHeader { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem); +} + +.groupTitle { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.helper, +.note { + color: var(--color-text-muted); + font-size: var(--text-small); + line-height: var(--line-body); + margin: 0; +} + +.note { + font-size: var(--text-body); +} + +.fieldGrid { + align-items: start; + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-width: 0; +} + +.fieldGrid > * { + min-width: 0; +} + +/* Planned changes */ + +.changes :global([data-repeatable-row]) { + align-items: start; +} + +/* The shared tooltip uses max-content. A long change name then widens the page. */ +.changes :global([data-repeatable-row]) > :global(button[aria-label])::after { + content: none; + overflow: hidden; + white-space: normal; + width: 16rem; +} + +.changes :global([data-repeatable-row]) > :global(button[aria-label]):hover::after, +.changes :global([data-repeatable-row]) > :global(button[aria-label]):focus-visible::after { + content: attr(aria-label); +} + +/* The remove action lines up with the inputs, below the visible labels. */ +.changes :global([data-repeatable-row]) > :global(button) { + margin-top: calc(var(--text-body) * var(--line-ui) + var(--space-2)); +} + +.change { + align-items: start; + border: 0; + display: grid; + gap: var(--space-4); + grid-template-columns: + minmax(8rem, 1fr) + minmax(8.5rem, 9.5rem) + minmax(6rem, 7.5rem) + minmax(9.5rem, 11rem) + minmax(8rem, 1fr); + margin: 0; + min-width: 0; + padding: 0; +} + +.change > * { + min-width: 0; +} + +.visuallyHidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +/* Results */ + +.results { + border-top: 1px solid var(--color-border); + overflow-wrap: anywhere; + padding-top: var(--space-6); +} + +.results :global(th), +.results :global(td) { + overflow-wrap: anywhere; +} + +@media (min-width: 761px) { + .results :global(table) { + table-layout: fixed; + width: 100%; + } +} + +.leads { + align-items: start; + display: grid; + gap: var(--space-6); + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-width: 0; +} + +.leads > * { + min-width: 0; +} + +.chart { + min-width: 0; +} + +.detailLines { + display: grid; + gap: var(--space-1); +} + +.changeList { + display: grid; + gap: var(--space-1); + list-style: none; + margin: 0; + padding: 0; +} + +.formula { + color: var(--color-text); + font-family: var(--font-mono); + font-size: var(--text-small); + line-height: var(--line-body); + margin: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 1080px) { + .change { + grid-template-columns: minmax(0, 1fr) minmax(8.5rem, 10rem) minmax(6rem, 7.5rem); + } + + .changeFrequency { + grid-column: 1; + } + + .changeName { + grid-column: 2 / -1; + } +} + +@media (max-width: 760px) { + .workbench { + padding-block: var(--space-5); + } + + .content { + gap: var(--space-6); + } + + .groupHeader, + .fieldGrid, + .leads { + grid-template-columns: minmax(0, 1fr); + } + + .currency { + max-width: 12rem; + } + + .groupHeader .currency { + grid-row: 1; + } + + .changes :global([data-repeatable-row]) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-4); + } + + .changes :global([data-repeatable-row]:last-child) { + border-bottom: 0; + padding-bottom: 0; + } + + .changes :global([data-repeatable-row]) > :global(button) { + margin-top: 0; + } + + .change { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .changeFrequency { + grid-column: auto; + } + + .changeName { + grid-column: 1 / -1; + } +} diff --git a/app/(site)/(tools)/(saas)/startup-runway-calculator/startup-runway-calculator.tsx b/app/(site)/(tools)/(saas)/startup-runway-calculator/startup-runway-calculator.tsx new file mode 100644 index 00000000..91124106 --- /dev/null +++ b/app/(site)/(tools)/(saas)/startup-runway-calculator/startup-runway-calculator.tsx @@ -0,0 +1,727 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useLayoutEffect, useMemo, useRef, useState, type FormEvent, type RefObject } from "react"; +import { UiLiveChart } from "@/app/ui/charts/chart-client"; +import type { UiChartSpec } from "@/app/ui/charts/chart-option"; +import { UiButton, UiDisclosure, UiRepeatableList, UiSelect, UiTextField, uiOption } from "@/app/ui/controls"; +import { + UiActionRow, + UiDataTable, + UiKeyValueList, + UiMetricGroup, + UiPrimaryResult, + UiStatus, + type UiMetric, +} from "@/app/ui/display"; +import { + STARTUP_RUNWAY_HORIZON_MONTHS, + STARTUP_RUNWAY_MAX_PLANNED_CHANGES, + calculateStartupRunway, + type StartupRunwayErrors, + type StartupRunwayFieldKey, + type StartupRunwayInput, + type StartupRunwayPlannedChange, + type StartupRunwayPlannedChangeInput, + type StartupRunwayProjection, + type StartupRunwayValues, +} from "@/lib/startup-runway-calculator/engine"; +import { SAAS_ECONOMICS_CURRENCIES, type SaasEconomicsCurrency } from "@/lib/saas-economics/shared"; +import styles from "./startup-runway-calculator.module.css"; + +type StatusMessage = { text: string; tone: "neutral" | "danger" }; +type FormFields = Omit; +type ChangeField = Exclude; +type Result = { values: StartupRunwayValues; calculatedAt: Date }; + +const CHANGED = "Inputs changed. Calculate again."; +const HORIZON = STARTUP_RUNWAY_HORIZON_MONTHS; + +const FREQUENCY_OPTIONS = [ + uiOption("once", "Once"), + uiOption("monthly", "Every month"), + uiOption("yearly", "Every 12 months"), +]; +const DIRECTION_OPTIONS = [uiOption("out", "Money out"), uiOption("in", "Money in")]; + +function emptyFields(): FormFields { + return { + currency: "USD", + cashNow: "", + collectedPerMonth: "", + paidOutPerMonth: "", + revenueGrowthPercent: "", + expenseGrowthPercent: "", + }; +} + +function emptyChange(id: string): StartupRunwayPlannedChangeInput { + return { id, name: "", direction: "out", amount: "", startMonth: "", frequency: "once" }; +} + +/* ---------------- formatting ---------------- */ + +function currencySymbol(currency: SaasEconomicsCurrency): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value ?? currency; +} + +function formatInteger(value: bigint | number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); +} + +function formatMoney(cents: bigint, currency: SaasEconomicsCurrency): string { + const negative = cents < 0n; + const magnitude = negative ? -cents : cents; + const fraction = (magnitude % 100n).toString().padStart(2, "0"); + return `${negative ? "−" : ""}${currencySymbol(currency)}${formatInteger(magnitude / 100n)}.${fraction}`; +} + +function formatRate(hundredths: bigint): string { + const negative = hundredths < 0n; + const magnitude = negative ? -hundredths : hundredths; + const fraction = (magnitude % 100n).toString().padStart(2, "0").replace(/0+$/, ""); + return `${negative ? "−" : ""}${formatInteger(magnitude / 100n)}${fraction ? `.${fraction}` : ""}%`; +} + +function formatRunway(tenths: bigint): string { + if (tenths === 0n) return "0"; + return `${formatInteger(tenths / 10n)}.${tenths % 10n}`; +} + +function formatMonths(count: number): string { + return `${formatInteger(count)} ${count === 1 ? "month" : "months"}`; +} + +/** "about 1 year 6 months" for runways of a year or more. */ +function yearsPhrase(tenths: bigint): string | null { + const whole = tenths / 10n; + if (whole < 12n) return null; + const years = whole / 12n; + const months = Number(whole % 12n); + const yearText = `${formatInteger(years)} ${years === 1n ? "year" : "years"}`; + return `about ${yearText}${months ? ` ${formatMonths(months)}` : ""}`; +} + +/** Calendar month for a runway, from the date Calculate ran. Only for runways up to the horizon. */ +function calendarMonth(tenths: bigint, from: Date): string | null { + if (tenths <= 0n || tenths > BigInt(HORIZON * 10)) return null; + const whole = Number(tenths / 10n); + const date = new Date(from.getFullYear(), from.getMonth(), 1); + date.setMonth(date.getMonth() + whole); + const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + date.setDate(Math.min(from.getDate(), lastDay) + Number(tenths % 10n) * 3); + return date.toLocaleDateString("en-US", { month: "long", year: "numeric" }); +} + +function changeLabel(change: { name: string }, index: number): string { + return change.name.trim() || `Planned change ${index + 1}`; +} + +function changeSummary(change: StartupRunwayPlannedChange, currency: SaasEconomicsCurrency): string { + const direction = change.direction === "in" ? "money in" : "money out"; + const amount = formatMoney(change.amountCents, currency); + const when = change.frequency === "once" + ? `once in month ${change.startMonth}` + : change.frequency === "monthly" + ? `every month from month ${change.startMonth}` + : `every 12 months from month ${change.startMonth}`; + return `${change.name} · ${direction} · ${amount} ${when}`; +} + +/* ---------------- results ---------------- */ + +type Lead = { label: string; value: string; unit?: string; lines: string[] }; + +function todayLead(values: StartupRunwayValues, calculatedAt: Date): Lead { + const { currency, today } = values; + const label = "At today's numbers"; + if (today.kind === "cashFlowPositive") { + return { label, value: "Cash-flow positive", lines: [`Cash grows by ${formatMoney(today.surplusCents, currency)} a month.`] }; + } + if (today.kind === "breakEven") { + return { + label, + value: "Break-even", + lines: [`Cash in equals cash out, so your balance stays at ${formatMoney(values.cashNowCents, currency)}.`], + }; + } + if (today.cashOutMonth === 0n) { + return { + label, + value: "0", + unit: "months", + lines: [`You have no cash to cover the ${formatMoney(today.netBurnCents, currency)} you're short this month.`], + }; + } + const years = yearsPhrase(today.runwayTenths); + const calendar = calendarMonth(today.runwayTenths, calculatedAt); + return { + label, + value: formatRunway(today.runwayTenths), + unit: "months", + lines: [ + `Cash runs out in month ${formatInteger(today.cashOutMonth)}${calendar ? `, around ${calendar}` : ""}.`, + ...(years ? [`That's ${years}.`] : []), + ], + }; +} + +function lowestLine(projection: StartupRunwayProjection, currency: SaasEconomicsCurrency): string | null { + const { lowestBalance } = projection; + if (lowestBalance.month === 0) return null; + return `Lowest balance: ${formatMoney(lowestBalance.cents, currency)} in month ${lowestBalance.month}.`; +} + +function forecastLead(values: StartupRunwayValues, projection: StartupRunwayProjection, calculatedAt: Date): Lead { + const { currency } = values; + const label = "With your forecast"; + const { cashOut, coversCostsFromMonth, firstCoversCostsMonth, outcome } = projection; + + if (outcome.kind === "runsOut" && cashOut) { + const lines: string[] = []; + const coveredBeforeCashOut = coversCostsFromMonth !== null && coversCostsFromMonth <= cashOut.month; + if (cashOut.month === 0) { + lines.push("You have no cash to cover the shortfall in month 1."); + } else if (coveredBeforeCashOut) { + const balance = projection.path[cashOut.month]?.balanceCents ?? -1n; + lines.push(balance === 0n + ? `A planned payment uses up the rest of your cash in month ${cashOut.month}.` + : `A planned payment takes cash below zero in month ${cashOut.month}.`); + } else { + const calendar = calendarMonth(cashOut.runwayTenths, calculatedAt); + lines.push(`Cash runs out in month ${cashOut.month}${calendar ? `, around ${calendar}` : ""}.`); + } + if (!coveredBeforeCashOut) { + lines.push(coversCostsFromMonth !== null + ? `Monthly income covers monthly costs from month ${coversCostsFromMonth}.` + : `Monthly costs still aren't covered by month ${HORIZON}.`); + } + return { label, value: formatRunway(cashOut.runwayTenths), unit: "months", lines }; + } + + if (outcome.kind === "coversCostsBeforeCashOut") { + const lines: string[] = []; + if (firstCoversCostsMonth !== null && firstCoversCostsMonth < outcome.month) { + lines.push(`Costs are covered in month ${firstCoversCostsMonth}, then rise above income again. They're covered for good from month ${outcome.month}.`); + } + lines.push(`No cash-out in the next ${HORIZON} months.`); + const lowest = lowestLine(projection, currency); + if (lowest) lines.push(lowest); + return { label, value: `Covers costs from month ${outcome.month}`, lines }; + } + + if (outcome.kind === "coversCostsThroughout") { + const lowest = lowestLine(projection, currency); + return { + label, + value: "Covers costs every month", + lines: [`No cash-out in the next ${HORIZON} months.`, ...(lowest ? [lowest] : [])], + }; + } + + return { + label, + value: `More than ${HORIZON} months`, + lines: [`No cash-out in the next ${HORIZON} months, but monthly costs still aren't covered by month ${HORIZON}.`], + }; +} + +function LeadOutput({ lead, resultRef }: { lead: Lead; resultRef?: RefObject }) { + return ( + + {lead.lines.map((line) => {line})} + + )} + inputRef={resultRef} + label={lead.label} + unit={lead.unit} + value={lead.value} + /> + ); +} + +function chartEndMonth(projection: StartupRunwayProjection): number { + if (projection.outcome.kind === "lastsBeyondHorizon") return HORIZON; + const key = Math.max(projection.cashOut?.month ?? 0, projection.coversCostsFromMonth ?? 0, projection.lowestBalance.month); + return Math.min(HORIZON, Math.max(12, key + 3)); +} + +function CashChart({ currency, projection }: { currency: SaasEconomicsCurrency; projection: StartupRunwayProjection }) { + const spec = useMemo(() => { + const end = chartEndMonth(projection); + const months = projection.path.filter((point) => point.month <= end); + const usd = currency === "USD"; + const count = projection.plannedChanges.length; + const changes = count ? `, ${count} planned ${count === 1 ? "change" : "changes"}` : ""; + return { + caption: `Revenue ${formatRate(projection.revenueGrowthHundredths)} a month, expenses ${formatRate(projection.expenseGrowthHundredths)} a month${changes}. Months 0 to ${end}.`, + format: usd ? "currency" : "number", + kind: "line", + series: [ + { id: "forecast", label: "With your forecast", points: months.map((point) => [point.month, Number(point.balanceCents) / 100] as const) }, + { id: "today", label: "At today's numbers", points: months.map((point) => [point.month, Number(point.atTodaysRateCents) / 100] as const) }, + ], + title: `Cash balance by month${usd ? "" : ` in ${currency}`}`, + xFormat: "number", + }; + }, [currency, projection]); + // The chart's hidden data table lays out at full width; clip it so the page doesn't scroll sideways. + return
; +} + +function MonthTable({ currency, projection }: { currency: SaasEconomicsCurrency; projection: StartupRunwayProjection }) { + const rows = projection.path.map((point) => { + const marks: string[] = []; + if (projection.cashOut && point.month === Math.max(projection.cashOut.month, 1)) marks.push("Cash runs out"); + if (point.month === projection.coversCostsFromMonth) marks.push("Covers costs"); + return { + id: `month-${point.month}`, + cells: { + month: marks.length ? `${point.month} · ${marks.join(" · ")}` : String(point.month), + collected: formatMoney(point.collectedCents, currency), + paidOut: formatMoney(point.paidOutCents, currency), + planned: point.plannedCents === 0n ? "None" : formatMoney(point.plannedCents, currency), + balance: formatMoney(point.balanceCents, currency), + }, + }; + }); + return ( + + + + ); +} + +function Assumptions({ values }: { values: StartupRunwayValues }) { + const { currency, projection } = values; + if (!projection) { + return

Runway = cash now ÷ (cash paid out − cash collected), with both staying the same each month.

; + } + const rate = (hundredths: bigint, wasBlank: boolean) => `${formatRate(hundredths)} a month${wasBlank ? " (left blank)" : ""}`; + const rows = [ + { label: "Revenue growth", value: rate(projection.revenueGrowthHundredths, projection.revenueGrowthWasBlank) }, + { label: "Expense growth", value: rate(projection.expenseGrowthHundredths, projection.expenseGrowthWasBlank) }, + { + label: "Planned changes", + value: projection.plannedChanges.length ? ( +
    + {projection.plannedChanges.map((change) =>
  • {changeSummary(change, currency)}
  • )} +
+ ) : "None", + }, + { label: "Month rule", value: "Month 1 uses the numbers you entered. Growth starts in month 2." }, + { label: "Horizon", value: formatMonths(HORIZON) }, + ]; + return ; +} + +function RunwayResults({ result, resultRef }: { result: Result; resultRef: RefObject }) { + const { values, calculatedAt } = result; + const { currency, projection } = values; + const compare = projection !== null && !projection.matchesToday; + const today = todayLead(values, calculatedAt); + const forecast = compare && projection ? forecastLead(values, projection, calculatedAt) : null; + const single = forecast ? null : { ...today, label: "Runway at today's numbers" }; + + const net = values.netBurnCents; + const burnItems: UiMetric[] = [ + { id: "gross", label: "Cash paid out (gross burn)", value: formatMoney(values.grossBurnCents, currency) }, + { id: "collected", label: "Cash collected", value: formatMoney(values.collectedPerMonthCents, currency) }, + net < 0n + ? { id: "net", label: "Net cash in", value: formatMoney(-net, currency) } + : { id: "net", label: "Net burn", value: formatMoney(net, currency) }, + ]; + + return ( +
+
+ {forecast ? ( +
+ + +
+ ) : single ? ( + + ) : null} + {projection?.matchesToday ?

Your forecast matches today's numbers. Add a growth rate or a planned change to compare.

: null} + {compare && projection?.cashShortfallCents != null ? ( + + ) : null} +
+ +
+

Monthly burn today

+ +
+ + {compare && projection ? ( +
+ + +
+ ) : null} + +
+

What the answer uses

+ +
+
+ ); +} + +/* ---------------- form ---------------- */ + +const MAIN_ORDER: StartupRunwayFieldKey[] = [ + "currency", + "cashNow", + "collectedPerMonth", + "paidOutPerMonth", + "revenueGrowthPercent", + "expenseGrowthPercent", +]; + +export function StartupRunwayCalculator() { + const [fields, setFields] = useState(emptyFields); + const [changes, setChanges] = useState([]); + const [changesOpen, setChangesOpen] = useState(false); + const [errors, setErrors] = useState({}); + const [result, setResult] = useState(null); + const [status, setStatus] = useState(null); + const nextChangeId = useRef(1); + const formRef = useRef(null); + const resultRef = useRef(null); + + const atLimit = changes.length >= STARTUP_RUNWAY_MAX_PLANNED_CHANGES; + + // Row fields share visible labels, so give each a row-specific accessible name. + useLayoutEffect(() => { + const form = formRef.current; + if (!form) return; + changes.forEach((change, index) => { + const row = `Planned change ${index + 1}`; + form.querySelector(`[data-focus-key="plannedChanges.${change.id}.amount"]`)?.setAttribute("aria-label", `${row} amount`); + form.querySelector(`[data-focus-key="plannedChanges.${change.id}.startMonth"]`)?.setAttribute("aria-label", `${row} start month`); + form.querySelector(`[data-focus-key="plannedChanges.${change.id}.name"]`)?.setAttribute("aria-label", `${row} name`); + }); + }); + + function errorText(key: StartupRunwayFieldKey): string | null { + return errors[key]?.message ?? null; + } + + function clearForEdit(announce?: string) { + const hadResult = result !== null; + setErrors({}); + setResult(null); + if (hadResult) setStatus({ text: CHANGED, tone: "neutral" }); + else if (announce) setStatus({ text: announce, tone: "neutral" }); + else if (status?.text !== CHANGED) setStatus(null); + } + + function updateField(field: K, value: FormFields[K]) { + setFields((current) => ({ ...current, [field]: value })); + clearForEdit(); + } + + function updateChange(id: string, field: ChangeField, value: string) { + setChanges((current) => current.map((change) => change.id === id ? { ...change, [field]: value } : change)); + clearForEdit(); + } + + function addChange() { + if (atLimit) return; + const change = emptyChange(`change-${nextChangeId.current++}`); + setChanges((current) => current.length >= STARTUP_RUNWAY_MAX_PLANNED_CHANGES ? current : [...current, change]); + clearForEdit(`Planned change ${changes.length + 1} added.`); + } + + function removeChange(id: string) { + const index = changes.findIndex((change) => change.id === id); + const label = index >= 0 ? changeLabel(changes[index], index) : "Planned change"; + setChanges((current) => current.filter((change) => change.id !== id)); + clearForEdit(`${label} removed.`); + } + + function focusKey(key: string) { + // Two frames, so a disclosure opened by the same submit has rendered its fields. + requestAnimationFrame(() => requestAnimationFrame(() => { + formRef.current?.querySelector(`[data-focus-key="${key}"]`)?.focus(); + })); + } + + function focusFirstError(nextErrors: StartupRunwayErrors) { + const order: StartupRunwayFieldKey[] = [ + ...MAIN_ORDER, + ...changes.flatMap((change) => [ + `plannedChanges.${change.id}.amount`, + `plannedChanges.${change.id}.direction`, + `plannedChanges.${change.id}.startMonth`, + `plannedChanges.${change.id}.frequency`, + `plannedChanges.${change.id}.name`, + ] as const), + "plannedChanges", + ]; + const first = order.find((key) => nextErrors[key]); + if (!first) return; + if (first.startsWith("plannedChanges")) setChangesOpen(true); + if (first === "plannedChanges") { + requestAnimationFrame(() => requestAnimationFrame(() => { + formRef.current?.querySelector("[data-repeatable-add]")?.focus(); + })); + } else { + focusKey(first); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + setStatus(null); + try { + const calculation = calculateStartupRunway({ ...fields, plannedChanges: changes }); + if (calculation.status === "invalid") { + setErrors(calculation.errors); + setResult(null); + if (calculation.errors.form) setStatus({ text: calculation.errors.form.message, tone: "danger" }); + focusFirstError(calculation.errors); + return; + } + setErrors({}); + setResult({ values: calculation.values, calculatedAt: new Date() }); + requestAnimationFrame(() => resultRef.current?.focus()); + } catch { + setErrors({}); + setResult(null); + setStatus({ text: "Couldn't calculate. Try again.", tone: "danger" }); + } + } + + function reset() { + setFields(emptyFields()); + setChanges([]); + setChangesOpen(false); + setErrors({}); + setResult(null); + setStatus({ text: "Form reset.", tone: "neutral" }); + focusKey("cashNow"); + } + + const listNote = errors.plannedChanges?.message ?? (atLimit ? `Up to ${STARTUP_RUNWAY_MAX_PLANNED_CHANGES} planned changes.` : undefined); + const changeCount = changes.length ? `${changes.length} planned` : undefined; + + return ( +
+
+
+
+
+

Today

+
+ { + if (!SAAS_ECONOMICS_CURRENCIES.includes(value as SaasEconomicsCurrency)) return; + updateField("currency", value); + }} + options={SAAS_ECONOMICS_CURRENCIES.map((currency) => uiOption(currency, currency))} + required + value={fields.currency} + /> +
+
+
+ updateField("cashNow", value)} + required + spellCheck={false} + suffix={fields.currency} + value={fields.cashNow} + /> +
+
+ updateField("collectedPerMonth", value)} + required + spellCheck={false} + suffix={fields.currency} + value={fields.collectedPerMonth} + /> + updateField("paidOutPerMonth", value)} + required + spellCheck={false} + suffix={fields.currency} + value={fields.paidOutPerMonth} + /> +
+
+ +
+
+

Forecast (optional)

+

Month 1 uses the numbers above. Growth starts in month 2.

+
+
+ updateField("revenueGrowthPercent", value)} + spellCheck={false} + suffix="%" + value={fields.revenueGrowthPercent} + /> + updateField("expenseGrowthPercent", value)} + spellCheck={false} + suffix="%" + value={fields.expenseGrowthPercent} + /> +
+ +
+

A hire, a yearly renewal, a one-off bill, or money you're sure is coming. Month 1 is the first month from today. Amounts don't grow.

+ `Remove ${changeLabel(change, index)}`} + renderItem={(change, index) => ( +
+ Planned change {index + 1} + updateChange(change.id, "amount", value)} + required + spellCheck={false} + suffix={fields.currency} + value={change.amount} + /> +
+ updateChange(change.id, "direction", value)} + options={DIRECTION_OPTIONS} + value={change.direction} + /> +
+ updateChange(change.id, "startMonth", value)} + required + spellCheck={false} + value={change.startMonth} + /> +
+ updateChange(change.id, "frequency", value)} + options={FREQUENCY_OPTIONS} + value={change.frequency} + /> +
+ updateChange(change.id, "name", value)} + placeholder={`Planned change ${index + 1}`} + value={change.name} + /> +
+ )} + /> +
+
+
+ + + Calculate runway + + + + {status ? {status.text} : null} +
+ {result ? : null} +
+
+ ); +} diff --git a/app/ui/charts/chart-parts.tsx b/app/ui/charts/chart-parts.tsx index 7d1a267f..b57b20c9 100644 --- a/app/ui/charts/chart-parts.tsx +++ b/app/ui/charts/chart-parts.tsx @@ -15,23 +15,26 @@ export function ChartHeading({ spec }: { spec: UiChartSpec }) { export function ChartDataTable({ spec }: { spec: UiChartSpec }) { const table = chartTable(spec); if (!table) return null; + // The wrapper hides the table. A table sized to its content would still widen the page. return ( - - - - - - {table.columns.map((column, index) => )} - - - - {table.rows.map((row, index) => ( - - - {row.cells.map((cell, column) => )} +
+
{spec.title}
{table.corner}{column}
{row.label}{cell}
+ + + + + {table.columns.map((column, index) => )} - ))} - -
{spec.title}
{table.corner}{column}
+ + + {table.rows.map((row, index) => ( + + {row.label} + {row.cells.map((cell, column) => {cell})} + + ))} + + + ); } diff --git a/content_docs/annual-plan-discount-calculator/research.md b/content_docs/annual-plan-discount-calculator/research.md new file mode 100644 index 00000000..d30e99ae --- /dev/null +++ b/content_docs/annual-plan-discount-calculator/research.md @@ -0,0 +1,266 @@ +# Research: Monthly vs annual pricing calculator + +Recommended flat slug: `/annual-plan-discount-calculator`. The phrase “annual plan discount calculator” appears in the seller-facing results and names the job clearly. Use “monthly vs annual pricing” in the page title and description to cover the comparison wording. `/monthly-vs-annual-pricing-calculator` is accurate, but long and broad enough to attract customers comparing a purchase rather than founders setting a price. `/annual-discount-calculator` is shorter, but less clear that this is for recurring plans. + +Route: `/annual-plan-discount-calculator` (chosen by coordinator 2026-10-10) +Catalog: SaaS Tools (`/saas-tools`) +Checked: 2026-10-10 +Competitors used: 6 + +## Scope + +The user is a solo SaaS founder choosing an annual price for a monthly plan. They need to convert between monthly price, annual price, discount percent, and months free; understand the upfront amount collected; and compare expected customer billings over 12 or more months. For the business comparison, monthly churn and annual renewal survival must be separate assumptions. Processor fees and refund effects change retained cash, while MRR is a normalized run-rate measure. + +Keep these quantities distinct: + +- **Price and discount:** how the annual contract compares with twelve monthly charges. +- **Cash collected:** the invoice amount collected at each payment date, before refunds, fees, taxes, and failed payments unless the model explicitly includes them. +- **Expected billings per acquired customer:** a scenario estimate that depends on churn or renewal assumptions. It is not a forecast of demand or accounting revenue. +- **MRR:** the discounted annual contract value divided by twelve. It is not the amount deposited up front. +- **Net receipts after modeled processor fees:** a simplified comparison, not profit. It excludes other costs and depends on the processor, country, card mix, plan, and fee schedule. + +This is different from existing Stack127 pages: + +- [`/mrr-calculator`](/mrr-calculator) normalizes plan prices and billing intervals into a current MRR and ARR portfolio. It does not decide an annual discount, model churn cohorts, or compare upfront and repeated collections. +- [`/customer-lifetime-value-calculator`](/customer-lifetime-value-calculator) estimates customer lifetime value from ARPA, margin, and churn. It does not compare two billing offers or distinguish annual renewal survival from monthly churn. +- [`/platform-fees`](/platform-fees) is a broad reference to platform charges. It does not compare per-customer billing schedules or show how fewer fixed transaction fees affect an annual discount decision. + +Searches tried: `SaaS annual vs monthly pricing calculator annual discount calculator`; `annual plan discount calculator SaaS monthly annual pricing tool`; `SaaS pricing annual plan monthly churn annual discount break even calculator`; `how much discount annual billing SaaS pricing calculator`. Recurring domains included `mathbehind.com`, `moneydeck.io`, `saasturf.com`, `sendpaybusiness.com`, `saasswitcher.com`, and `rankite.com`. These are mostly pricing or SEO-tool sites rather than billing-provider tools. I did not find an official processor calculator among the surfaced results; processor docs were used as primary sources for fee facts. + +## Search intent + +| Query | What the searcher wants | Answer they need | What current results miss | +| --- | --- | --- | --- | +| SaaS annual vs monthly pricing calculator | Compare the business result of offering monthly and annual billing | Annual price, customer savings, upfront collection, and an apples-to-apples period comparison | Many results show buyer savings only or one first-year churn calculation. Few model annual renewals or fees. | +| annual plan discount calculator | Set an annual price from a monthly price, or derive a discount from an existing annual price | Both conversion directions, exact discount, savings, and monthly equivalent | Several tools provide only one direction. Some allow negative prices or discounts over 100% to create nonsensical output. | +| two months free annual plan percentage | Translate a familiar promotion into an annual discount | Two months free is 2/12 = 16.7%; show the reverse conversion too | Pages sometimes present months-free and percent-off without explaining they are different units. | +| SaaS annual pricing monthly churn break even | See whether lower prepaid revenue can be offset by customers who would otherwise churn | Expected monthly billings under churn, annual renewal assumptions, and the break-even discount or tenure | Most tools use monthly churn but stop at year one, or omit annual renewal behavior. | +| how much discount should I give for annual SaaS billing | Choose a price based on retention, cash timing, and buyer response | A scenario result, with assumptions, instead of a universal benchmark | Competitor copy cites broad “healthy” ranges without tying them to a founder's churn, renewal, or margin. | +| Stripe vs Paddle vs Lemon Squeezy annual billing fees | Understand whether fewer annual charges save processing cost | Current fee examples by processor, payment count, and fee date | General fee pages do not apply the fixed per-charge fee to the monthly and annual alternatives. | + +Google “People also ask” questions visible for `annual discount calculator SaaS pricing monthly annual` on 2026-10-10: + +- “How do I convert an annual discount rate to a monthly discount rate?” +- “How much should I charge for my SaaS?” +- “How is a 20% discount calculated?” +- “How do I calculate a 30% discount?” + +Related questions and competitor FAQs that fit the task include “What annual discount is typical?”, “Does this include renewals?”, “How much is two months free as a percentage?”, and “Why offer annual plans if they can earn less?” No top result gives a well-sourced universal answer to what discount is typical. The useful answer is to calculate what a chosen discount costs under the founder's own churn, annual renewal, fee, and refund assumptions. + +## User problems + +| Problem people repeat | Evidence link | What it means for the tool | +| --- | --- | --- | +| An early founder has little or no sales or retention data, but still has to decide whether to launch annual pricing. Replies disagree about waiting for retention evidence versus quietly offering an annual option. | [Yearly vs. Monthly Subscriptions. Which one works better, and why?](https://www.reddit.com/r/SaaS/comments/1sa77cm/yearly_vs_monthly_subscriptions_which_one_works/) (April 2026) | Do not label one discount as right for every SaaS. Let users see scenarios and state that churn and renewal inputs are assumptions, especially for a new product. | +| A founder with onboarding and pricing still changing worries that annual commitments will create refund friction. Other founders favor a low-visibility annual option for buyers who need one. | [Monthly only vs monthly + annual for a new SaaS?](https://www.reddit.com/r/SaaS/comments/1vrdgi0/monthly_only_vs_monthly_annual_for_a_new_saas/) (August 2026) | Explain that the calculator compares payment economics; it cannot decide whether the product is ready for a long commitment or predict conversion lift. Make refund assumptions visible. | +| A small SaaS founder asks whether it is fair to refund annual plans but not monthly plans. | [My budgeting SaaS offers refunds on annual plans but not monthly. Is that fair?](https://www.reddit.com/r/micro_saas/comments/1svqrn4/my_budgeting_saas_offers_refunds_on_annual_plans/) (2026) | An annual prepayment is not necessarily retained cash. Show gross collections separately from refunds and fees, and avoid treating a refund policy as a processor fact. | +| Founders ask peers what happened after adding a discounted annual option because the appeal of upfront cash competes with the risk of giving away months of service. | [SaaS founders who added a discounted annual payment option, what were the results?](https://www.indiehackers.com/post/saas-founders-who-added-a-discounted-annual-payment-option-what-were-the-results-3ec3058120) (2018) | Give worked examples of the discount cost and expected collections. Treat forum replies as individual experiences, not evidence for a general retention lift. | +| Buyers can be put off by annual-only offers or discounts that make the monthly price feel inflated. | [How do you handle SaaS tools that only offer annual billing?](https://www.reddit.com/r/indianstartups/comments/1vjvamp/how_do_you_handle_saas_tools_that_only_offer/) (2026) | Keep buyer savings and seller revenue visible together. The calculator should not imply that a larger discount is automatically better for conversion. | + +Forum posts show questions and opinions, not measured causal effects. Do not use them as benchmarks for churn, conversion, or ideal discounts. + +## Facts to get right + +| Fact | Primary source | How the tool should treat it | +| --- | --- | --- | +| Annual price and discount conversions | Arithmetic; see formulas below | For monthly price `P`, annual price `A`, and discount fraction `d`: `A = 12P(1-d)` and `d = 1-A/(12P)`. If `A > 12P`, report a surcharge or negative discount rather than hiding it. Months free is `12d`; two months free is 16.7%, and 20% off is 2.4 months free. | +| Monthly plan billings with constant monthly churn | Geometric-series calculation; churn definition should be stated | For a horizon of `n` monthly invoices, first invoice paid at signup, and monthly churn `c` before each later renewal, expected billings are `P × Σ(1-c)^k` for `k=0..n-1`. When `c > 0`, this is `P × [1-(1-c)^n]/c`; at `c=0`, it is `nP`. This assumes a stable independent churn probability and excludes plan changes, failed payments, refunds, and changes in acquisition. | +| Annual billings with renewals | Geometric-series calculation; annual renewal survival `q` is a separate input | Over `y` complete annual terms, expected billings from one initial annual sale are `A × Σq^j` for `j=0..y-1`. For two terms it is `A(1+q)`; for three, `A(1+q+q²)`. `q` is the chance the customer renews at each year boundary, not the monthly plan's churn rate. This assumes no mid-term cancellation refund. | +| Break-even discount and price-equivalent tenure | Arithmetic using the assumptions above | In one year, let `E` be expected monthly-plan billings after monthly churn: `E=P × Σ(1-c)^k` for `k=0..11`. The annual discount at which annual gross billings equal this monthly scenario is `d_BE=1-E/(12P)`, for `P>0`. A smaller discount gives more gross billings from the annual sale in this comparison; a larger discount gives less. Separately, the annual invoice equals `A/P` monthly invoices. Show the fractional threshold and, if useful, the first whole monthly invoice that reaches it. These are two different meanings of “break even.” | +| Annual MRR is normalized contract value, not upfront cash | [Stripe: Understanding MRR and ARR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29); [Paddle: MRR](https://www.paddle.com/help/profitwell-metrics/measure/subscription-metrics/mrr) | Use discounted annual contract price divided by 12. An annual contract of $200 contributes $16.67 MRR while collecting $200 upfront. Stripe says MRR is distinct from cash flow and GAAP revenue. Do not subtract processor fees from MRR. | +| Stripe published US online card rate and billing fee | [Stripe pricing](https://stripe.com/pricing) | Checked 2026-10-10. The displayed standard US online card rate is 2.9% + $0.30 per successful transaction. Stripe Billing pay-as-you-go is a separate 0.7% of Billing volume. A fee estimate must state country, payment type, and date; country, card, currency, and account terms can change the fee. | +| Stripe refund treatment | [Stripe: Understanding fees for refunded payments](https://support.stripe.com/questions/understanding-fees-for-refunded-payments) | Checked 2026-10-10. Stripe says standard pricing does not charge a refund fee for most payment methods, but original processing, Connect, and currency-conversion fees are not returned. Some methods and regions can differ. Do not treat a refunded card charge as fee-free. | +| Paddle published checkout rate and refund uncertainty | [Paddle pricing](https://www.paddle.com/pricing); [Paddle: issue refunds](https://www.paddle.com/help/manage/your-customers/how-do-i-issue-refunds) | Checked 2026-10-10. Published pay-as-you-go price is 5% + $0.50 per Checkout transaction, with payment, billing, tax, and other merchant-of-record services included. Paddle allows full or partial refunds. The public refund help page does not settle whether the original processing fee is returned in every case; treat refund fee treatment as unknown unless the user's contract or account confirms it. Custom pricing may apply. | +| Lemon Squeezy published transaction and refund fees | [Lemon Squeezy pricing](https://www.lemonsqueezy.com/pricing); [fee documentation](https://docs.lemonsqueezy.com/help/getting-started/fees); [refunds and chargebacks](https://docs.lemonsqueezy.com/help/payments/refunds-chargebacks) | Checked 2026-10-10. The headline rate is 5% + $0.50 per transaction. The docs list an extra 0.5% for subscription payments, plus 1.5% for international payments outside the US and 1.5% for PayPal. The refund page says refund amounts, less the platform fee, are deducted from the seller's next payout; chargebacks can also incur a $15 dispute fee. Fees can vary by plan and payout path. | +| Processor fee effect on annual discounts | Current provider fee schedules above | Model percentage fees on the amount charged and fixed fees once per successful paid transaction. For equal sales tax and a US $20 monthly price, a two-month-free annual price is $200. At the listed base rates and excluding other fees: Stripe costs $10.56 for twelve $20 card charges vs $6.10 for one $200 charge; Paddle costs $18 vs $10.50; Lemon Squeezy subscription fees cost $19.20 vs $11.50. Annual saves fixed-charge costs, but still collects $40 less gross than twelve full monthly payments. Fee savings alone do not establish that the discount is profitable. | +| Refunds and fee estimates | Processor documentation above; refund policy is seller-specific | Keep displayed base comparisons before refunds. If a refund scenario is added, make the refund timing and amount an explicit assumption, and apply provider-specific fee treatment only where verified. Taxes, chargebacks, payout costs, currency conversion, payment failures, support, and service delivery costs are outside the base comparison. | + +### Formula examples + +For a $20 monthly plan, two months free means `A=$20×10=$200`, a 16.7% discount, $40 saved against twelve monthly payments, and annual MRR of `$200/12=$16.67`. Cash collected at initial checkout is $200 before refunds, fees, and taxes. + +At 3% monthly churn, expected monthly paid invoices in the first year are `Σ(0.97)^k` for `k=0..11`, or about 10.20. Expected monthly-plan billings are about $204.11. The one-year break-even discount is about 15.0%. At a 16.7% discount, annual first-year billings are $200, about $4.11 below the monthly scenario before fees. That result says nothing about which offer sells more customers or whether an annual buyer renews. + +For three annual terms at 70% annual renewal survival, expected annual-plan billings are `A(1+0.7+0.7²)`. Compare against a monthly scenario using the monthly churn assumption over the same 36-month horizon. Do not substitute monthly churn for annual renewal survival without telling the user. + +### Processor example limits + +The fee examples use published standard US assumptions as checked 2026-10-10. Stripe's example uses its standard card rate only; the separate 0.7% Billing fee is excluded. Paddle's price is its published pay-as-you-go Checkout rate. Lemon Squeezy uses its base transaction rate plus the published subscription surcharge, with no international or PayPal surcharge. They exclude tax treatment, custom deals, refunds, disputes, currency effects, and provider-specific account terms. They are illustrations, not quotes for a particular founder. + +## Competitor reviews + +All six tools below were opened and tested on 2026-10-10. “Wrong input” means a negative or out-of-range value, not a calculation error deliberately supplied by the user. + +### 1. MathBehind Annual vs Monthly Pricing Calculator + +URL: [mathbehind.com/tools/annual-vs-monthly-pricing-calculator](https://mathbehind.com/tools/annual-vs-monthly-pricing-calculator) + +- [Observed] It asks for monthly price, annual discount, and monthly churn. Results include annual price, annual monthly equivalent, customer savings, first-year expected monthly billings, maximum first-year break-even discount, and the monthly payment count to equal the annual charge. +- [Observed] Correct: `$20/month, 20% discount, 3% monthly churn` returned `$192` annual, `$16/month` equivalent, `$48` customer savings, `$204.11` expected monthly first-year billings, 15.0% maximum break-even discount, and 9.6 months to the annual amount. +- [Observed] Edge: `0% churn` returned `$240` expected monthly billings and `0%` maximum break-even discount. +- [Observed] Wrong input: monthly price `-$1` produced zero-dollar outputs and “n/a”, rather than a visible input error. The URL retained the negative input. +- [Documented] It gives the discount formula and a monthly churn calculation; zero churn is handled as a special case. +- [Inferred] This is the closest direct match for a churn-adjusted first-year comparison. It does not model renewal years, processor fees, or refunds. +- [Unknown] I could not confirm its churn estimates against user data or whether it supports annual renewal survival elsewhere. + +```text +$20/month -> 20% off -> 3% monthly churn -> $192 annual; $204.11 expected monthly first-year billings +``` + +### 2. MoneyDeck Annual Plan Discount Calculator + +URL: [moneydeck.io/tools/annual-plan-discount-calculator](https://moneydeck.io/tools/annual-plan-discount-calculator) + +- [Observed] It asks for monthly price, annual discount, monthly churn, new signups per month, and annual-plan share. Outputs include price, annual advantage, paid months, break-even churn, first month cash, and a cumulative 12-month chart. +- [Observed] Correct: `$50/month, 20% off, 5% monthly churn, 100 signups/month, 30% annual share` returned `$480` annual, `$459.64` expected first-year monthly billings for a customer, `$20.36` annual advantage on that per-customer comparison, 9.19 months to match the annual charge, and month-one collections of `$17,900` across the assumed cohort. +- [Observed] Edge: `0% churn` gave `$600` monthly first-year billings against `$480` annual and showed the annual offer collecting cash earlier while earning less in the first year. +- [Observed] Wrong input: `110% discount` violated the field's HTML max but still produced results as though the discount had been capped at 90%, with a `$60` annual price and 10.8 months free. +- [Documented] The page labels its calculations and identifies a first-year comparison. It displayed “updated 29 Sep 2026.” +- [Inferred] It is the strongest competitor for connecting churn to cohort cash flow, but a capped out-of-range value can hide the user's mistake. Its one-year view does not answer longer-term annual renewal economics. +- [Unknown] The page does not establish its churn or annual-plan mix as representative benchmarks. + +```text +$50/month -> 20% off -> 5% churn -> $480 annual; $459.64 monthly expected first-year billings +``` + +### 3. SaaSTurf Annual Plan Discount Calculator + +URL: [saasturf.com/tools/annual-plan-discount-calculator](https://www.saasturf.com/tools/annual-plan-discount-calculator/) + +- [Observed] It accepts monthly and annual prices and shows discount, savings, and annual monthly equivalent. +- [Observed] Correct: `$20/month, $192/year` returned 20% discount, `$48` savings, and `$16/month` equivalent. +- [Observed] Edge: `$0/year` returned a 100% discount, `$240` savings, and `$0/month` equivalent. +- [Observed] Wrong input: `-$1/year` was invalid according to the HTML minimum but still calculated as a 100.4% discount, `$241` savings, and `-$0.08/month` equivalent. +- [Documented] The page describes 15% to 20% as a common “healthy” annual discount but does not show supporting data on the calculator page. +- [Inferred] Its conversion is quick, but the unsupported benchmark could be mistaken for a rule. Negative annual prices should be rejected visibly. +- [Unknown] It does not show a source for the discount benchmark or model churn, annual renewals, fees, or refunds. + +```text +$20/month -> $192/year -> 20% discount; -$1/year -> 100.4% discount +``` + +### 4. SendPay Business Annual Pricing Calculator + +URL: [sendpaybusiness.com/tools/annual-pricing-calculator](https://www.sendpaybusiness.com/tools/annual-pricing-calculator) + +- [Observed] It accepts monthly price and either months free or percent off, plus annual customer count. It shows annual price, monthly equivalent, savings, discount, and upfront collection. +- [Observed] Correct: `$10/month, 2 months free, 100 annual customers` returned `$100/year`, `$8.33/month` equivalent, `$20` saved per customer, 16.7% off, and `$10,000` collected upfront. +- [Observed] Edge: `12 months free` returned a $0 annual price and 100% discount. Entering 13 months was effectively clamped to the same result without a clear error. +- [Observed] Wrong input: monthly price `-$10` was accepted and displayed zero price, savings, and upfront collection while retaining the 16.7% discount from the months-free field. +- [Documented] It explains both months-free and percentage-off entry and multiplies the annual amount by customer count. +- [Inferred] It is useful for promotional math and upfront cash, but a line about removing eleven chances to cancel can imply that an annual customer cannot cancel or request a refund. +- [Unknown] It does not show churn, renewal assumptions, fee math, or refund behavior. + +```text +$10/month -> 2 months free -> 100 customers -> $100 annual price; $10,000 upfront +``` + +### 5. SaaSSwitcher Annual vs Monthly Calculator + +URL: [saasswitcher.com/tools/annual-vs-monthly-calculator](https://www.saasswitcher.com/tools/annual-vs-monthly-calculator) + +- [Observed] It compares monthly and annual per-seat prices and seat count, showing annual total, monthly total, savings, discount, and months to break even. It is framed for a buyer comparing software plans. +- [Observed] Correct: `10 seats, $15/seat/month, $12/seat/month annual equivalent` returned `$1,440` annual vs `$1,800` monthly for a year, `$360` saved, 20% off, and a 9.6-month break-even. +- [Observed] Edge: `$0` annual equivalent returned a 100% discount and $1,800 savings. +- [Observed] Wrong input: `-$1` annual equivalent bypassed the HTML minimum and produced a 107% discount and negative annual price; the threshold output became a dash. +- [Documented] The page compares seat-based buyer costs and identifies upfront annual billing. +- [Inferred] Its break-even label describes buyer spend parity, not seller revenue after monthly churn. Founders could confuse the two meanings if adopting its label without explanation. +- [Unknown] It does not model seller churn, annual renewals, processor costs, or refunds. + +```text +10 seats × $15/month -> $12 annual equivalent -> $1,440 annual vs $1,800 monthly +``` + +### 6. Rankite Annual vs Monthly Pricing Calculator + +URL: [rankite.com/free-seo-tools/annual-vs-monthly-pricing-calculator](https://rankite.com/free-seo-tools/annual-vs-monthly-pricing-calculator) + +- [Observed] It accepts monthly and annual price and shows monthly equivalent, discount, savings, and a break-even month. +- [Observed] Correct: `$20/month, $192/year` returned `$16/month` equivalent, 20% discount, `$48` saved, and 10 months to break even. +- [Observed] Edge: `$0/year` showed dashes and a message to enter an annual price greater than zero. +- [Observed] Wrong input: `-$1/year` also showed dashes and the validation message. +- [Documented] Its zero and negative annual prices are rejected from the result, unlike several competitors tested. +- [Inferred] The 10-month result is a rounded-up invoice threshold corresponding to the 9.6-month amount shown by MathBehind and SaaSSwitcher. The display would be clearer if it distinguished fractional price parity from the first whole monthly charge. +- [Unknown] It does not explain churn, annual renewal, cash flow for a seller, fees, or refunds. + +```text +$20/month -> $192/year -> 20% discount; $20/month -> $0 or -$1/year -> validation message +``` + +## Comparison + +| Feature or answer | MathBehind | MoneyDeck | SaaSTurf | SendPay | SaaSSwitcher | Rankite | What Stack127 should do | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Convert price, discount, or months free both ways | Price plus discount | Price plus discount | Monthly plus annual price | Months free or percent off | Monthly and annual buyer price | Monthly and annual price | Show reciprocal conversion and label units clearly. | +| Customer savings and annual equivalent | Yes | Yes | Yes | Yes | Yes | Yes | Show annual total, monthly equivalent, discount, and dollars saved. | +| Upfront collection | No | Cohort estimate | No | Yes | Annual invoice total | No | Separate cash collected from MRR and expected multi-term billings. | +| Monthly churn | Yes | Yes | No | No | No | No | State the survival assumption and compare over matched 12, 24, and 36 month horizons. | +| Annual renewal survival | No | No | No | No | No | No | Add a separate renewal assumption; do not reuse monthly churn. | +| Processor fees and refunds | No | No | No | No | No | No | Use dated optional fee assumptions, payment counts, and clear refund limits. | +| Break-even meaning | Revenue and invoice count | Churn-adjusted revenue and invoice count | Not shown | Not shown | Buyer spend parity | Rounded buyer parity | Name revenue break-even separately from invoice or buyer spend parity. | +| Invalid input behavior | Negative monthly price becomes zero / n/a | Out-of-range discount is capped | Negative annual price calculates | Negative monthly price creates inconsistent zeros | Negative annual price calculates | Negative annual price is rejected | Reject invalid prices and impossible discounts with a visible, specific message. | + +## Where competitors disagree + +- **9.6 months versus 10 months:** MathBehind and SaaSSwitcher show the fractional price-equivalent time for `$192 / $20 = 9.6` months. Rankite shows 10 months, which is the first whole monthly invoice that reaches or exceeds `$192`. These can both be useful if labeled. Do not silently present rounded 10 as an exact threshold. +- **A “typical” discount versus a calculated threshold:** SaaSTurf calls 15% to 20% “healthy,” while churn-aware calculators can return different break-even discounts for different churn and price inputs. The percentage that matches expected first-year billings is formula-dependent, not a universal best price. A break-even result also ignores the effect of discount on conversion, buyer mix, support, product usage, and renewal. +- **Buyer savings versus seller economics:** SaaSSwitcher and Rankite mainly answer how much the buyer saves and when cumulative monthly payments reach an annual invoice. MathBehind and MoneyDeck add a monthly-churn-adjusted seller-side first-year view. These answer different questions and should not share an unexplained “break even” label. +- **A monthly churn rate is not an annual renewal rate:** Competitors that use monthly churn commonly stop at one year. Projecting an annual plan beyond its prepaid term requires a renewal probability at each year boundary. Extending a monthly churn percentage to annual subscriptions without stating the assumption changes the model. +- **Gross collections are not retained receipts:** None of the six tools deducts processor fees or refunds. Published schedules show a fixed per-transaction component, so fewer annual charges can lower processing cost. But in the worked $20 example, this saving does not recover the $40 annual discount. For Lemon Squeezy, subscription transactions have an extra published 0.5% fee; comparing only the headline 5% + $0.50 misses that. +- **MRR is not cash collected:** Stripe and Paddle normalize annual contract value by twelve for MRR. The upfront annual invoice remains a cash-flow event. Reporting the full annual prepayment as MRR would overstate recurring run rate. + +## How Stack127 can win + +- Make the seller's decision the center of the page: price an annual offer, compare it with the monthly offer, and see what the discount costs under explicit retention assumptions. +- Support both directions between monthly price, annual price, discount percent, and months free. Show `$` saved and effective monthly equivalent beside the percent. +- Compare 12, 24, and 36 complete months using monthly churn for monthly billing and annual renewal survival for annual billing. Define whether the first invoice is already paid and when renewals occur. +- Separate initial upfront cash, expected gross billings over the horizon, normalized MRR, and optional estimated receipts after payment fees. Do not call any of those “profit.” +- Show two distinct thresholds: the annual price expressed as monthly invoice equivalents, and the discount at which annual expected billings equal the churn-adjusted monthly scenario. If showing whole monthly invoice count, explain the rounding. +- Use worked examples such as two months free = 16.7%, and make zero churn, zero discount, annual price above list price, and short tenure easy to understand. +- Make invalid states explicit: monthly price must be positive; annual price cannot be negative; discount must be below 100% unless a free year is intentionally modeled; churn and renewal must be in the 0% to 100% range. Do not silently clamp out-of-range values. +- If offering processor estimates, use dated, editable assumptions. Distinguish percent fees from fixed fees per successful charge and separate Stripe Billing's 0.7% volume fee from card processing. Let the user override the preset because published fees differ by country, account, payment method, and custom plan. +- State that processor estimates exclude taxes, refunds unless modeled, failed payments, chargebacks, payout and currency costs, discounts, support, hosting, margin, and conversion effects. Surface provider refund-fee unknowns rather than inventing a number. +- Link the existing MRR, customer lifetime value, and platform fee references where they answer a next question, while saying plainly that this calculator compares billing choices and their expected collections. +- Avoid a canned “best annual discount” recommendation. A founder without trustworthy churn and renewal data should be able to enter a range and read the sensitivity, not mistake a point estimate for evidence. + +## Blockers + +None for the research or a calculator using user-entered assumptions. Current processor fee presets would need dated source links and periodic review; editable rates can keep the calculation usable when a provider changes its public price. Public Paddle refund documentation does not confirm original fee treatment in every case, so that detail must stay unknown until supported by the user's account terms. + +## Sources + +### Primary sources, checked 2026-10-10 + +- [Stripe pricing](https://stripe.com/pricing) +- [Stripe: Understanding fees for refunded payments](https://support.stripe.com/questions/understanding-fees-for-refunded-payments) +- [Stripe: Understanding MRR and ARR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29) +- [Paddle pricing](https://www.paddle.com/pricing) +- [Paddle: Issue refunds](https://www.paddle.com/help/manage/your-customers/how-do-i-issue-refunds) +- [Paddle: MRR](https://www.paddle.com/help/profitwell-metrics/measure/subscription-metrics/mrr) +- [Lemon Squeezy pricing](https://www.lemonsqueezy.com/pricing) +- [Lemon Squeezy fee documentation](https://docs.lemonsqueezy.com/help/getting-started/fees) +- [Lemon Squeezy: Refunds and chargebacks](https://docs.lemonsqueezy.com/help/payments/refunds-chargebacks) +- [Google search query and People also ask results](https://www.google.com/search?q=annual+discount+calculator+SaaS+pricing+monthly+annual) + +### Competitors, operated and checked 2026-10-10 + +- [MathBehind Annual vs Monthly Pricing Calculator](https://mathbehind.com/tools/annual-vs-monthly-pricing-calculator) +- [MoneyDeck Annual Plan Discount Calculator](https://moneydeck.io/tools/annual-plan-discount-calculator) +- [SaaSTurf Annual Plan Discount Calculator](https://www.saasturf.com/tools/annual-plan-discount-calculator/) +- [SendPay Business Annual Pricing Calculator](https://www.sendpaybusiness.com/tools/annual-pricing-calculator) +- [SaaSSwitcher Annual vs Monthly Calculator](https://www.saasswitcher.com/tools/annual-vs-monthly-calculator) +- [Rankite Annual vs Monthly Pricing Calculator](https://rankite.com/free-seo-tools/annual-vs-monthly-pricing-calculator) + +### Community discussions + +- [Reddit: Yearly vs. Monthly Subscriptions. Which one works better, and why?](https://www.reddit.com/r/SaaS/comments/1sa77cm/yearly_vs_monthly_subscriptions_which_one_works/) +- [Reddit: Monthly only vs monthly + annual for a new SaaS?](https://www.reddit.com/r/SaaS/comments/1vrdgi0/monthly_only_vs_monthly_annual_for_a_new_saas/) +- [Reddit: My budgeting SaaS offers refunds on annual plans but not monthly. Is that fair?](https://www.reddit.com/r/micro_saas/comments/1svqrn4/my_budgeting_saas_offers_refunds_on_annual_plans/) +- [Indie Hackers: SaaS founders who added a discounted annual payment option, what were the results?](https://www.indiehackers.com/post/saas-founders-who-added-a-discounted-annual-payment-option-what-were-the-results-3ec3058120) +- [Reddit: How do you handle SaaS tools that only offer annual billing?](https://www.reddit.com/r/indianstartups/comments/1vjvamp/how_do_you_handle_saas_tools_that_only_offer/) + +### Existing Stack127 pages checked 2026-10-10 + +- [`/mrr-calculator`](https://stack127.com/mrr-calculator) +- [`/customer-lifetime-value-calculator`](https://stack127.com/customer-lifetime-value-calculator) +- [`/platform-fees`](https://stack127.com/platform-fees) diff --git a/content_docs/annual-plan-discount-calculator/tool-spec.md b/content_docs/annual-plan-discount-calculator/tool-spec.md new file mode 100644 index 00000000..2636ab4a --- /dev/null +++ b/content_docs/annual-plan-discount-calculator/tool-spec.md @@ -0,0 +1,538 @@ +# Annual plan discount calculator + +Route: `/annual-plan-discount-calculator` + +Research: [research.md](research.md) + +## Why people will choose this tool + +The searcher is a founder with a monthly plan who has to put a price on the annual option. They want to know what a discount means in plain terms, such as "2 months free is 16.7%", what it costs them, and whether the upfront cash makes up for it once some monthly customers would have left anyway. + +Where the current top results fail them: + +- Most convert in one direction only. SaaSTurf and Rankite take two prices, MathBehind and MoneyDeck take a discount, SendPay takes months free. None takes all three. +- Bad input goes through. A negative price, a 110% discount, or 13 months free gets capped or turned into zeros without a word on five of the six tools. +- "Break even" means two different things and nobody says so. SaaSSwitcher and Rankite mean "the annual price equals this many monthly payments". MathBehind and MoneyDeck mean "the discount where annual collects as much as monthly after churn". Rankite rounds 9.6 up to 10 without saying it. +- Churn-aware tools stop at year one. None has an annual renewal rate, so nobody can see what happens at month 24 or 36. +- Nobody separates upfront cash, expected billings, and MRR, or applies processor fees, even though one annual charge pays the fixed fee once instead of twelve times. +- SaaSTurf calls 15% to 20% "healthy" with no source. + +What this tool does better: + +1. Starts from the monthly price and takes the annual offer as an annual price, a discount, or months free. It always shows all three, plus what the customer saves. +2. Shows two thresholds under two names: the annual price in monthly payments (9.6, and the 10th payment that passes it), and the break-even discount after monthly churn. +3. Keeps upfront cash, MRR, and expected billings over 12, 24, and 36 months in separate rows. Monthly churn drives the monthly plan. A separate annual renewal rate drives the annual plan. +4. Optional processor fees, with dated, editable US presets for Stripe, Paddle, and Lemon Squeezy. Fees show as their own figures and never touch MRR. +5. Rejects bad input under the field, with a reason. It never clamps. +6. No "best discount", grade, or benchmark range. Without churn it still answers the conversion. With churn it shows the scenario and says which assumptions drive it. + +## Product + +### Promise and scope + +Primary user: an indie hacker or solo SaaS founder with a monthly plan price who is setting, or checking, an annual price. + +Job: turn an annual offer into its price, discount, and months free, then see what it collects next to the monthly plan under their own churn, renewal, and fee assumptions. + +Launch scope: + +- One currency, as a label only. Processor presets only for USD. +- Monthly price as the anchor. The annual offer comes from one of three inputs: annual price, discount percent, or months free. +- Optional monthly churn and optional annual renewal rate. +- Optional processor fee: a preset or a custom percent plus a fixed fee per successful charge. +- Results per customer who signs up: upfront charge, MRR, and expected billings by month 12, 24, and 36, before and after fees. + +Exclusions, each with its reason: + +- **Working out a monthly price from an annual price and a discount.** The research scope is a founder who already has a monthly plan. Deriving it would need a rounded monthly price that then changes every other figure. +- **Refunds.** Refund timing and size are seller policy, and Paddle's fee treatment on refunds is unknown. The article covers it. The base comparison stays before refunds. +- **Customer counts, signup mix, and conversion lift.** The tool can't know how many people pick annual or whether a discount sells more. Per-customer figures avoid a made-up forecast. +- **Taxes, failed payments, chargebacks, payout and currency costs, and international or PayPal surcharges.** +- **Profit, LTV, CAC, and recommendations.** Other SaaS tools cover LTV and CAC. +- **URL sharing, saved scenarios, and export.** + +`/mrr-calculator` normalizes many plans into portfolio MRR. This tool prices one annual offer and compares it with the same plan billed monthly. + +### Technical contract + +**Inputs.** All raw values are strings from the form, except `offerMode` and `feePreset`, which are ids. + +| Input | Key | Accepted | Notes | +| --- | --- | --- | --- | +| Currency | `currency` | `USD`, `EUR`, `GBP`, `INR`, `CAD`, `AUD`. Default `USD`. | Formatting only. From `SAAS_ECONOMICS_CURRENCIES`. | +| Monthly price | `monthlyPrice` | Money, `0.01` to `1,000,000,000.00`. Required. | The price of one month on the monthly plan. `0` is invalid. | +| Annual offer as | `offerMode` | `annualPrice`, `discount`, or `monthsFree`. Default `discount`. | Only the field for the chosen mode is read. The other two are ignored, not validated. | +| Annual price | `annualPrice` | Money, `0.01` to `1,000,000,000.00`. | Above 12 × monthly price is valid. It's a surcharge with a negative discount. | +| Discount | `discountPercent` | Percent, `0` to `99.99`, two decimals. | No negative values. Above list price is entered as an annual price. | +| Months free | `monthsFree` | Months, `0` to `11.99`, two decimals. | `2` means pay for 10 of 12 months. | +| Monthly churn | `monthlyChurnPercent` | Percent, `0` to `100`, two decimals. Optional. | Blank means "not entered", never 0. | +| Annual renewal rate | `annualRenewalPercent` | Percent, `0` to `100`, two decimals. Optional. | Share of annual customers who renew at each year end. Blank leaves months 24 and 36 for the annual plan unknown. | +| Processor fee | `feePreset` | `none`, `stripeCard`, `stripeCardBilling`, `paddle`, `lemonSqueezy`, or `custom`. Default `none`. | The four named presets are valid only with `USD`. | +| Fee percent | `feePercent` | Percent, `0` to `50`, two decimals. | Read only when `feePreset` isn't `none`. Required then. | +| Fixed fee per charge | `feeFixed` | Money, `0` to `100.00`. | Read only when `feePreset` isn't `none`. Required then. | + +**Parsing.** Use `lib/saas-economics/shared.ts`. `parseMoneyCents` for money, `parsePercentHundredths` for percents and months free. Both use non-negative bounds, so a minus sign is a format error. Same strict rules as the sibling engines: no commas, signs, symbols, exponents, or a third decimal. Leading and trailing spaces are trimmed. Blank is never zero. + +**Fee presets.** Engine-owned data. Each preset has an id, a label, a percent in hundredths, a fixed fee in cents, a short scope note, a source URL, and `checked: "2026-10-10"`. All are published US rates in USD and exclude everything in the exclusions list. + +| Id | Label | Percent | Fixed | Scope | Source | +| --- | --- | --- | --- | --- | --- | +| `stripeCard` | Stripe, card | `2.90` | `0.30` | Standard US online card rate. Stripe Billing not included. | https://stripe.com/pricing | +| `stripeCardBilling` | Stripe, card and Billing | `3.60` | `0.30` | Card rate plus Billing pay-as-you-go 0.7% of volume. | https://stripe.com/pricing | +| `paddle` | Paddle | `5.00` | `0.50` | Pay-as-you-go Checkout rate. Custom pricing may differ. | https://www.paddle.com/pricing | +| `lemonSqueezy` | Lemon Squeezy, subscription | `5.50` | `0.50` | 5% + $0.50, plus 0.5% for subscription payments. No international or PayPal surcharge. | https://docs.lemonsqueezy.com/help/getting-started/fees | + +`custom` has no values. The frontend fills the two fee fields from a preset when one is chosen. The person may edit them. The engine reports `edited: true` when the parsed values differ from the chosen preset. + +**The model.** Integer cents for money, integer hundredths for percents and months. Every step uses exact fractions (`ExactFraction` in `lib/saas-economics/unit-economics.ts`). Rounding happens only for output. `P` = monthly price, `A` = annual price, `c` = monthly churn, `q` = annual renewal rate, `p` = fee percent, `f` = fixed fee. + +1. **Resolve the annual price** to whole cents. Every later figure uses this `A`. + - `annualPrice`: `A` as entered. + - `discount`: `A = round(12 × P × (10000 − d) ÷ 10000)`, half away from zero. + - `monthsFree`: `A = round(P × (1200 − m) ÷ 100)`, half away from zero. + - `annualPriceRounded` is true when the exact derived amount wasn't whole cents. + - A derived `A` of `0` is an error on the mode's field (see messages). +2. **Offer.** `L = 12P`, the list price for a year of monthly payments. + - Customer saves: `L − A` cents. Signed. Negative is a surcharge. + - Discount: `(L − A) ÷ L`. Signed. + - Months free: `(L − A) ÷ P`. Signed. + - MRR on the annual plan: `A ÷ 12`. MRR on the monthly plan: `P`. +3. **Invoice-equivalent threshold.** `A ÷ P` monthly payments. Also `ceil(A ÷ P)`, the first whole monthly payment whose running total reaches `A`. This is the price comparison only. It has no churn. +4. **Expected monthly-plan billings by month `n`.** The first charge is at signup. Before each later monthly charge, a share `c` of remaining customers cancels. + `M(n) = P × Σ (1 − c)^k for k = 0 … n − 1`. This equals `cumulativeGrossProfit({ arpaCents: P, grossMarginHundredths: 10000n, churnHundredths: c }, n)` from `unit-economics.ts`. Reuse it. At `c = 0` it's `nP`. At `c = 100%` it's `P`. + Expected monthly charges by month `n`: `M(n) ÷ P`. +5. **Expected annual-plan billings by month `n`**, for `n` = 12, 24, 36. One charge at signup, then at the start of months 13 and 25 for customers who renew. + `Y(n) = A × Σ q^j for j = 0 … n/12 − 1`, with `q^0 = 1`. `Y(12) = A` needs no renewal rate. `Y(24)` and `Y(36)` are `null` when the renewal rate is blank. + Expected annual charges: `Y(n) ÷ A`. +6. **Break-even discount by month `n`.** The discount at which `Y(n)` equals `M(n)`: + `d_BE(n) = 1 − M(n) ÷ (12P × Σ q^j)`. It doesn't depend on `P`. Month 12 needs only churn. Months 24 and 36 also need renewal. A negative value means annual would have to cost more than 12 monthly payments to match. Report it, and don't clamp it. + `annualVsMonthly(n)` compares exact `Y(n)` with exact `M(n)`: `more`, `same`, or `less`. +7. **Fees.** Each successful charge of amount `x` costs `x × p ÷ 10000 + f`. + Expected fees by month `n`: `(P × p ÷ 10000 + f) × expected monthly charges`, and `(A × p ÷ 10000 + f) × expected annual charges`. + Upfront fee: one charge of `P` for monthly, one of `A` for annual. + After fees, as displayed: `net = rounded gross − rounded fees`, so every table row adds up. Net can be negative when a fixed fee is larger than a small price. Report it signed. + Fees never change MRR, the thresholds, or `d_BE`. +8. **Running totals for the chart**, months 1 to 36, gross only: `M(n)`, and annual `Y` for the current term. That's `A` for months 1–12, `A(1+q)` for 13–24, and `A(1+q+q²)` for 25–36. Annual is `null` from month 13 when renewal is blank. Only present when churn is entered. + +**Rounding for output.** + +- Money: cents, half away from zero. A difference is the rounded annual figure minus the rounded monthly figure, so the table adds up. Same rule as the LTV tool's scenario delta. +- Discount and break-even discount: tenths of a percent, half away from zero, signed. `nearZero: true` when the exact value isn't 0 but rounds to `0.0`. The interface shows `Under 0.1%`, or `Under −0.1%` for a tiny surcharge. +- Months free and invoice-equivalent months: tenths, half away from zero, signed. `exact: true` when the exact value is a whole number, so the interface can say "Exactly 10". +- Expected charges: hundredths, half away from zero. At 3% churn, 12 months gives `10.21`. The research says "about 10.20". The exact value is 10.2055…, so 10.21 is the rounded figure. + +**Validation.** The engine collects every error in one pass and returns `status: "invalid"` with no values. It never throws for user input. It never clamps or swaps in a value. + +| Case | Key | Message | +| --- | --- | --- | +| Monthly price blank | `monthlyPrice` | `Enter the monthly price.` | +| Monthly price `0` | `monthlyPrice` | `Enter a monthly price above 0.` | +| Any money format error (sign, comma, symbol, 3 decimals, letters) | field | `Use digits with up to two decimals. No signs, commas, or currency symbols.` | +| Money above max | field | `Enter an amount up to 1,000,000,000.00.` For `feeFixed`: `Enter a fixed fee up to 100.00.` | +| Annual price blank, mode `annualPrice` | `annualPrice` | `Enter the annual price.` | +| Annual price `0` | `annualPrice` | `Enter an annual price above 0.` | +| Discount blank, mode `discount` | `discountPercent` | `Enter the annual discount.` | +| Discount format or range (negative, `100`, `110`, `20%`) | `discountPercent` | `Enter a discount from 0 to 99.99. For an annual price above 12 monthly payments, enter the annual price instead.` | +| Months free blank, mode `monthsFree` | `monthsFree` | `Enter the number of months free.` | +| Months free format or range (negative, `12`, `13`) | `monthsFree` | `Enter months free from 0 to 11.99.` | +| Derived annual price rounds to `0.00` | mode's field | `This makes the annual price 0.00. Use a smaller discount.` For months free: `…Use fewer months free.` | +| Churn format or range | `monthlyChurnPercent` | `Enter a percent from 0 to 100, with up to two decimals, or leave it blank.` | +| Renewal format or range | `annualRenewalPercent` | `Enter a percent from 0 to 100, with up to two decimals, or leave it blank.` | +| Fee percent blank, format, or range with a fee on | `feePercent` | `Enter a fee percent from 0 to 50, with up to two decimals.` | +| Fixed fee blank with a fee on | `feeFixed` | `Enter a fixed fee per charge. Use 0 if there is none.` | +| Named preset with non-USD currency | `feePreset` | `These presets are US rates in USD. Choose Custom fee for other currencies.` | +| Unknown currency, mode, or preset | that key | `Choose one of the listed options.` | +| Not an object, or a non-string value | `form` | `Something in the form is out of date. Reset and try again.` | + +The writing session may reword messages in the engine. Keys and cases stay. + +**Action.** `Compare plans` is the only primary action. Its behavior is under Interface. + +**Privacy and security.** Runs in the browser. Values never go to a server, the URL, cookies, storage, logs, or analytics. No API route, dataset, or background job. The clean URL never changes. + +### Backend contract + +Same runtime rules as the sibling SaaS engines: strip-types safe, no enums or namespaces, explicit `.ts` imports, no DOM, network, time, or randomness. Reuse `lib/saas-economics/shared.ts` and `lib/saas-economics/unit-economics.ts` as they are. Neither needs a change. + +#### `lib/annual-plan-discount-calculator/engine.ts` + +```ts +import { type SaasEconomicsCurrency } from "../saas-economics/shared.ts"; +import { type ExactFraction } from "../saas-economics/unit-economics.ts"; + +export const ANNUAL_PLAN_OFFER_MODES = ["annualPrice", "discount", "monthsFree"] as const; +export type AnnualPlanOfferMode = (typeof ANNUAL_PLAN_OFFER_MODES)[number]; + +export const ANNUAL_PLAN_HORIZONS = [12, 24, 36] as const; +export type AnnualPlanHorizon = (typeof ANNUAL_PLAN_HORIZONS)[number]; + +export const ANNUAL_PLAN_FEE_PRESET_IDS = ["none", "stripeCard", "stripeCardBilling", "paddle", "lemonSqueezy", "custom"] as const; +export type AnnualPlanFeePresetId = (typeof ANNUAL_PLAN_FEE_PRESET_IDS)[number]; + +export type AnnualPlanFeePreset = { + id: Exclude; + label: string; // "Stripe, card" + percentHundredths: bigint; // 290n + fixedCents: bigint; // 30n + currency: "USD"; + scope: string; // one short sentence: what is and isn't included + sourceUrl: string; + checked: "2026-10-10"; +}; +/** The four named presets, in menu order. */ +export const ANNUAL_PLAN_FEE_PRESETS: readonly AnnualPlanFeePreset[]; + +export type AnnualPlanInput = { + currency: string; + monthlyPrice: string; + offerMode: string; + annualPrice: string; + discountPercent: string; + monthsFree: string; + monthlyChurnPercent: string; // "" = not entered + annualRenewalPercent: string; // "" = not entered + feePreset: string; + feePercent: string; + feeFixed: string; +}; + +export type AnnualPlanFieldKey = "form" | keyof AnnualPlanInput; +export type AnnualPlanErrors = Partial>; + +export type AnnualPlanMoney = { cents: bigint; exact: ExactFraction }; // cents half away from zero; signed where noted +export type AnnualPlanPercent = { tenths: bigint; nearZero: boolean; exact: ExactFraction }; // tenths of a percent, signed +export type AnnualPlanMonths = { tenths: bigint; exact: boolean }; // tenths of a month, signed; exact = whole number + +export type AnnualPlanOffer = { + monthlyPriceCents: bigint; + annualPriceCents: bigint; // >= 1n, resolved and used everywhere below + annualPriceRounded: boolean; // derived from discount or months free and not whole cents + listAnnualCents: bigint; // 12 * monthly + customerSavingsCents: bigint; // list - annual; negative = surcharge + discount: AnnualPlanPercent; // negative = surcharge + monthsFree: AnnualPlanMonths; // negative = surcharge + mrr: { monthlyPlanCents: bigint; annualPlan: AnnualPlanMoney; differenceCents: bigint }; // annual / 12, rounded. differenceCents = rounded annual MRR − monthly MRR (example 1: -333n). Added by coordinator after the backend check. + invoiceEquivalent: { + months: AnnualPlanMonths; // annual / monthly + wholePayments: bigint; // ceil(annual / monthly), >= 1n + }; +}; + +export type AnnualPlanFeeAmounts = { + feesCents: bigint; // >= 0n + netCents: bigint; // rounded gross - rounded fees; signed +}; + +export type AnnualPlanUpfront = { + monthly: { grossCents: bigint; fee: AnnualPlanFeeAmounts | null }; + annual: { grossCents: bigint; fee: AnnualPlanFeeAmounts | null }; + differenceCents: bigint; // annual - monthly gross +}; + +export type AnnualPlanHorizonResult = { + months: AnnualPlanHorizon; + monthly: { + grossCents: bigint; + expectedChargesHundredths: bigint; // 1021n = 10.21 + fee: AnnualPlanFeeAmounts | null; // null when fee preset is none + }; + /** null for 24 and 36 when the renewal rate is blank. Never null for 12. */ + annual: { + grossCents: bigint; + expectedChargesHundredths: bigint; + fee: AnnualPlanFeeAmounts | null; + } | null; + differenceCents: bigint | null; // annual.grossCents - monthly.grossCents + netDifferenceCents: bigint | null; // annual net - monthly net, when both exist + annualVsMonthly: "more" | "same" | "less" | null; // from exact gross values + breakEvenDiscount: AnnualPlanPercent | null; // null when annual is null +}; + +export type AnnualPlanComparison = { + monthlyChurnHundredths: bigint; + annualRenewalHundredths: bigint | null; + horizons: AnnualPlanHorizonResult[]; // always three, in ANNUAL_PLAN_HORIZONS order + /** Months 1 to 36, gross. annualCents is null from month 13 when renewal is blank. */ + runningTotals: { month: number; monthlyCents: bigint; annualCents: bigint | null }[]; +}; + +export type AnnualPlanFee = { + preset: Exclude; + percentHundredths: bigint; + fixedCents: bigint; + edited: boolean; // named preset whose values were changed +}; + +export type AnnualPlanValues = { + currency: SaasEconomicsCurrency; + offerMode: AnnualPlanOfferMode; + offer: AnnualPlanOffer; + upfront: AnnualPlanUpfront; + fee: AnnualPlanFee | null; + /** null when monthly churn is blank. The offer, upfront, and MRR still apply. */ + comparison: AnnualPlanComparison | null; +}; + +export type AnnualPlanCalculation = + | { status: "valid"; values: AnnualPlanValues; errors: Record } + | { status: "invalid"; values: null; errors: AnnualPlanErrors }; + +export function calculateAnnualPlanDiscount(input: unknown): AnnualPlanCalculation; +``` + +Notes for the builder: + +- When churn is blank and renewal is filled, the result is valid and `comparison` is `null`. Renewal is parsed and validated but has no output. +- Exact values (`exact` fields) stay in the output so the frontend can compare and show `Under 0.1%` without its own math. The frontend does no arithmetic beyond turning cents into display numbers for the chart. +- Throwing is only for programmer errors. + +### Known examples + +Calculated by hand with exact fractions. They don't come from competitor output, but several match it. USD, no fee unless stated. "Diff" is annual minus monthly. + +| # | Inputs | Expected | +| --- | --- | --- | +| 1 | Monthly `20`, months free `2`, churn `3`, renewal `70`, fee `stripeCard` | Annual `$200.00`, not rounded. Discount `16.7%`. Months free `2.0`, exact. Saves `$40.00`. MRR `$20.00` vs `$16.67`. Invoice-equivalent `10.0` exact, whole payments `10`. Upfront `$20.00` vs `$200.00`, diff `$180.00`. Upfront fees `$0.88` vs `$6.10`. Month 12: monthly `$204.11` (charges `10.21`), annual `$200.00` (charges `1.00`), diff `−$4.11`, `less`, break-even `15.0%`. Month 24: `$345.72` vs `$340.00`, diff `−$5.72`, break-even `15.3%`. Month 36: `$443.98` vs `$438.00`, diff `−$5.98`, break-even `15.5%`. Fees: month 12 `$8.98` vs `$6.10`, net `$195.13` vs `$193.90`, net diff `−$1.23`. Month 24 fees `$15.21` vs `$10.37`, net `$330.51` vs `$329.63`. Month 36 fees `$19.54` vs `$13.36`, net `$424.44` vs `$424.64`, net diff `+$0.20`. | +| 2 | Monthly `20`, annual price `192`, churn `3` | Discount `20.0%`. Months free `2.4`, not exact. Saves `$48.00`. MRR `$16.00`. Invoice-equivalent `9.6`, whole payments `10`. Month 12 `$204.11` vs `$192.00`, diff `−$12.11`, break-even `15.0%`. Months 24 and 36: monthly `$345.72` and `$443.98`, annual `null`, break-even `null`. Matches MathBehind's 15.0% and 9.6, and Rankite's 10. | +| 3 | Monthly `50`, discount `20`, churn `5`, renewal `80` | Annual `$480.00`. Month 12 monthly `$459.64` (charges `9.19`), diff `+$20.36`, `more`, break-even `23.4%`. Month 24 `$708.01` vs `$864.00`, break-even `34.4%`. Month 36 `$842.22` vs `$1,171.20`, break-even `42.5%`. Month 12 matches MoneyDeck's $459.64 and 9.19. | +| 4 | Monthly `9.99`, discount `15` | Exact `101.898`, so annual `$101.90` with `annualPriceRounded: true`. Discount `15.0%`, from the rounded price, exact 14.998…%. Months free `1.8`. Saves `$17.98`. MRR `$8.49`. Invoice-equivalent `10.2`, whole payments `11`. `comparison: null`. | +| 5 | Monthly `29`, discount `20`, churn `2.5`, renewal `75`, fee `paddle` | Annual `$278.40`. Month 12 `$303.92` (charges `10.48`) vs `$278.40`, break-even `12.7%`. Month 36 `$693.74` vs `$643.80`, break-even `13.8%`. Month 12 fees `$20.44` vs `$14.42`. | +| 6 | Monthly `20`, months free `2`, churn `0`, renewal `100` | Month 12 `$240.00` vs `$200.00`, diff `−$40.00`, break-even `0.0%`, not `nearZero`. Month 36 `$720.00` vs `$600.00`. | +| 7 | Monthly `20`, months free `2`, churn `100`, renewal `0` | Monthly is `$20.00` at every horizon, charges `1.00`. Annual is `$200.00` at every horizon. Break-even `91.7%` at all three. | +| 8 | Monthly `20`, annual price `300` | Discount `−25.0%`. Months free `−3.0`. Saves `−$60.00`. Invoice-equivalent `15.0`. Valid, not an error. | +| 9 | Monthly `20`, months free `2`, churn `0`, fees `paddle` / `lemonSqueezy` / `stripeCardBilling` | Month 12 fees: Paddle `$18.00` vs `$10.50`. Lemon Squeezy `$19.20` vs `$11.50`. Stripe with Billing `$12.24` vs `$7.50`. With `stripeCard`, `$10.56` vs `$6.10`. These match the research. | +| 10 | Example 1 with `custom`, `2.9`, `0.30` | Same fee figures as example 1. `preset: "custom"`, `edited: false`. `stripeCard` with fixed `0.25` gives `edited: true`. | +| 11 | Monthly `20`, discount `0`, churn `0` | Discount `0.0%`, saves `$0.00`, months free `0.0`. Month 12 diff `$0.00`, `same`. | +| 12 | Monthly `0.01`, months free `11.99` | Invalid. `monthsFree`: the "0.00" message. | +| 13 | Monthly `1000000000`, discount `0` | Annual `$12,000,000,000.00`. Derived prices may exceed the entry maximum. | +| 14 | Invalid: monthly blank, `0`, `-20`, `$20`, `1,000`, `20.001`, `1e3`. Discount `100`, `-5`, `20%`. Months free `12`. Annual price `0`. Churn `100.01` or `-1`. Renewal `101`. `feePercent` blank with `paddle`. `paddle` with `EUR`. `offerMode: "weekly"`. Input `null`. | A field-specific error each, from the table. `status: "invalid"`, no values. With mode `discount`, a junk `annualPrice` draft is ignored. Churn and renewal blank are valid. | + +Hand check for example 1, month 12: `Σ 0.97^k` for k = 0…11 is `(1 − 0.97^12) ÷ 0.03 = 10.2055…`. × $20 = $204.11. Break-even is `1 − 204.11 ÷ 240 = 14.95% → 15.0%`. Month 24 annual is `200 × 1.7 = 340`. + +### Ownership + +Sol builds the backend: + +- `lib/annual-plan-discount-calculator/engine.ts`. +- `tests/pages/annual-plan-discount-calculator/annual-plan-discount-calculator.test.mjs`, covering the examples above. +- The development tool-page row with a placeholder title, when the coordinator asks. + +Opus builds the frontend: + +- `app/(site)/(tools)/(saas)/annual-plan-discount-calculator/page.tsx`, `layout.tsx`, `annual-plan-discount-calculator.tsx`, and `annual-plan-discount-calculator.module.css`. Same shell as `break-even-mrr-calculator`. Header icon `Tag` from `lucide-react`. No SaaS sibling uses it, and it matches the card art. +- `app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator.spec.json`, `.svg`, and `.ts`, made with marker-art-draw. A new `toolSpot` motif in `scripts/stack127/marker-art/figures.mjs`, and a registration line in `lib/card-art.ts`. Both are shared files that other SaaS planners may also touch. Check `git status` before editing, and wait if someone else has them open. + +No URL-restore wrapper. + +### Catalog placement + +Primary and only catalog: `/saas-tools`. The reader is pricing a plan, which is the job of the SaaS tools catalog. Place it next to the MRR and ARR calculators, because annual MRR is the next question. The coordinator sets the final order for the batch. No secondary catalog fits. `/platform-fees` is a reference, not a tool catalog. + +Catalog `toolSpot` key: `annual-plan-discount-calculator`. Suggested motif: `annualDiscount`. + +- **Takeaway:** an annual plan is twelve months of payments with a few given away. +- **Focal subject:** a large price tag on its side. The hole and string are on the left. Inside the tag is one row of twelve small equal tiles. The first ten are solid `@sub`. The last two are dashed outlines in `@primary`, empty. +- **One supporting relationship:** a short curved arrow lifts the two empty tiles slightly up and out of the tag's right edge, so they read as taken off the price. +- **Tone:** calm, with the accent only on the giveaway. No numbers, currency signs, percent signs, scissors, calendars, or binder rings. +- **Keep it distinct from** the ARR card's `annualizeMonthly` (a calendar with a 3×4 grid), the MRR card's `monthlyNormalize` (lines into a calendar), and `usagePrice` (a document and a coin). The tag outline and the single row of tiles do that. +- Wordless, and legible at card size in both themes. If twelve tiles turn to mush at card size, use six tiles with one empty. The ratio stays the same. + +## Interface + +### Flow + +- **Question:** "If I offer this annual price, what discount is it, and what does it collect next to my monthly plan?" +- **Minimum input:** monthly price and one annual offer value. Churn, renewal, and fees refine the answer. +- **Primary action:** `Compare plans`. +- **Lead answer:** the value the person didn't type. That's the annual price when they gave a discount or months free, or the discount when they gave an annual price. +- **Control mode:** `disclosure`. Processor fees sit behind a disclosure, because they're an optional refinement that does nothing when off. Churn and renewal stay visible, since they drive the comparison. + +Workbench order: + +1. `Currency`, a narrow select, and `Monthly price` *, on one row. +2. `Annual offer as`: three visible choices, `Discount`, `Months free`, and `Annual price`. One field below it changes with the choice: `Annual discount` (`%`), `Months free` (`months`), or `Annual price` (currency code). Switching keeps each field's draft. +3. `Monthly churn` (`%`) and `Annual renewal rate` (`%`), on one row. Both optional, with "Optional" in the label hint. One helper line each, because people mix them up: + - Churn: `Share of monthly customers who cancel each month. Leave blank to skip the comparison.` + - Renewal: `Share of annual customers who renew each year. Not the same as monthly churn.` +4. Disclosure `Processor fees`. The summary reads `None`, or `Stripe, card · 2.9% + $0.30`, or with `(edited)` added. Inside: + - `Fee preset` select: `None`, the four presets, `Custom fee`. For a non-USD currency, only `None` and `Custom fee`. + - `Fee percent` (`%`) and `Fixed fee per charge` (currency code), on one row, shown when the preset isn't `None`. A preset fills both, and the person can edit them. + - One line under the fields: the preset's scope sentence, a source link, and `Published US rate, checked 10 Oct 2026.` For a custom fee: `Percent of each charge, plus a fixed amount per successful charge.` + - Changing the currency away from USD while a named preset is selected switches it to `Custom fee` and keeps both values. That isn't silent: the summary changes and the status announces `Fee preset changed to Custom fee.` +5. `Compare plans` and a quiet `Reset`. +6. One divider, then the results. + +### Result regions + +Each region answers a different question. No formatted value repeats in two regions. + +1. **Lead.** `UiPrimaryResult`, tone `neutral`. + - Label `Annual price` (modes `discount`, `monthsFree`), with value `$200.00`. Or label `Annual discount` (mode `annualPrice`), with value `20.0%`. + - Detail line: the other two offer values and the savings. Example: `16.7% off 12 payments of $20.00 · 2.0 months free · Customer saves $40.00 a year`. Leave out the field the person typed. + - Surcharge: `25.0% more than 12 payments of $20.00 · Customer pays $60.00 more a year`. No months free on a surcharge. + - When `annualPriceRounded`: add `Rounded to the cent from $101.898.` Show the exact amount to three decimals at most, or `Rounded to the cent.` if it has more. +2. **Two thresholds.** `UiKeyValueList`, two rows. Each value has a short second line. + - `Equal to monthly payments`: `9.6`. Second line: `The 10th monthly payment passes $192.00.` When exact: `Exactly 10 monthly payments.` + - `Break-even discount at 3% churn`: `15.0%`. Second line: `Your 20.0% is above it. In year one, annual collects $12.11 less than monthly is expected to.` Or `below it … more`, or `equal to it … the same`. With renewal, add a third line: `Over 24 months: 15.3%. Over 36 months: 15.5%, at 70% renewal.` A negative break-even reads `None. Annual would need to cost 5.2% more than 12 payments to match.` + - Without churn, the value is `Add monthly churn`, and the second line is `Needed to compare expected billings.` + - Never call either value "best" or "recommended". +3. **Per-customer comparison.** `UiDataTable`, caption `Per customer who signs up`. Columns: the row label, `Monthly plan`, `Annual plan`, `Difference` (annual minus monthly, with a sign). All right-aligned except the row label. + - `Charged at signup`: $20.00, $200.00, +$180.00. Always shown. + - `MRR`: $20.00, $16.67, −$3.33. Always shown. The row header has the visually quiet note `Annual price ÷ 12, not cash`. + - `Expected billings by month 12`, `by month 24`, `by month 36`. Only with churn. An unknown annual cell reads `Needs renewal rate`, and its difference reads `—`. + - With a fee: `After fees by month 12`, `24`, `36`, with the net figures. Each cell has a second small line, `$8.98 fees`. `Charged at signup` gets the same second line. + - Signs use `−` and `+`, not color alone. A positive difference means annual collects more. +4. **Running total chart.** `UiLiveChart`, kind `line`, format `currency`, `xFormat: "number"`. Title `Expected billings per customer, running total`. Two series, `Monthly plan` and `Annual plan`. X runs over months 1–36, or 1–12 when renewal is blank. No `range`. Hover and series toggles help compare points. Caption: `Annual jumps when customers renew at months 13 and 25.` Shown only with churn. +5. **Assumptions.** One small line under the chart, or under the table when there's no chart. It's built from the inputs: `Monthly customers pay at signup, then 3% cancel before each later payment. Annual customers pay at signup, and 70% renew each year. Before refunds, taxes, and failed payments. Not profit.` When fees are on, add the fee sentence. + +The chart is the only visual. It shows a real relationship: when the cumulative monthly line catches up with the annual line, and how each renewal moves it. A line chart draws the annual jump from month 12 to 13 as a one-month slope. That's acceptable at 36 points. A stepped line would be more exact. It's listed under Components as an optional improvement, not a blocker. + +### Components + +Existing shared components: + +| Use | Component | +| --- | --- | +| Currency, fee preset | `UiSelect` from `@/app/ui/controls` | +| Annual offer as | `UiChoiceGroup` from `@/app/ui/controls` | +| Prices, percents, months, fees | `UiTextField` from `@/app/ui/controls`, `inputMode="decimal"`, with a `suffix` of the currency code, `%`, or `months`. Autocomplete, capitalization, and spellcheck off. | +| Processor fees | `UiDisclosure` from `@/app/ui/controls`, with `summary` | +| Actions | `UiActionRow` from `@/app/ui/display`, `UiButton` from `@/app/ui/controls` | +| Lead | `UiPrimaryResult` from `@/app/ui/display` | +| Thresholds | `UiKeyValueList` from `@/app/ui/display` | +| Comparison | `UiDataTable` from `@/app/ui/display` | +| Running total | `UiLiveChart` from `@/app/ui/charts/chart-client` | +| Changes, reset, failure | `UiStatus` from `@/app/ui/display` | +| Page | `ToolPageShell`, `ToolPageHeader`, `ToolPageArticle`, `PageCanvas`, as in `break-even-mrr-calculator/page.tsx` | + +New or improved shared components built in planning: none. Every control and result fits an existing component. Shared files touched: none. + +Optional shared improvement, not built: a per-series `step` option on line charts in `app/ui/charts/chart-option.ts`, using ECharts `step: "start"`. It would draw annual billings as a true step. It's left out because a one-month slope across 36 points reads correctly, and the chart files belong to every page. Report it to the coordinator if the audit flags the slope. + +Tool-specific component, in the route file: + +- `AnnualPlanDiscountCalculator`. It owns the raw form state, all three offer drafts, preset filling and the edited summary, the error mapping by `AnnualPlanFieldKey`, focus, status, and rendering the results. It calls only `calculateAnnualPlanDiscount` and reads `ANNUAL_PLAN_FEE_PRESETS`. It turns cents into numbers only for the chart spec. + +### States + +- **Initial.** Every value field is blank. `USD`, `Discount`, and fee `None` are selected. The disclosure is closed. No result. The clean URL doesn't change. +- **Invalid or incomplete.** Clear any old result and keep the inputs. Show one error under each invalid field, focus the first invalid field in reading order, and add no error panel. If the first error is inside the closed fee disclosure, open it before focusing. A `feePreset` currency error focuses the preset select. +- **Valid without churn.** The lead, the thresholds (break-even reads `Add monthly churn`), the table with `Charged at signup` and `MRR`, and the assumptions line without the churn sentence. No chart. This is a full answer to the conversion question, not an error. +- **Valid with churn and no renewal.** Month 24 and 36 annual cells read `Needs renewal rate`. The chart runs from month 1 to 12. The break-even line has no third line. +- **Valid with everything.** All five regions. Focus the lead and let it announce once. +- **Surcharge, zero discount, zero churn, 100% churn, negative net.** Valid results in the same layout, with honest signs. +- **Edited after a result.** Clear the result and announce once, politely: `Inputs changed. Compare again.` Focus stays where the person is typing. Opening or closing the disclosure isn't an edit. +- **Mode switch.** The visible field changes, the drafts stay, and the result clears. Focus stays on the choice group. +- **Preset chosen.** Fill both fee fields and clear the result. Editing a filled value adds `(edited)` to the summary. +- **Reset.** Restore the initial state with every draft cleared and the disclosure closed. Focus `Monthly price` and announce `Form reset.` +- **Unexpected failure.** If the engine throws, keep the inputs, clear the result, show one `UiStatus` error by the actions (`Couldn't calculate. Try again.`), and leave the button enabled. +- **Restored.** If the back-forward cache restores a result, the next edit clears it. +- **Long values.** `$12,000,000,000.00`, `−$12,345,678.90`, and `Needs renewal rate` must fit in table cells without clipping. The table may scroll inside its own frame. The lead value wraps rather than overflowing. +- **Loading, empty, partial request, and retry.** These don't apply. The calculation is local and synchronous. + +### Responsive and accessible behavior + +Desktop: + +- Shared workbench band with a measured inner width, as on the sibling SaaS tools. +- Currency and monthly price share one row. The offer choice group and its field sit on their own row. Churn and renewal share a row. +- Results stack full width: lead, thresholds, table, chart, assumptions. The table needs four columns at real widths, so nothing sits beside it. + +Mobile: + +- One column, in the order above. The fee fields stack. +- `UiDataTable` turns into labeled rows. Each fee second line stays under its value. +- The chart fills the width at its default height. Its legend toggles stay reachable. +- Buttons go full width. No page-level horizontal overflow. Grid children get `min-width: 0`. + +Keyboard and screen readers: + +- Tab order follows the visual order. The disclosure button sits between renewal and the actions. +- Enter in any field submits. +- Every field has a visible label. Required fields have `*`, and optional fields say so in the label hint. +- Errors are tied to their fields with `aria-describedby`. +- One polite live region announces edits, mode changes, preset changes caused by currency, resets, and failures. Results are announced only through the focused lead. +- The chart carries its title. With 72 values it gets the wrapper's description rather than a hidden table. The comparison table already gives the exact checkpoint numbers. + +Check at desktop and mobile, in both themes: + +- Example 1 with the Stripe preset: all regions, fee lines, and the month 36 net turning positive. +- Example 2, renewal blank: `Needs renewal rate` and the 12-month chart. +- Example 4: the rounding note. +- Example 8: the surcharge wording. +- No churn: the conversion-only result. +- Invalid `20%` in the discount field, and `110` months free. +- A named preset, then switch currency to EUR. +- Example 13: long values in the table. + +## Content + +Plan for the writing pass. The page copy lives in the route's `content/intro.mdx`, `usage.mdx`, and `article.mdx`. Don't copy this plan into the page. + +### Search intent and title + +The searcher is a founder with a monthly plan who has to set the annual price. They want to turn an offer like "2 months free" into a percent and a price, and to know whether the upfront cash makes up for the discount once some monthly customers would have left anyway. The top results each answer one direction, mix up two meanings of "break even", stop at year one, ignore fees, or quote an unsourced "healthy" range. This page converts all three ways, names both thresholds, runs to month 36 with a separate renewal rate, and shows fees per charge. + +- Visible title: `Annual plan discount calculator: monthly vs annual pricing for SaaS` +- SEO title: `Annual plan discount calculator: monthly vs annual pricing` +- SEO description: `Convert an annual price, discount, or months free. Compare what monthly and annual plans collect per customer after churn, renewals, and processor fees.` +- Card name: `Annual plan discount` +- Card description: `Price an annual plan and compare what it collects against monthly billing.` + +### Reader questions and where each is answered + +| Question | Where | +| --- | --- | +| What discount is 2 months free? What price is 20% off? | Tool lead. Article: formula and a table of common offers at $20. | +| Is 2 months free the same as 20% off? | Article, own section. 16.7% vs 20%, $200 vs $192. | +| What does the customer save? | Tool lead. | +| When has a monthly customer paid as much as the annual price? | Tool threshold `Annual price in monthly payments`. Article: two thresholds table. | +| Does annual collect more or less than monthly after churn? | Tool threshold `Break-even discount at N% churn`, the table, and the chart. Usage: worked example. Article: how churn moves it. | +| What about year two and three? | Tool table and chart with a renewal rate. Article: renewal section with a 50% case. | +| Is annual MRR the upfront payment? | Tool MRR row note. Article: MRR section with Stripe and Paddle links. | +| Do fees make annual cheaper to collect? | Tool fee rows. Article: fee table at four presets, with dated sources. | +| What about refunds? | Article, short section with processor refund links. | +| What annual discount should I offer? | Article: the tool doesn't pick one, and why. | + +### Outline + +**Intro.** What you get, and what to have ready. Three sentences. + +**Usage.** `What to enter`: where each number comes from, that "2 months free" goes in `Months free`, customer churn not revenue churn, renewal as a separate guess for new products, presets as editable published US rates. `How to read the result`: the lead, the two thresholds, the table, the chart. One `Example`: $20, 2 months free, 3% churn, 70% renewal. + +**Article.** + +1. `How do you calculate an annual plan discount?` Formula box, then a table of six common offers at $20. +2. `Is 2 months free the same as 20% off?` No. Two sentences and the numbers. +3. `What are the two break-even numbers?` A table naming both thresholds, the question each answers, and the $192 example (9.6 and the 10th payment, 15.0% at 3% churn). +4. `How does monthly churn change the comparison?` Expected payments formula, 10.21 payments in year one at 3%. The 6% case flips the result. +5. `Why is the annual renewal rate separate from monthly churn?` Annual customers leave only at year end. The 50% renewal case: month 36 break-even turns to `None`. One `Warning` against plugging in 100 minus monthly churn. +6. `Is annual plan MRR the same as the upfront payment?` No. $16.67 MRR, $200 cash. Stripe and Paddle links. +7. `Do processor fees make annual billing cheaper?` A table of year-one fees at four presets. Fee savings are $4.46 to $7.70 next to a $40 discount. The Stripe example turns month 36 from $5.98 behind to $0.20 ahead. A `Note` with dated sources. +8. `What happens when you refund an annual plan?` Before refunds. One line each on Stripe, Paddle, Lemon Squeezy. +9. `What annual discount should you offer?` No pick, no benchmark, no conversion claim. Try several churn and renewal values. +10. `What this calculator doesn't cover`, then next steps. + +Formats: one formula box, one example, three tables, one warning, one note. No article chart; the tool already draws one. No marker art in the article. + +### Left out on purpose + +- Input format rules, blank vs zero, and the `0.00` annual price case: field hints and engine messages. +- Surcharges above 12 payments: the lead says it. One clause in usage. +- Rounding of derived prices: the lead's rounding note. +- Customer counts and signup mix: out of scope in the product contract. + +### Facts and sources + +- Conversion formulas and every example figure come from `calculateAnnualPlanDiscount`, run 2026-10-10. +- Annual MRR is the annual price ÷ 12, not cash: [Stripe MRR and ARR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29), [Paddle MRR](https://www.paddle.com/help/profitwell-metrics/measure/subscription-metrics/mrr). +- Stripe US card 2.9% + $0.30 and Billing 0.7%: [Stripe pricing](https://stripe.com/pricing), checked 2026-10-10. +- Paddle 5% + $0.50: [Paddle pricing](https://www.paddle.com/pricing), checked 2026-10-10. +- Lemon Squeezy 5% + $0.50, plus 0.5% on subscriptions, 1.5% international, 1.5% PayPal: [Lemon Squeezy fees](https://docs.lemonsqueezy.com/help/getting-started/fees), checked 2026-10-10. +- Refunds: [Stripe refunded payment fees](https://support.stripe.com/questions/understanding-fees-for-refunded-payments), [Paddle refunds](https://www.paddle.com/help/manage/your-customers/how-do-i-issue-refunds), [Lemon Squeezy refunds](https://docs.lemonsqueezy.com/help/payments/refunds-chargebacks), checked 2026-10-10. + +Claims to avoid: a best or healthy discount, that annual plans lift conversion or retention, that annual customers can't cancel or get refunds, that MRR is cash, that monthly churn converts to a renewal rate, and that fee savings make a discount profitable. + +### Next steps + +[`/customer-churn-calculator`](/customer-churn-calculator) for the churn input (in usage), [`/churn-rate-converter`](/churn-rate-converter) in the renewal warning, [`/mrr-goal-calculator`](/mrr-goal-calculator) in the MRR section, [`/mrr-calculator`](/mrr-calculator) for MRR across plans, [`/customer-lifetime-value-calculator`](/customer-lifetime-value-calculator) for value per customer, and [`/platform-fees`](/platform-fees) for other platform charges. diff --git a/content_docs/churn-rate-converter/research.md b/content_docs/churn-rate-converter/research.md new file mode 100644 index 00000000..d96b93c1 --- /dev/null +++ b/content_docs/churn-rate-converter/research.md @@ -0,0 +1,246 @@ +# Research: churn rate converter + +**Slug recommendation:** Keep `/churn-rate-converter`. The search wording is usually “monthly to annual churn rate calculator,” but the proposed route covers monthly, quarterly, and annual rates in either direction. Use “monthly to annual churn rate calculator” in the page title and lead. + +Route: `/churn-rate-converter` +Checked: 2026-10-10 +Competitors used: 6 + +## Scope + +A SaaS founder already has a customer churn rate and its period. They want the equivalent rate for another period, especially monthly to annual, without multiplying by 12. The converter should handle monthly, quarterly, and annual rates in both directions. It should show retention and, for customer churn only, the expected result for a starting group of 100 customers. Average customer lifetime can help if it uses the same period as the rate and is labeled as a model estimate. + +This tool does not calculate a churn rate from customer counts or billing data. That is the job of `/customer-churn-calculator`. It does not treat revenue churn as customer churn or give a universal “good” grade. A converted rate is a projection under a constant-rate assumption, not a measured result for a specific year. + +The route is planned, not present in the current app or page registry. Roadmap item 79, “Monthly ↔ Annual Churn Converter,” is marked Keep in `stack127_docs/tool-roadmap-feasibility-review-2026-09-24.md`. + +The existing tools leave a distinct job for this route: + +- `/customer-churn-calculator` takes opening accounts and lost opening accounts. It returns churn for that one reporting period and retained accounts. Its article already explains that 5% monthly churn compounds to about 46% annual churn, but its calculator does not convert a rate the user already knows. +- `/revenue-churn-calculator` uses opening MRR, canceled MRR, and reduced MRR to calculate gross revenue churn for a month. It does not convert a supplied percentage. +- `/customer-lifetime-value-calculator` takes customer churn with ARPA and margin. The selected basis can be month or year, but the user must supply churn on that basis. Its lifetime model uses `1 ÷ churn` and does not convert rates. +- `/saas-metrics-calculator` also lists annualized churn conversion as out of scope in its tool spec. + +Keep this as a standalone tool. The input job differs from the customer churn calculator: the user has a rate, not opening and lost counts. A separate route also gives the conversion question a clear search landing page. Link it from the three related calculators. + +Searches tried: + +- “monthly to annual churn rate calculator” +- “churn rate converter monthly annual calculator” +- “annual churn rate from monthly churn calculator” +- “monthly churn annualized is it multiply by 12 compound churn rate” +- “quarterly to annual churn rate converter formula” +- “quarterly churn rate to monthly equivalent formula” +- “customer churn vs revenue churn conversion negative revenue churn” +- “customer churn calculator annual contract monthly rate cohort question” + +Google results repeatedly surfaced `churnkey.co`, `churntools.com`, `subscriptionindex.com`, `omnicalculator.com`, Stripe, and ChartMogul. Further searches surfaced SubJolt, Refgrow, and katlab. + +## Search intent + +| Query | What the searcher wants | Answer they need | What current results miss | +| --- | --- | --- | --- | +| monthly to annual churn rate calculator | Convert a known monthly rate to an annual figure | Compound the monthly retention rate, then subtract it from 1 | Many results stop at monthly to annual and do not show the reverse or quarterly conversion. Some say “annual churn” without saying it is modeled. | +| churn rate converter monthly annual calculator | Convert a known rate in either direction | Choose the input period and get equivalent rates with period labels | Most tools combine a rate converter with a count calculator, or show only one input direction. | +| annual churn rate from monthly churn calculator | Compare monthly operations with an annual benchmark or plan | Show annual retention and lost share, with a clear constant-rate assumption | Results often present annualized monthly churn as though it were directly measured annual churn. | +| monthly churn annualized is it multiply by 12 | Check whether `monthly × 12` is valid | Explain the shrinking base with a worked example | Some finance and SaaS material still calls multiplication an easy annualization. It is only a rough linear estimate, not the cohort loss under steady churn. | +| quarterly churn rate to annual churn | Convert a quarterly rate to one year | Use four quarter retention factors, or convert through a monthly equivalent | Direct quarterly examples are rare. A number entered as “quarterly” is easy to mistake for a monthly rate. | +| customer churn vs revenue churn negative churn | Understand whether to use customer loss, lost MRR, or net revenue churn | Keep logo churn separate from gross and net revenue churn; explain that net churn can be below zero | Pages often put these in neighboring modes without explaining that negative net churn means revenue growth, not negative customer loss. | +| what is a good churn rate / what does a 20% churn rate mean | Judge a rate or interpret an unlabeled percentage | Ask what is measured, for which period, customer or revenue basis, and business segment | Benchmark bands conflict and are often shown without source, date, contract type, or customer price range. | + +Google’s “People also ask” box for the exact monthly-to-annual query showed: + +- How do I convert monthly churn to annual churn? +- How to calculate monthly churn rate? +- How to convert a monthly rate to annual? +- What does a 20% churn rate mean? + +Related searches included monthly-to-annual churn rate calculator Excel or app, monthly churn rate formula, churn calculation formula, annualized churn rate, good churn rate, how to calculate churn in Excel, and SaaS churn rate calculator. The first three questions are answered by top results. The “20% churn” question is often answered with benchmarks instead of first asking what period and churn type the 20% describes. Few results explain when a converted rate should not be used as a forecast. + +## User problems + +| Problem people repeat | Evidence link | What it means for the tool | +| --- | --- | --- | +| Monthly and annual benchmarks look contradictory when the period is missing. A SaaS founder asked why a 4% monthly rate was paired with a 5% to 7% annual benchmark. | [Reddit: Recurly market churn rate error](https://www.reddit.com/r/SaaS/comments/1lsl8p1/) | Put the period next to every rate. Show the equivalent, then say whether it is measured or modeled. Do not compare the figures without matching the period and churn type. | +| Founders with annual-only subscriptions ask whether a yearly non-renewal rate should be spread across months. A discussion of 20% annual churn also raised renewal timing, cohorts, and annual contract value. | [Reddit: churn rates for an annual SaaS product](https://www.reddit.com/r/private_equity/comments/1imfebe/) | Explain that 1.84% monthly is the constant monthly equivalent of 20% annual churn. It does not mean annual-plan customers can cancel every month. Renewal-at-risk analysis is better when renewal dates are known. | +| A founder with 8% monthly churn asked whether to focus on churn or growth. Replies asked them to inspect tenure, acquisition source, and revenue churn rather than treating one blended rate as the whole story. | [Reddit: when does churn become a problem?](https://www.reddit.com/r/SaaS/comments/1wnhblx/) | Show what a rate means for a 100-customer starting cohort, but do not grade the business or prescribe a fix from one percentage. State that cohorts and segments can differ. | +| A beginner asked how to normalize products with monthly and annual subscriptions into one cohort view. | [Reddit: cohort analysis help for a beginner](https://www.reddit.com/r/analytics/comments/19cxd21/) | Keep the source period visible and explain that a monthly equivalent is a model, not a direct observation of annual-plan behavior. | +| A founder sharing a free churn calculator listed recurring calculation mistakes, including mixing customer churn and revenue churn and using ending customers as the denominator. | [Reddit: free SaaS churn calculator and common pitfalls](https://www.reddit.com/r/SaaS/comments/1n4x3le/) | Keep customer churn separate from revenue churn. Link to the count calculator for users who have counts but not a rate. | +| A SaaS founder described annual churn as nearly zero until renewal after changing from monthly to annual-only plans. | [Reddit: switched from monthly to annual-only pricing](https://www.reddit.com/r/SaaS/comments/1s3a7vs/) | A steady monthly hazard does not describe renewal-only cancellation risk. Label the converted rate as equivalent and recommend direct renewal or cohort data when available. | + +## Facts to get right + +| Fact | Primary source | How the tool should treat it | +| --- | --- | --- | +| Basic customer churn is customers lost during a period divided by active customers at the start of that period. The interval must be stated. | [Stripe monthly churn guide](https://stripe.com/resources/more/monthly-churn-101) | This converter starts with an already calculated rate. Do not ask for new customers or mix acquisition into churn. Link to `/customer-churn-calculator` for count-based calculation. | +| If a constant fraction `p` of the remaining cohort churns each month, monthly retention is `1 − p`. After 12 months retention is `(1 − p)^12`, so annual cohort churn is `1 − (1 − p)^12`. The reverse is `1 − (1 − annual churn)^(1/12)`. These reverse and quarterly formulas follow by taking roots or changing the number of periods. | [ChartMogul customer churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate) confirms that 5% monthly loss corresponds to about 46% annual loss. [ChartMogul cohort retention](https://help.chartmogul.com/article/159-cohort-customer-retention) states the same 5% example and offers monthly, quarterly, and annual intervals. | Treat the formula as a constant-rate conversion, not as a claim that every real cohort loses the same fraction each month. For a source interval of `s` months and target interval of `t` months, use `target churn = 1 − (1 − source churn)^(t/s)`. | +| A 5% monthly rate gives `1 − 0.95^12 = 45.96%` annual churn and 54.04% annual retention. With 100 starting customers, the model expects 45.96 lost and 54.04 retained. | [ChartMogul customer churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate) | Say “about 46 of 100” and “about 54 remain.” These are expected cohort values, not a promise of an exact integer count. The simple `5% × 12 = 60%` estimate overstates this result. | +| Quarterly conversions use three months per quarter and four quarters per year. For quarterly churn `q`, annual churn is `1 − (1 − q)^4`. The monthly equivalent is `1 − (1 − q)^(1/3)`. | Derived from the same retention-factor rule above. ChartMogul lists month, quarter, and year as cohort intervals in its [retention report](https://help.chartmogul.com/article/159-cohort-customer-retention). | Label each input period. For example, 5% quarterly churn equals 1.70% monthly equivalent and 18.55% annualized churn. Do not divide or multiply by 3 or 4. | +| Average customer lifetime `1 ÷ p` is a geometric-model expectation in periods, when each surviving customer has the same churn probability each period. | Existing stack127 model: `lib/customer-lifetime-value-calculator/engine.ts`; explanation in `app/(site)/(tools)/(saas)/customer-lifetime-value-calculator/content/article.mdx`. | Use only for customer churn, keep the rate and lifetime period aligned, and say it is not observed tenure. At 0% churn there is no finite estimate. For revenue churn, do not label `1 ÷ rate` as customer lifetime. | +| Gross revenue churn measures lost recurring revenue from cancellations and downgrades. Net revenue churn subtracts expansion. Net churn may be negative when expansion exceeds losses. | [Stripe revenue churn guide](https://stripe.com/resources/more/revenue-churn-101-how-to-calculate-it-and-why-it-matters); [ChartMogul NRR definition](https://help.chartmogul.com/article/138-benchmarks) | A net churn rate of `−1%` means 101% net revenue retention for that period. Repeating that same revenue factor for 12 months gives `1 − 1.01^12 = −12.68%` net annual churn, or 112.68% NRR. This is modeled net revenue growth, not a customer survival probability. Do not attach a customer lifetime or “100 customers lost” result to it. For launch, keep the converter focused on customer churn. | +| Directly measured annual churn can differ from an annualized monthly rate. Cohort retention changes with customer age, acquisition period, plan, and reactivation. | [ChartMogul cohort analysis](https://help.chartmogul.com/article/161-cohort-analysis); [ChartMogul customer churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate) | Prefer an actual year-long cohort or renewal result when the user has one. Call the converted result an equivalent or annualized rate. Keep the assumption visible. | +| Contract term and payment schedule are different. A lump-sum annual contract often has one renewal decision per year; an annual contract paid in installments has more payment-failure opportunities. | [ChartMogul billing cycles](https://chartmogul.com/saas-metrics/billing-cycles/) | Do not imply that annual-plan customers face the same monthly cancellation hazard as monthly subscribers. The converter changes rate units; it cannot infer renewal timing or involuntary payment churn. | +| Customer churn, gross revenue churn, and net revenue retention can move in different directions. ChartMogul's standard customer churn definition also excludes customers who join and churn or churn and reactivate within the interval. | [ChartMogul customer churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate); [ChartMogul NRR definition](https://help.chartmogul.com/article/138-benchmarks) | Ask the user to convert one consistent metric from one source. Do not silently turn a count of cancellation events into customer churn, or treat customer churn as a revenue rate. | + +## Competitor reviews + +Each calculator below was opened and used on 2026-10-10. The three examples in each code block were entered in the live page. Rates are percentages. + +### 1. Churnkey + +URL: [Convert monthly to annual churn](https://churnkey.co/blog/convert-monthly-churn-to-annual-churn/) + +- **Observed** The article has a monthly rate text field and slider. It returns annual churn and shows a conversion table. The calculator has one input direction, monthly to annual. +- **Observed** `5%` returned `45.96%`; `100%` returned `100%`. Typing `-1` left `-1` in the text field but the paired slider moved to `1`, and the result became `11.36%` with no validation message. +- **Documented** The article gives both monthly-to-annual and annual-to-monthly formulas and explains that a repeated monthly loss applies to a shrinking base. +- **Inferred** The math is clear, but the invalid entry behavior can make the visible value disagree with the value used in the calculation. +- **Unknown** The article documents the reverse formula, but the calculator UI I tested did not expose an annual input. + +```text +5% monthly -> 45.96% annual +100% monthly -> 100% annual +-1% monthly -> 11.36% shown, input still displays -1 +``` + +### 2. ChurnTools + +URL: [Churn Rate Calculator](https://churntools.com/churn-rate-calculator) + +- **Observed** It has tabs for customer churn, gross revenue churn, net revenue churn, and monthly-to-annual conversion. Customer churn takes opening customers and customers lost. A separate converter shows monthly and annual rate fields. +- **Observed** In customer mode, `1,000` starting and `50` lost returned `5.0%` monthly, `46.0%` annual, and `460` lost per year. `100` starting and `100` lost returned `100%` for both rates. `100` starting and `101` lost was accepted and returned `101.0%` monthly churn. +- **Observed** In the monthly-to-annual mode, `5%` returned `46.0%` annual. I could not confirm reverse-input behavior: entering `45.96` in the annual field left the monthly field blank in this browser session. +- **Documented** The page states the compound monthly-to-annual formula and explains that net revenue churn subtracts expansion from canceled and downgraded MRR. +- **Inferred** Combining all churn types under one calculator makes the metric tabs important. Its “lost per year” count is a modeled expected count, not a promise of whole customers. +- **Unknown** Whether the annual field needs another direction control or whether reverse conversion failed in the tested browser session. + +```text +1,000 start, 50 lost -> 5.0% monthly, 46.0% annual, 460 modeled losses +100 start, 100 lost -> 100.0% monthly, 100.0% annual +100 start, 101 lost -> accepted as 101.0% monthly +``` + +### 3. SubJolt + +URL: [Free Churn Rate Calculator](https://www.subjolt.com/tools/churn-rate-calculator/) + +- **Observed** It takes starting customers, customers lost, and a period length in months from 1 to 12. It shows a monthly equivalent, annualized churn, retention, customer lifetime, a 12-month loss projection, and a benchmark label. +- **Observed** `1,000` start, `50` lost over `1` month returned `5%` monthly, `45.96%` annual, and 20 months lifetime. Over 3 months, the same 5% period churn returned `1.70%` monthly equivalent and `18.55%` annual churn. A 100% quarterly loss returned 100% annual churn and a 1-month lifetime. `100` start and `101` lost produced an error: “You can't lose more customers than you started with.” +- **Documented** It explains that annual churn compounds and that customer and revenue churn are different measures. +- **Inferred** Its benchmark label adds a judgment to an otherwise useful converter. The label can distract from the different behavior of contract types and customer segments. At 100% quarterly loss, the monthly-equivalent hazard does not reveal when inside the quarter customers left, so the 1-month lifetime looks more precise than the input supports. +- **Unknown** The calculator page does not show a source next to its benchmark bands. + +```text +1,000 start, 50 lost, 1 month -> 5.00% monthly, 45.96% annual, 20 months lifetime +1,000 start, 50 lost, 3 months -> 1.70% monthly equivalent, 18.55% annual +100 start, 101 lost -> validation error, no result +``` + +### 4. Refgrow + +URL: [Churn Rate Calculator](https://refgrow.com/churn-rate-calculator) + +- **Observed** It takes starting and lost customers, a monthly, quarterly, or annual period, and monthly ARPU. It reports period churn, retention, annualized churn, average lifetime, revenue lost, customer LTV, and a health message. +- **Observed** `500` start and `25` lost monthly returned `5%` monthly churn, `95%` retention, `45.96%` annual churn, and 20 months lifetime. `1,000` start and `50` lost quarterly returned `5%` quarterly churn, `1.70%` monthly equivalent, and `18.55%` annual churn. `100` start and `100` lost quarterly returned 100% churn and a 1-month lifetime. `100` start and `101` lost was accepted and returned `101%` quarterly churn and `−1%` retention. +- **Documented** The page says it supports monthly, quarterly, and annual periods, and describes its results as estimates. +- **Inferred** Returning a percentage below zero for retained customers after impossible count input can mislead a user. The revenue and LTV outputs also require a careful explanation of how ARPU and period basis relate. +- **Unknown** How the health message changes by segment, contract duration, or ARPU. The page does not identify a source beside the health result. + +```text +500 start, 25 lost, monthly -> 5% churn, 95% retention, 45.96% annual +1,000 start, 50 lost, quarterly -> 5% quarter, 1.70% month equivalent, 18.55% annual +100 start, 101 lost, quarterly -> accepted as 101% churn and -1% retention +``` + +### 5. Subscription Index + +URL: [Churn Rate Calculator](https://www.subscriptionindex.com/tools/churn-rate-calculator) + +- **Observed** It takes customers at the start of the month and customers lost that month. It shows monthly churn, annual churn, annual retention, and formulas. It has no period selector or rate-only converter. +- **Observed** `1,000` start and `50` lost returned `5.0%` monthly, `46.0%` annual, and `54.0%` annual retention. `100` and `100` returned 100% monthly and annual churn. `100` and `101` was accepted and returned 101% monthly, 100% annual churn, and 0% annual retention. +- **Documented** The page says it measures logo churn and provides the compounding formula. It says revenue churn can differ and points users to track both. +- **Inferred** It answers the monthly count calculation cleanly, but cannot convert a rate the user already has or handle quarterly reporting. Its benchmark bands are not accompanied by a source on the calculator page. +- **Unknown** How it handles a zero starting count. I did not test that boundary. + +```text +1,000 start, 50 lost -> 5.0% monthly, 46.0% annual, 54.0% retention +100 start, 100 lost -> 100.0% monthly and annual +100 start, 101 lost -> accepted as 101.0% monthly +``` + +### 6. katlab + +URL: [Churn Rate Calculator](https://calc.katlab.dev/churn-rate/) + +- **Observed** It has a customer-count mode and a separate monthly-to-annual mode. The count mode shows churn, retention, average lifetime, and annual churn. The conversion mode accepts a monthly rate and shows annual churn, annual retention, and the wrong `× 12` result. +- **Observed** In the converter, `5%` returned `46.0%` annual churn and `54.0%` retention. `100%` returned `100%` annual churn. `−1%` returned no result and the message “Enter a monthly churn rate between 0 and 100%.” +- **Documented** The page explains the shrinking-base formula and gives the count formula. The count example with 3.5% monthly churn returns 34.8% annual churn. +- **Inferred** The “wrong × 12” comparison helps explain the task. The converter still covers only monthly to annual, so quarterly and reverse conversion require a different page. +- **Unknown** Whether the creator plans more rate periods or reverse conversion. + +```text +5% monthly -> 46.0% annual, 54.0% retention, 60.0% naive × 12 +100% monthly -> 100.0% annual +-1% monthly -> no result, range error shown +``` + +## Comparison + +| Feature or answer | Churnkey | ChurnTools | SubJolt | Refgrow | Subscription Index | katlab | What stack127 should do | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Monthly to annual | Yes | Yes | Yes | Yes | Yes | Yes | Show both rates to two decimals. | +| Quarterly input | No | Not shown | Yes | Yes | No | No | Support monthly, quarterly, and annual directly. | +| Annual to monthly | Formula in article, no UI input | UI field shown, reverse result not confirmed | No | No | No | No | Support both directions and test the reverse path. | +| Retention / 100-customer result | Annual retention | Modeled loss count | Retention and modeled loss count | Retention and revenue/LTV results | Annual retention | Annual retention and example | Show retention and an approximate 100-customer cohort result, clearly labeled as expected. | +| Average customer lifetime | No converter result | No converter result | Yes | Yes | No | Yes in count mode | Show only for customer churn. Match lifetime units to the source rate. | +| Negative or impossible input | Negative rate clamps to a 1% slider result | Accepts 101 lost from 100 | Rejects lost greater than start | Accepts lost greater than start | Accepts lost greater than start | Rejects a negative rate | Reject values outside 0% to 100%; do not silently clamp. | +| Benchmarks / grade | Article gives advice | Separate rank product | Yes | Yes | Yes | Says benchmarks depend on segment | Skip a universal grade. Cite any benchmark by source, segment, and date. | +| Revenue churn | Article links to analytics product | Gross and net modes | Separate tools listed | ARPU and revenue impact | Explains the difference | Not in converter | Keep the core converter focused on customer churn. Explain the boundary to revenue churn. | + +## Where competitors disagree + +- **Compounding versus multiplying by 12.** Some finance material describes `monthly churn × 12` as a quick annualization. For actual one-year loss from a cohort that keeps the same monthly survival rate, compound retention: `1 − (1 − monthly churn)^12`. At 5% monthly, that is 45.96%, not 60%. Multiplication can be a rough low-rate approximation, but it is not the cohort's loss share and can exceed 100%. +- **What counts as churn.** Stripe's basic formula uses customers lost against the opening base. ChartMogul's standard report excludes customers who join and churn in the same period and customers who churn and reactivate in that period. These are reporting rules, not a difference in the conversion math. The converter should not recalculate a rate from events. It should tell users to keep their metric definition consistent. +- **Annualized versus measured annual churn.** A constant monthly rate produces an equivalent annual rate. It can differ from the directly measured loss in an actual year, especially with annual renewal dates, different churn by customer age, seasonal behavior, or changing customer mix. When direct annual cohort data exists, use it for reporting. +- **What counts as healthy churn.** Refgrow described 5% monthly churn as “Good.” SubJolt's table puts 5% at the start of “High.” Neither grade is a universal answer. ChartMogul's churn report reports different rates by average revenue per account, and contract type changes how often customers can leave. +- **Customer churn versus revenue churn.** ChurnTools offers both in separate modes. Stripe defines net revenue churn as lost revenue minus expansion; it can be negative. The compounding equation can be applied to a repeated net revenue factor, but the result is revenue growth, not a customer retention probability. Never calculate average customer lifetime from negative net revenue churn. + +## How stack127 can win + +- Keep the standalone `/churn-rate-converter` route. Make “monthly to annual churn rate calculator” the main search phrase, while the route covers quarterly conversion and reverse conversions too. +- Accept a known percentage and a source period. Show its equivalent monthly, quarterly, and annual rates without requiring customer counts. +- Use `1 − (1 − rate)^(target periods / source periods)`. Show the assumption beside the result: the same share of the remaining starting cohort churns in every period. +- Put a worked 5% monthly example beside the calculation: 45.96% annual churn, 54.04% annual retention, and about 46 of 100 customers lost over a year. Show `60%` as the incorrect `5% × 12` comparison. +- Label converted values as “monthly equivalent” or “annualized.” If the user has a directly measured annual figure, do not suggest the converted figure replaces it. +- If showing lifetime, state that `1 ÷ customer churn` is a model estimate in the selected period. Use “not estimable” at zero churn. Do not apply it to revenue churn. +- Keep the first version about customer churn. If a revenue mode is added later, separate gross revenue churn from net revenue churn. For a negative net rate, show the implied revenue growth or NRR only under the repeated-factor assumption. Do not show customers retained or average customer lifetime. +- Reject rates below 0% or above 100% for customer churn. If a count example is included, reject lost customers above the starting count and handle zero starting customers as undefined. +- Avoid a “good” or “bad” grade without a dated, segment-matched source. The founder still needs a clear period label and a useful conversion when they do not want a benchmark. +- Link the customer count calculator, revenue churn calculator, and customer LTV calculator where each answers the next question. + +## Blockers + +None. The conversion needs no paid API, maintained dataset, account, or background job. The main limitation is the input definition: the calculator cannot verify how a founder's billing system counted customers, renewals, reactivations, or revenue movements. + +## Sources + +- [Google search for “monthly to annual churn rate calculator”](https://www.google.com/search?q=monthly+to+annual+churn+rate+calculator), checked 2026-10-10. +- [Stripe monthly churn guide](https://stripe.com/resources/more/monthly-churn-101), checked 2026-10-10. +- [ChartMogul customer churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate), checked 2026-10-10. +- [ChartMogul customer retention cohorts](https://help.chartmogul.com/article/159-cohort-customer-retention), checked 2026-10-10. +- [ChartMogul cohort analysis](https://help.chartmogul.com/article/161-cohort-analysis), checked 2026-10-10. +- [Stripe revenue churn guide](https://stripe.com/resources/more/revenue-churn-101-how-to-calculate-it-and-why-it-matters), checked 2026-10-10. +- [ChartMogul benchmarks and retention definitions](https://help.chartmogul.com/article/138-benchmarks), checked 2026-10-10. +- [ChartMogul billing cycles](https://chartmogul.com/saas-metrics/billing-cycles/), checked 2026-10-10. +- [Scale Venture Partners, Four Vital Signs of SaaS](https://www.scalevp.com/wp-content/uploads/2022/04/Scale-Four-Vital-Signs-of-SAAS-E-Book-09-20-2021.pdf), checked 2026-10-10. +- [Reddit: Recurly market churn rate error](https://www.reddit.com/r/SaaS/comments/1lsl8p1/), checked 2026-10-10. +- [Reddit: churn rates for an annual SaaS product](https://www.reddit.com/r/private_equity/comments/1imfebe/), checked 2026-10-10. +- [Reddit: when does churn become a problem?](https://www.reddit.com/r/SaaS/comments/1wnhblx/), checked 2026-10-10. +- [Reddit: cohort analysis help for a beginner](https://www.reddit.com/r/analytics/comments/19cxd21/), checked 2026-10-10. +- [Reddit: free SaaS churn calculator and common pitfalls](https://www.reddit.com/r/SaaS/comments/1n4x3le/), checked 2026-10-10. +- [Reddit: switched from monthly to annual-only pricing](https://www.reddit.com/r/SaaS/comments/1s3a7vs/), checked 2026-10-10. +- [Churnkey calculator and formula article](https://churnkey.co/blog/convert-monthly-churn-to-annual-churn/), checked 2026-10-10. +- [ChurnTools calculator](https://churntools.com/churn-rate-calculator), checked 2026-10-10. +- [SubJolt churn calculator](https://www.subjolt.com/tools/churn-rate-calculator/), checked 2026-10-10. +- [Refgrow churn calculator](https://refgrow.com/churn-rate-calculator), checked 2026-10-10. +- [Subscription Index churn calculator](https://www.subscriptionindex.com/tools/churn-rate-calculator), checked 2026-10-10. +- [katlab churn calculator](https://calc.katlab.dev/churn-rate/), checked 2026-10-10. diff --git a/content_docs/churn-rate-converter/tool-spec.md b/content_docs/churn-rate-converter/tool-spec.md new file mode 100644 index 00000000..5d7f9e52 --- /dev/null +++ b/content_docs/churn-rate-converter/tool-spec.md @@ -0,0 +1,463 @@ +# Churn rate converter + +Route: `/churn-rate-converter` + +Research: [research.md](research.md) + +## Why people will choose this tool + +The searcher has a customer churn rate for one period and needs the same rate for another period. Most often they have a monthly rate and need the annual figure for a plan, an investor update, or a benchmark that is quoted per year. Some have the reverse: an annual non-renewal rate they want to compare with monthly numbers. Underneath is a doubt: "Can I just multiply by 12?" + +Where the current top results fail them: + +- Most converters go one way only, monthly to annual. Only SubJolt and Refgrow take a quarterly rate, and both make you enter customer counts first. None of the six takes an annual rate and returns a monthly one. +- Bad input goes through. Churnkey shows `-1` in the field but calculates with 1%. ChurnTools, Refgrow, and Subscription Index accept 101% churn, and Refgrow then shows −1% retention. +- Only katlab shows the wrong `× 12` figure next to the right one, which is the fastest way to see why the tool exists. +- The converted rate is presented as if it were measured. No result says it assumes the same share leaves every period, or that an annual-plan customer doesn't really get a monthly chance to cancel. +- Several pages add a "Good" or "High" grade without a source. Refgrow calls 5% monthly good, and SubJolt calls it high. +- Lifetime is shown with false precision. At 100% quarterly churn, SubJolt and Refgrow both show a 1-month lifetime, though the input says nothing about when inside the quarter people left. + +What this tool does better: + +1. One rate and its period in, equivalent monthly, quarterly, and annual rates out, in every direction. +2. The wrong linear figure (`× 12`, `÷ 12`, `× 4`, and so on) sits beside each converted rate, so the reader sees how far off it is. +3. Retention beside every rate, and a plain "out of 100 customers" view of one year, labeled as expected values. +4. Average lifetime as a model estimate in the period the user entered, and `Not estimable` at 0%. +5. Values below 0% or above 100% are rejected with a message under the field. Nothing is clamped. A negative rate gets a short note that negative net revenue churn isn't customer churn. +6. The constant-rate assumption is stated in the result. An annual source rate also gets the annual-plan caveat. +7. No grades or benchmarks. + +## Product + +### Promise and scope + +The primary user is an indie hacker or solo SaaS founder who already has a customer (logo) churn rate for one period, from their billing tool, dashboard, or the customer churn calculator. The tool gives the constant-rate equivalent of that rate for a month, a quarter, and a year, with retention, the linear mistake for contrast, a 100-customer view of one year, and the model's average lifetime. + +Launch scope: + +- Customer churn only. +- One rate, as a percent with up to two decimals, from 0 to 100. +- One source period: month, quarter, or year. A quarter is 3 months and a year is 12 months. +- Outputs for all three periods, always in month, quarter, year order. + +Exclusions: + +- Calculating churn from customer counts. `/customer-churn-calculator` does that, and the page links to it. +- Revenue churn, gross or net. A revenue mode would need its own labels: negative net churn is revenue growth, and it has no customer lifetime or 100-customer view. The article explains the boundary and links to `/revenue-churn-calculator` and `/net-revenue-retention-calculator`. No revenue toggle. +- Custom period lengths, such as 6 months or 2 years. They're rare in the research and would turn three plain choices into a number field. +- Cohort curves, renewal-date analysis, and churn that changes with customer age. +- Grades, benchmark bands, recommendations, LTV, money, URL sharing, saved scenarios, and export. + +### Technical contract + +**Inputs.** + +| Input | Accepted value | Notes | +| --- | --- | --- | +| Churn rate (`rate`) | String. A percent from `0` to `100`, with up to two decimals. Required. | Share of customers at the start of one period who left by its end. | +| Period (`period`) | `month`, `quarter`, or `year`. Default `month`. | The period the rate was measured over. | + +**Parsing `rate`,** in this order. Use `parsePercentHundredths` from `lib/saas-economics/shared.ts` for the number itself. Don't write a second decimal parser. + +1. Not a string, or blank after trimming: `missing`. +2. Trim. Remove one trailing `%` and trim again, so a pasted `5%` or `5 %` works. Anything left empty: `missing`. +3. Starts with `-`: `belowZero`. This applies to `-0` too, since a sign has no use here. +4. Matches `^\d+\.\d{3,}$`: `tooPrecise`. +5. Call `parsePercentHundredths(text, { min: 0n, max: 10000n })`. `format` stays `format`, `range` becomes `aboveHundred`, and `missing` can't happen here. + +Commas, spaces inside the number, a leading `+` or `.`, a trailing `.`, and exponents are `format`. `0`, `0.00`, `100`, and `100.00` are valid. Leading zeros are fine: `05` is 5%. + +**Validation codes and messages.** The engine owns the messages, like the sibling engines. The writing session may reword them later in the engine. + +| Field | Code | Message | +| --- | --- | --- | +| `rate` | `missing` | `Enter a churn rate from 0 to 100%.` | +| `rate` | `format` | `Use digits only, like 5 or 4.25.` | +| `rate` | `tooPrecise` | `Use up to two decimals.` | +| `rate` | `belowZero` | `Churn can't be below 0%. Negative net revenue churn isn't customer churn, so it can't be converted here.` | +| `rate` | `aboveHundred` | `Churn can't be above 100%. A period can't lose more customers than it started with.` | +| `period` | `unknownPeriod` | `Choose monthly, quarterly, or annual.` | + +Both fields are checked on every call, so both errors can show at once. + +**Formula.** Let `h` be the rate in hundredths of a percent (0 to 10000), `r = (10000 − h) / 10000` the source retention, and `s` and `t` the source and target period lengths in months. Target retention is `R = r^(t / s)` and target churn is `1 − R`. This is the research formula `target = 1 − (1 − source)^(t/s)`. + +The exponent is always one of `1`, `3`, `4`, `12`, `1/3`, `1/4`, or `1/12`: + +| Source → target | Exponent | Kind | +| --- | --- | --- | +| Same period | 1 | Identity | +| Month → quarter, month → year, quarter → year | 3, 12, 4 | Whole power, exact fraction | +| Quarter → month, year → quarter, year → month | 1/3, 1/4, 1/12 | Root, irrational in general | + +**Numeric method.** Do every step in `bigint`. No floating point anywhere in the engine, including roots. + +- Whole power `n`: churn is the exact fraction `1 − (10000 − h)^n / 10000^n`. Build it with `exactFraction` and round with `roundExact(value, 10000n)` from `lib/saas-economics/unit-economics.ts`. The numbers stay small: `10000^12` is 10^48. +- Root `1/n`: the root itself is never computed. Rounding only needs to know which side of each half-step the true value falls on, and that comparison can be raised to the power `n` and done exactly. The rounded churn in hundredths of a percent is the largest whole `k` from 0 to 10000 for which `k = 0` or + + `(10000 − h) × 20000^n ≤ (20001 − 2k)^n × 10000`. + + Find `k` by binary search, about 14 comparisons. Why it works: churn is at least `(k − ½) / 10000` exactly when `R ≤ (20001 − 2k) / 20000`, and both sides are non-negative, so raising them to the power `n` keeps the order, and `R^n = r` is exact. Ties round up, matching the half-up rule. This method was checked against 70-digit decimals for every `h` from 0 to 10000 and every root, with no mismatch. +- Identity: churn is `h`. + +**Rounding.** All rates are returned in hundredths of a percent, so `4596` is `45.96%`. + +- Churn: half up, as above. Every rate is between 0 and 100%, so half up and half away from zero are the same. +- Retention: `10000 − churn hundredths`, taken from the rounded churn. Each pair adds up to exactly 100.00%. Never round retention separately. +- `roundedToZero`: true when the exact churn is above 0 but rounds to `0`. The interface shows `Under 0.01%` for churn, and `Over 99.99%` for its retention. +- `nearlyAll`: true when the exact churn is below 100% but rounds to `10000`. The interface shows `Over 99.99%` for churn and `Under 0.01%` for its retention. Exact 0% and exact 100% show plainly. +- Exact churn is above 0 exactly when `h > 0`, and below 100% exactly when `h < 10000`. Both flags come from that and the rounded value. + +**Linear estimate,** the wrong figure the tool shows for contrast. For each target period other than the source: `h × t / s`, as an exact fraction rounded half up to hundredths of a percent. It can go above 100%: 10% monthly × 12 is 120%. Return it as it is and set `overHundred`. Set `roundedToZero` the same way as for churn. The source row has no linear estimate. + +**100 customers over a year.** From the exact annual churn `A`, which is always a whole power or the identity, never a root: + +- `lost` is `A × 100` rounded half up to a whole number of customers, with `roundExact(A, 100n)`. Don't round the already rounded annual hundredths again. +- `remaining` is `100 − lost`, so the two always add up to 100. +- `lostUnderOne`: `A > 0` and `lost` is 0. +- `remainingUnderOne`: `A < 1` and `lost` is 100. + +These are expected values from the model. The interface says "about 46", not "46", unless the source rate is exactly 0% or 100%. + +**Average lifetime.** `1 ÷ churn`, in the source period: `10000 / h` periods, rounded half up to tenths with `roundExact(exactFraction(10000n, h), 10n)`. It ranges from 1.0 (at 100%) to 10,000.0 (at 0.01%). At `h = 0` the result is `notEstimable` with reason `zeroChurn`. Never return zero, `Infinity`, or a value from a substituted rate. + +Lifetime stays in the source period on purpose. In this discrete model, `1 ÷ monthly equivalent` doesn't equal `3 × (1 ÷ quarterly rate)`: 5% quarterly gives 20 quarters, while its 1.70% monthly equivalent gives 59 months, not 60. Showing only the source period avoids two lifetimes that disagree. + +**Lead period.** `year` when the source is `month` or `quarter`. `month` when the source is `year`. + +**Action and handling.** `Convert churn rate` is the only primary action. + +- A valid submit shows the results and focuses the lead result. +- An invalid submit clears any old result, keeps the raw input, shows the error under the field, and focuses it. +- Editing the rate or changing the period clears the result and announces `Inputs changed. Convert again.` +- Reset clears the rate, sets the period back to monthly, clears errors and results, and focuses the rate field. + +**Privacy and security.** The conversion runs in the browser. The rate never goes to a server, the URL, cookies, storage, logs, or analytics. The clean URL stays unchanged. No API route, dataset, or background job. + +### Backend contract + +Same runtime rules as the other SaaS economics engines: safe for `--experimental-strip-types`, no enums or namespaces, explicit `.ts` imports, and no DOM, network, time, randomness, or floating point. + +Reuse: + +- `parsePercentHundredths` from `lib/saas-economics/shared.ts`. +- `exactFraction`, `compareExact`, and `roundExact` from `lib/saas-economics/unit-economics.ts`. + +Don't change either shared file. If something there is missing, report it instead of adding a parallel helper. + +#### `lib/churn-rate-converter/engine.ts` + +```ts +export const CHURN_PERIODS = ["month", "quarter", "year"] as const; +export type ChurnPeriod = (typeof CHURN_PERIODS)[number]; +export const CHURN_PERIOD_MONTHS: Readonly> = { month: 1, quarter: 3, year: 12 }; + +export type ChurnRateInput = { rate: string; period: string }; + +export type ChurnRateErrorCode = "missing" | "format" | "tooPrecise" | "belowZero" | "aboveHundred"; +export type ChurnRateErrors = { + rate?: { code: ChurnRateErrorCode; message: string }; + period?: { code: "unknownPeriod"; message: string }; +}; + +/** A rate in hundredths of a percent: 4596 is 45.96%. */ +export type ChurnPercent = { + hundredths: number; // 0 to 10000 + roundedToZero: boolean; // exact value > 0, rounded value 0 + nearlyAll: boolean; // exact value < 100%, rounded value 10000 +}; + +export type ChurnLinearEstimate = { + hundredths: number; // 0 or more; may exceed 10000 + overHundred: boolean; // hundredths > 10000 + roundedToZero: boolean; +}; + +export type ChurnPeriodRow = { + period: ChurnPeriod; + isSource: boolean; + churn: ChurnPercent; + /** 10000 - churn.hundredths. roundedToZero mirrors churn.nearlyAll; nearlyAll mirrors churn.roundedToZero. */ + retention: ChurnPercent; + /** null on the source row. */ + linearEstimate: ChurnLinearEstimate | null; +}; + +export type ChurnCohortOf100 = { + lost: number; // 0 to 100, whole customers, half up from the exact annual churn + remaining: number; // 100 - lost + lostUnderOne: boolean; + remainingUnderOne: boolean; +}; + +export type ChurnLifetime = + | { kind: "finite"; tenths: number; period: ChurnPeriod } // 10 to 100000, in the source period + | { kind: "notEstimable"; reason: "zeroChurn" }; + +export type ChurnConversion = { + source: { hundredths: number; period: ChurnPeriod }; + rows: [ChurnPeriodRow, ChurnPeriodRow, ChurnPeriodRow]; // always month, quarter, year + leadPeriod: ChurnPeriod; // "year", or "month" when the source is "year" + yearOf100: ChurnCohortOf100; + lifetime: ChurnLifetime; +}; + +export type ChurnRateCalculation = + | { status: "valid"; values: ChurnConversion; errors: Record } + | { status: "invalid"; values: null; errors: ChurnRateErrors }; + +/** Never throws for any input, including null, arrays, and non-string fields. */ +export function convertChurnRate(raw: unknown): ChurnRateCalculation; +``` + +Notes for the builder: + +- `raw` that isn't a plain object is invalid with both a `rate` `missing` error and a `period` `unknownPeriod` error. +- `period` is matched exactly after trimming. `Monthly`, `MONTH`, and `12` are `unknownPeriod`. +- Keep the root comparison in a small unexported helper. Throwing is only for programmer errors, and the public function must not throw. + +#### Focused test: `tests/pages/churn-rate-converter/engine.test.mjs` + +Use the examples below. Check every row value and flag, the 100-customer counts, and the lifetime. Also check that retention plus churn is 10000 on every row for every `h` from 0 to 10000 with every source period. That loop is cheap and protects the rounding. + +### Known examples + +Calculated independently with 60 to 70 digit decimals, not with the engine method. Rates are percent. Linear estimates are in brackets. + +| # | Input | Monthly | Quarterly | Annual | Of 100 in a year | Lifetime | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | 5% monthly | 5.00% (source) | 14.26% [15.00%] | 45.96% [60.00%], retention 54.04% | 46 lost, 54 remain | 20.0 months | +| 2 | 5% quarterly | 1.70% [1.67%] | 5.00% (source) | 18.55% [20.00%] | 19 lost, 81 remain | 20.0 quarters | +| 3 | 20% annual | 1.84% [1.67%] | 5.43% [5.00%] | 20.00% (source) | 20 lost, 80 remain | 5.0 years | +| 4 | 3.5% monthly | 3.50% | 10.14% [10.50%] | 34.79% [42.00%] | 35 lost, 65 remain | 28.6 months | +| 5 | 10% monthly | 10.00% | 27.10% [30.00%] | 71.76% [120.00%, `overHundred`] | 72 lost, 28 remain | 10.0 months | +| 6 | 45.96% annual | 5.00% [3.83%] | 14.26% [11.49%] | 45.96% | 46 lost, 54 remain | 2.2 years | + +Exact working for the three anchor examples: + +- 5% monthly: `1 − 0.95^12 = 0.459639912…`, so 45.96% annual and 54.04% retention. `0.95^3 = 0.857375`, so 14.2625% quarterly, shown as 14.26%. +- 5% quarterly: `1 − 0.95^(1/3) = 0.016952427…`, so 1.70% monthly. `1 − 0.95^4 = 0.18549375`, so 18.55% annual. +- 20% annual: `1 − 0.8^(1/12) = 0.018423470…`, so 1.84% monthly. `1 − 0.8^(1/4) = 0.054258390…`, so 5.43% quarterly. + +Boundaries: + +| Input | Expected | +| --- | --- | +| `0` monthly | Every row 0.00% churn and 100.00% retention, no flags. Linear 0.00%. 0 lost, 100 remain, no flags. Lifetime `notEstimable`, `zeroChurn`. | +| `100` quarterly | Every row 100.00%, no flags. Monthly linear 33.33%. Annual linear 400.00%, `overHundred`. 100 lost, 0 remain, no flags. Lifetime 1.0 quarter. | +| `0.01` annual | Monthly churn 0 with `roundedToZero` (exact 0.000833…%). Quarterly churn 0 with `roundedToZero` (exact 0.0025000…%). Both retentions 10000 with `nearlyAll`. Both linear estimates 0 with `roundedToZero`. 0 lost with `lostUnderOne`, 100 remain. Lifetime 10000.0 years. | +| `0.01` monthly | Quarterly 0.03% (exact 0.029997…%). Annual 0.12% (exact 0.119934…%). 0 lost, `lostUnderOne`. Lifetime 10000.0 months. | +| `99.99` monthly | Quarterly and annual churn 10000 with `nearlyAll`, retention 0 with `roundedToZero`. Annual linear 1199.88%, `overHundred`. 100 lost, 0 remain, `remainingUnderOne`. Lifetime 1.0 month. | +| `0.5` annual | 100-customer tie: exact 0.5 lost rounds half up to 1 lost, 99 remain. | +| `0.02` annual | Quarterly exact 0.0050004%, rounds to 0.01%, so not `roundedToZero`. Quarterly linear exact 0.005% is a tie and rounds to 0.01%. | +| `0.06` annual | Monthly exact 0.0050014%, rounds to 0.01%. Monthly linear exact 0.005% rounds to 0.01%. | +| `66.35` quarterly | Root near a tie: monthly exact 30.444999802…%, so 30.44%, not 30.45%. | +| `0.64` monthly | Lifetime tie: exact 156.25 rounds to 156.3 months. | + +Parsing: + +| Raw `rate` | Result | +| --- | --- | +| `" 5% "` | Valid, 500. | +| `"05"` | Valid, 500. | +| `""`, `" "`, `"%"`, `undefined`, `5` (a number) | `missing` | +| `"5,5"`, `"1e2"`, `".5"`, `"5."`, `"+5"`, `"five"`, `"5%%"` | `format` | +| `"4.567"` | `tooPrecise` | +| `"-1"`, `"-0"` | `belowZero` | +| `"100.01"`, `"101"`, `"9999999999999"` | `aboveHundred` | +| `period: "Monthly"`, `""`, missing | `unknownPeriod`, alongside any rate error | + +### Ownership + +Sol builds the backend: + +- `lib/churn-rate-converter/engine.ts` +- `tests/pages/churn-rate-converter/engine.test.mjs` +- The development preview row in `public.stack127_pages` and `public.stack127_tool_pages`, with a clearly marked placeholder title and the placeholder Markdown fields the tables require. + +Opus builds the frontend: + +- `app/(site)/(tools)/(saas)/churn-rate-converter/page.tsx`, `layout.tsx`, `churn-rate-converter.tsx`, and `churn-rate-converter.module.css`. +- Placeholder `content/intro.mdx`, `content/usage.mdx`, and `content/article.mdx`, so the route renders until the writing session replaces them. +- Card art in `app/(site)/(tools)/(saas)/churn-rate-converter/art/`, registered as `churn-rate-converter` in `lib/card-art.ts`. + +No shared file changes are planned. + +### Catalog placement + +Primary and only catalog: `/saas-tools`. A founder there is comparing churn, LTV, and revenue calculators. This converter is the step between the customer churn calculator, which produces a rate for one period, and the LTV calculator, which needs that rate on a monthly or annual basis. Place it directly after `/customer-churn-calculator` and confirm the full order during integration. No secondary catalog serves this task. + +Card `toolSpot` art key: `churn-rate-converter`. + +- Visual takeaway: customers lost add up more slowly each period, because each period starts with fewer customers. Multiplying one period's loss overstates the total. +- Focal subject: five filled bars of customers lost so far, rising along flattening steps. +- Supporting relationship: a dashed outline on top of the last bar, reaching the height a straight multiply would give. +- Tone: calm and explanatory. No danger red or health colors. +- Keep it wordless. No numbers, `%`, `× 12`, calendar pages, clocks, money, or a generic calculator icon. It reads at card size in both themes, and it looks different from the `customer-churn-calculator` card, which shows one group with one departing mark. +- Built as the `churnCompounding` `toolSpot` motif. A straight dashed line across shrinking bars was tried first and dropped, because the line had to run through the bars. + +## Interface + +### Flow + +- Question: "What is my churn rate as a monthly, quarterly, or annual rate?" +- Minimum inputs: the rate and the period it covers. Both always visible. Control mode is `single`. +- Primary action: `Convert churn rate`. Reset is the quiet secondary action. +- Lead answer: the equivalent rate for the lead period, usually annual churn. +- Supporting order: lead result, then the table of all three periods, then one year out of 100 customers, then average lifetime, then the assumption note. + +The workbench reads in one pass: + +```text +Churn rate [ 5 % ] Period ( Monthly | Quarterly | Annual ) +[Convert churn rate] [Reset] +----------------------------------------------------------------- +Annual churn +45.96% +Equivalent of 5% monthly churn. + +Period Churn Retention Multiplying instead +Monthly 5.00% (entered) 95.00% — +Quarterly 14.26% 85.74% 15.00% +Annual 45.96% 54.04% 60.00% + +Out of 100 customers, over a year +[█████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░] +About 46 leave · About 54 remain + +Average lifetime 20 months (model estimate) + +Assumes the same share of remaining customers leaves every month. +``` + +The interface words above are working labels. The writing session sets the final words. + +The lead value appears again as one table row. That's deliberate: the table is the full conversion, and it holds the linear comparison for every period. Nothing else repeats. The 100-customer view repeats annual churn as counts, which is the reading most founders want and the relationship the bar draws. + +### Components + +Existing shared components: + +| Use | Component | Import | +| --- | --- | --- | +| Rate field, with a `%` suffix and `inputMode="decimal"` | `UiTextField` | `@/app/ui/controls` | +| Period, three visible choices | `UiChoiceGroup` | `@/app/ui/controls` | +| Convert and Reset | `UiButton`, `UiActionRow` | `@/app/ui/controls`, `@/app/ui/display` | +| Changed-input and reset announcements, unexpected failure | `UiStatus` | `@/app/ui/display` | +| Lead result | `UiPrimaryResult` | `@/app/ui/display` | +| Three-period table | `UiDataTable`, right-aligned number columns | `@/app/ui/display` | +| One year out of 100 customers | `UiAllocationBar`, `accent` palette, lost as the accent segment | `@/app/ui/display` | +| Average lifetime | `UiKeyValueList`, one row | `@/app/ui/display` | +| Page shell and header | `ToolPageShell`, `ToolPageHeader`, `ToolPageArticle`, `PageCanvas` | as in `customer-churn-calculator/page.tsx` | + +The period uses visible choices, not `UiSelect`, because mixing up a quarterly and a monthly rate is the most likely error, and the doc says not to hide three important choices in a dropdown. That rules out `UiQuantityInput` too. + +New or improved shared components: none. Every region fits an existing component. `UiAllocationBar`'s `formatValue` gets only the value, but that's enough: the two segments add up to 100, so a value of 0 or 100 can be matched to its flags without knowing the segment. + +Tool-specific code, all in `churn-rate-converter.tsx`: + +- `ChurnRateConverter`, the client workbench, following the `customer-churn-calculator.tsx` structure: form state, submit, clear on edit, reset, focus handling. +- Small format helpers: `formatPercent(ChurnPercent)` (`Under 0.01%`, `Over 99.99%`, or two decimals), `formatLinear`, `formatLifetime` (drop `.0`, use the source period name, singular at exactly 1.0), and `formatOf100`. + +### Display rules + +- Period names: `Monthly`, `Quarterly`, `Annual` in the choice group and table. `month`, `quarter`, `year` in lifetime and the assumption note. +- Lead label: `Annual churn` or `Monthly churn`. Detail: `Equivalent of {rate} {period} churn.` A rate at exactly 100% or 0% still shows normally. +- Source row: its churn cell adds a quiet `(entered)` tag. Its linear cell shows `—` with an accessible label such as `Not applicable`. +- Linear column header names the mistake. Values over 100% show as they are, such as `120.00%`. Don't style them as errors. The header and the article explain them. +- 100-customer legend: `About 46 leave`, `About 54 remain`. For exact 0% and 100% source rates, drop "About": `0 leave`, `100 remain`, or `100 leave`, `0 remain`. With `lostUnderOne`: `Fewer than 1 leaves`. With `remainingUnderOne`: `Fewer than 1 remains`. A zero segment keeps its legend entry and gets no bar width. +- Lifetime: `20 months`, `2.2 years`, `156.3 months`, `10,000 years`, `1 quarter`. At 0%: `Not estimable at 0% churn`. The row label says it's a model estimate. +- Assumption note, below the results in `--text-small`: `Assumes the same share of remaining customers leaves every {source period}.` +- Annual-plan note, only when the source period is `year`, beside the assumption note: `A monthly equivalent doesn't mean annual-plan customers can cancel every month.` Keep both as plain result text, not `Callout` or `Warning`, which are editorial blocks. +- No tone colors on any result. Every `UiPrimaryResult` uses `neutral`. + +### States + +| State | Behavior | +| --- | --- | +| Initial | Empty rate field, `Monthly` selected, Convert enabled after hydration, no result region. | +| Valid | Result region below one divider. Focus moves to the lead result. | +| Invalid | One message under the rate field, from the engine. Focus moves to the rate field. Any old result is cleared. The raw input stays. A `period` error can't happen through the interface; if it does, show it under the choice group. | +| Edited after a result | Result clears. `UiStatus` says `Inputs changed. Convert again.` | +| Reset | Rate empty, `Monthly`, no errors or results. `UiStatus` says `Converter reset.` Focus on the rate field. | +| Unexpected failure | The engine shouldn't throw. If it does, catch it, clear the result, and show one `UiStatus` with `danger` tone: `The rate couldn't be converted. Try again.` Keep the input. | +| Long values | The longest values are `Over 99.99%`, `1199.88%`, `10,000 quarters`, and `Not estimable at 0% churn`. They must fit their cells at 320px without overflow. | +| Loading, empty, partial, retry, restored | Don't apply. The conversion is instant and local, and there's no URL state. | + +### Responsive and accessible behavior + +- Desktop: the rate field and period choices share one row, about 1:2. Convert and Reset sit below them. Results stack at the workbench's inner measure, which follows the `customer-churn-calculator` CSS: 66rem max, one divider above the results. +- Mobile, under 760px: rate field, then period choices, then the actions, then results, in that order. The three choices stay on one row if they fit at 320px, otherwise they wrap as the shared choice row already does. `UiDataTable` becomes labeled rows on small screens. Check that the `(entered)` tag and the `—` cell still read correctly there. +- Keyboard: rate field, period radios (arrow keys move within the group), Convert, Reset. Enter in the rate field submits. +- Focus: lead result after a valid submit, rate field after an invalid submit or reset. +- Announcements: field errors through the field's error text. Changed-input, reset, and failure messages through `UiStatus`. The lead `output` is focused, so its label and value are read. +- Both themes: the allocation bar accent and the table must keep contrast in light and dark. + +### Checks for the frontend round + +- Example 1 (5% monthly) shows 45.96% annual, 54.04% retention, 60.00% linear, about 46 of 100, and 20 months. +- Example 3 (20% annual) shows 1.84% monthly as the lead and the annual-plan note. +- `-1` shows the `belowZero` message under the field with no result. `101` shows `aboveHundred`. +- `0` shows `Not estimable at 0% churn`. `0.01` annual shows `Under 0.01%`. +- Changing the period after a result clears it. +- Desktop and mobile widths, both themes. The URL stays clean. + +## Content + +The writing session fills this section with `skills/stack127/tools/stack127-tool-writing/SKILL.md`. Page copy uses existing components only. + +Notes for the writing session from planning: + +- Title and lead should use the "monthly to annual churn rate calculator" wording, per research. +- An optional reference table of common monthly rates and their annual equivalents fits the article as a Markdown table. These values come from the same 60-digit calculation as the examples: 1% → 11.36%, 2% → 21.53%, 3% → 30.62%, 5% → 45.96%, 7% → 58.14%, 10% → 71.76%, 15% → 85.78%. +- The article explains customer churn versus gross and net revenue churn, and why negative net churn can't go into this tool. + +### Search intent and title + +Used once: 5% monthly showed 45.96% annual churn as the lead, 14.26% quarterly, 60.00% in the shortcut column, about 46 of 100 leaving in a year, and 20 months average lifetime. The likely misreads: taking 45.96% as a measured yearly rate, reading the shortcut column as a second valid answer, and comparing the 20-month lifetime with a quarterly or annual figure. + +The searcher has one customer churn rate and needs it for another period, most often monthly to annual, for a plan, an investor update, or a benchmark. The doubt underneath is "can I multiply by 12?" Top results go one way only, accept impossible input, grade the rate without a source, and present the annual figure as measured. This page answers both directions, shows the shortcut beside the right figure, and says plainly that the result is a model. + +- Title: `Monthly to annual churn rate calculator: convert churn without multiplying by 12` +- SEO title: `Monthly to annual churn rate calculator (quarterly too)` +- SEO description: `Convert customer churn between monthly, quarterly, and annual rates, in either direction. See why 5% a month is 45.96% a year, not 60%.` +- Card: `Churn rate converter`, `Turn a monthly, quarterly, or annual churn rate into the other periods, without multiplying by 12.` + +### Reader questions + +| Question | Answered in | +| --- | --- | +| How do I convert monthly churn to annual? And back? | Tool result; article formula | +| Can I multiply by 12? | Shortcut column; article section with the step-by-step loss | +| What are common monthly rates per year? | Article reference table | +| Which rate and period do I enter? | Field labels; usage | +| Is annualized churn my real annual churn? Annual plans? | Result notes; article | +| How long does a customer stay? | Lifetime row; article, with the CLV link | +| Revenue churn or negative churn? | `belowZero` message; article boundary section | +| How do I compare with a benchmark? | Article: match period and churn type first. No grades. | + +### Outline + +- Intro: what the tool converts, in the searched wording, and what to have ready. +- Usage: what to enter (customer churn, and the period it was measured over, which is the slip that matters), then one example box for 5% monthly and how to read the shortcut column, the "about" counts, and lifetime. +- Article, with question headings: + 1. How do you convert monthly churn to annual churn? Formula box, general form, and the three powers. + 2. Why not multiply monthly churn by 12? Shrinking base, numbers for the first months, 10% × 12 = 120%, and ÷ 12 understating. + 3. Monthly to annual churn table. Markdown table of 1, 2, 3, 5, 7, 10, 15% with the × 12 figure. + 4. Is annualized churn your real annual churn? Model versus measured, with the annual-plan case. + 5. How long does an average customer stay? 1 ÷ churn, in the entered period, model estimate; link to CLV. + 6. Can you convert revenue churn or negative churn? Customer churn only; negative net churn is revenue growth. + 7. How do you compare your churn with a benchmark? Same period, same type; no grades. + +### Facts, limits, and sources + +- Compounding formula and the 5% → about 46% example: [ChartMogul customer churn report](https://help.chartmogul.com/article/203-chart-customer-churn-rate), [ChartMogul cohort retention](https://help.chartmogul.com/article/159-cohort-customer-retention). +- Revenue churn and negative net churn: [Stripe revenue churn guide](https://stripe.com/resources/more/revenue-churn-101-how-to-calculate-it-and-why-it-matters). +- Annual renewal versus monthly decisions: [ChartMogul billing cycles](https://chartmogul.com/saas-metrics/billing-cycles/). +- All numbers on the page come from the engine and the spec's known examples. +- Avoid: grades, benchmark bands, "measured" wording for converted rates, lifetime in a period other than the entered one, lifetime or customer counts for revenue churn. +- Left out on purpose: rounding, `Under 0.01%` and `Over 99.99%`, input format rules (field messages handle them), the discrete-model lifetime mismatch across periods (the tool avoids it by showing one period), and count-based churn (`/customer-churn-calculator`). + +### Next step + +Link `/customer-churn-calculator` for counts, `/customer-lifetime-value-calculator` for lifetime value, `/revenue-churn-calculator` and `/net-revenue-retention-calculator` for revenue. Link `/annual-plan-discount-calculator` from the annual-plan section and `/mrr-goal-calculator` from the lifetime section, since both take churn in a set period. diff --git a/content_docs/free-trial-conversion-calculator/research.md b/content_docs/free-trial-conversion-calculator/research.md new file mode 100644 index 00000000..b7692826 --- /dev/null +++ b/content_docs/free-trial-conversion-calculator/research.md @@ -0,0 +1,225 @@ +# Research: Free trial conversion calculator + +Recommended slug: `/free-trial-conversion-calculator`. Live search wording includes "free trial conversion rate calculator" and "free trial conversion calculator." This slug is concise and keeps "free trial," the clearest modifier. Use "free trial to paid" in the page title or description to make the outcome clear. `/trial-conversion-rate-calculator` is less specific; `/trial-to-paid-conversion-calculator` is precise but drops "free trial." This is a wording recommendation from current results, not search-volume data. + +Route: `/free-trial-conversion-calculator` +Checked: 2026-10-10 +Competitors used: 5 + +## Scope + +The tool should answer: "Of the people who started a free trial or entered a free product in a defined period, how many became paying customers within a defined follow-up window, and what monthly recurring revenue did those conversions add?" Trial and freemium starts are different cohorts and need separate modes. No-card and card-required trials also need separate labels because their populations and conversion rates differ. + +The core rate is cohort based. A trial that starts in August and pays in September belongs to the August cohort. Counting August conversions divided by August trial starts can miss that conversion, or mix in people whose trials started in July. Users need to choose an event that means "paid" in their billing system and give every cohort the same time to mature. A result for an incomplete cohort is provisional. + +The scope does not include churn, retention, revenue recognition, CAC, full funnel attribution, trial activation, or a forecast of future conversions. The MRR result is new recurring run rate from converted accounts, not cash collected, first-year revenue, or MRR retained after churn. + +Searches tried: + +- "free trial conversion calculator SaaS trial to paid calculator" +- "trial conversion rate calculator cohort free trial to paid SaaS" +- "freemium free to paid conversion rate calculator SaaS" +- "trial to paid conversion rate calculator benchmarks SaaS" +- "free trial conversion rate SaaS" +- "trial to paid conversion benchmark primary study" +- "trial to paid conversion calculator free SaaS tools" +- "site:reddit.com/r/SaaS trial to paid cohort" + +Search results repeatedly surfaced ChartMogul, Growth Unhinged/ProductLed, First Page Sage, Appcues, Userpilot, Wall Street Prep, Calclet, Agents for Data, TrialMonitor, VentriTools, Technotize, and Recurly. The local SearXNG search endpoint was unavailable, so the search fallback was the web search connector and a live Google results page. No search-volume tool was available. Search wording above is evidence for the slug, not a volume estimate. + +### Existing stack127 tools + +- `/mrr-calculator` takes paid plan counts, prices, and billing intervals, then normalizes them into MRR and ARR. It does not connect subscriptions to a trial-start cohort. +- `/customer-acquisition-cost-calculator` divides acquisition spend by new first-time paying customers. Its guidance excludes trials, signups, and renewals from that customer count. It does not calculate trial conversion or cohort MRR. +- `/saas-metrics-calculator` analyzes a completed month of MRR changes, retention, and growth, with optional CAC and burn inputs. It does not separate trial or free signup cohorts. + +This tool adds early-funnel measurement: trial-start and freemium-start cohorts, a named follow-up window, separate no-card and card-required trial context, and the normalized MRR added by converted customers. It complements the existing tools; it does not replace their CAC or whole-business MRR calculations. + +## Search intent + +| Query | What the searcher wants | Answer they need | What current results miss | +| --- | --- | --- | --- | +| free trial conversion calculator | A quick trial-to-paid rate | A clear numerator, denominator, and result | Many calculators use "started this period" without saying whether conversions came from those same starts. | +| trial conversion rate calculator cohort | A rate that accounts for trial length and delayed purchases | Start cohort, paid event, and equal follow-up window | Most calculators give a single period rate without cohort maturity or lag. | +| opt-in vs opt-out trial conversion rate | A way to compare no-card and card-required trials | Plain definitions and model-specific context | Results often use "opt-in/opt-out" inconsistently or compare rates without noting the different friction and populations. | +| free-to-paid conversion rate calculator | A freemium conversion rate | A separate free-account cohort and measurement window | Some pages treat freemium as a trial despite having no fixed end date. | +| trial conversion MRR calculator | The recurring revenue associated with newly paid accounts | Monthly normalized recurring value, including annual plans | Competitors may call hypothetical unconverted value "lost revenue," or omit billing interval normalization. | +| SaaS trial conversion rate benchmarks | A sense of whether a result is plausible | Dated evidence split by business and trial model | Generic "good" bands often lack primary sources, sample details, or a clear definition of conversion. | + +Google's live "People also ask" results included "What is the typical conversion rate for SaaS trials?", "What is a good trial conversion rate?", and "Is 2.5% a good conversion rate?" The same page also showed "What is the 3 3 2 2 2 rule of SaaS?", which is not useful for this calculator. Related searches included "Free trial conversion rate SaaS calculator," "Conversion rate optimization for SaaS trial to paid funnel," "SaaS conversion rate benchmarks," "Activation rate SaaS," and "Average freemium conversion rate." + +The result that needs the most care is "What is a good trial conversion rate?" No single answer applies across no-card trials, card-required trials, freemium, B2B and consumer products, trial lengths, or sales-assisted funnels. The page should make the rate computable first and show benchmark figures only with their population and date. + +## User problems + +| Problem people repeat | Evidence link | What it means for the tool | +| --- | --- | --- | +| Founders debate whether requiring a card improves paid conversion enough to offset fewer or less engaged signups. | [SaaS thread on card required upfront or not](https://www.reddit.com/r/SaaS/comments/1s4dsd5/free_trial_credit_card_required_upfront_or_not/) | Make no-card and card-required modes explicit. Do not present the higher card-required rate as proof that one model is better; signups, activation, refunds, and retention also matter. | +| A builder moved from a card wall to an email-only seven-day trial and wants paid outcomes rather than signup counts, while weighing server costs. | [SaaS thread on freemium versus card upfront](https://www.reddit.com/r/SaaS/comments/1t6nx4x/freemium_vs_cc_upfront_for_a_7day_trial_just/) | Distinguish free starts from paid conversions, and avoid implying that signup volume alone measures success. A cost or unit-economics model is outside this tool. | +| Founders ask what trial-to-paid rate others see, while acknowledging that product, audience, activation, and time to convert vary. | [SaaS thread asking for trial conversion rates](https://www.reddit.com/r/SaaS/comments/1ro9ggt/whats_your_trail_to_conversion_rate/) | Show the cohort and measurement window so a user can compare like with like. Explain that one percentage without segment and timing details is hard to interpret. | +| An adjacent consumer app owner reported a 20.7% blended rate versus 44% for four fully observed weekly cohorts, plus missed analytics events and small-cohort caveats. | [AppBusiness thread on fixing trial conversion tracking](https://www.reddit.com/r/AppBusiness/comments/1upohjk/fixed_my_trial_conversion_21_44_and_just_passed/) | Label cohort maturity and tracking completeness. This is an anecdote from an iOS fitness app, not a SaaS benchmark. | + +These forum posts are qualitative evidence of questions and measurement pain, not representative rate data. + +## Facts to get right + +| Fact | Primary source | How the tool should treat it | +| --- | --- | --- | +| Cohort rate is the converted share of trial starts from the same start cohort. A cohort can update as later conversions arrive. | [ChartMogul: Trial-to-paid conversion rate](https://help.chartmogul.com/article/219-chart-trial-to-paid-conversion-rate) | Use the same start cohort for numerator and denominator. Require or state a follow-up window, or compare cohorts at the same age. Mark immature cohorts provisional. Keep the customer/account/subscription unit consistent. | +| A rolling-window conversion KPI can use a different denominator and can exceed 100%. Stripe's Billing metric compares conversions in the last 30 days to trials ended in the last 30 days; the two groups need not be the same cohort. | [Stripe: Calculating trial conversion rate in Billing](https://support.stripe.com/questions/calculating-trial-conversion-rate-in-billing?locale=en-GB) | Do not silently call a rolling period rate a cohort conversion rate. Explain why it can differ from the start-cohort answer. | +| "Paid" depends on billing state. Stripe's subscription lifecycle moves a trialing subscription to active after trial end, and first invoice/payment status affects whether it is paid. | [Stripe Subscription object](https://docs.stripe.com/api/subscriptions/object) | Define the paid event. Do not count a card on file as a conversion. Users should align the event with their billing or analytics source. | +| Freemium conversion is a free-start cohort, not a time-limited trial cohort. The 2026 ChartMogul report defines free-to-paid as leads or free signups that become paying within six months. | [ChartMogul 2026 SaaS Conversion Report](https://chartmogul.com/reports/saas-conversion-report/) | Give freemium its own mode and state the chosen window. Do not compare its rate with trial rates without showing the difference in model and horizon. | +| MRR is recurring subscription value normalized to one month. Annual subscriptions are divided by 12; one-time fees are excluded. New Business MRR is recurring revenue from new paying customers. | [ChartMogul: MRR](https://chartmogul.com/saas-metrics/mrr/) | Sum normalized monthly recurring value for the converted cohort, or multiply conversions by a stated average monthly recurring amount. Label it new MRR/run rate; exclude one-time fees. | +| Recurly's trial chart can use trial start date and distinguishes subscribers from subscriptions. It separates payment failures and pending states from successful paid transitions. | [Recurly: Trial performance](https://docs.recurly.com/recurly-subscriptions/docs/trial-performance) | Be explicit about whether the unit is an account, subscriber, or subscription. Do not mix failed or pending payments into paid conversions. | + +Recommended calculation: + +`trial-to-paid rate = unique eligible accounts from a trial-start cohort that meet the named paid-event rule within the selected window / unique eligible accounts in that same trial-start cohort * 100` + +For freemium, replace trial starts with eligible free-account starts. Count unique entities consistently. Use a fixed elapsed window from cohort start, or compare cohorts only after the same elapsed time. A hypothetical example: 100 trials start in August; 6 pay during August and 20 have paid by day 30. The calendar-month ratio is 6%, while the 30-day start-cohort rate is 20%. The latter requires observing that cohort through day 30. + +For added MRR, sum each converted account's recurring subscription amount normalized to a month at the chosen conversion point. For an annual plan, divide its recurring annual amount by 12; exclude setup fees and other one-time charges. This is new-business MRR at conversion, not cash collected, recognized revenue, retained MRR, or money "lost" from people who did not convert. If annualized ARR is shown, describe it as run rate (MRR multiplied by 12), not first-year revenue. + +### Benchmark evidence and limits + +| Source and date | Population and method | Reported figures | How much to trust it | +| --- | --- | --- | --- | +| [ChartMogul, Growth Unhinged, and ProductLed, 2026 SaaS Conversion Report](https://chartmogul.com/reports/saas-conversion-report/) | Survey of 200 software products in January 2026, distributed through email, Slack, and social channels. Typical respondent was reported at $1m-$10m ARR and $50-$249 monthly ARPC. The report includes B2B, consumer, hybrid, and several free models. | "Good" (50th percentile) / "great" (75th percentile): freemium regular signup 3-5% / 8-12%; free trial with no card 4-6% / 10-15%; card-required trial 25-35% / 50-60%. Overall median was 8%. | Dated, sourced, and directly split by model, so it is the most relevant found. It is self-reported survey data with a convenience distribution, mixed product types, and smaller segment counts than the total 200. Use as directional segment evidence, not a universal target or a guarantee. | +| [First Page Sage, updated September 5, 2025](https://firstpagesage.com/seo-blog/saas-free-trial-conversion-rate-benchmarks/) | Agency data from 86 SaaS clients, Q1 2022 through Q3 2025; 71% B2B and 29% B2C. The page reports organic and paid acquisition separately. | No-card "opt-in" trial to paid: 18.2% organic, 17.4% paid. Card-upfront "opt-out": 48.8% organic, 51% paid. Freemium to paid: 2.6% organic, 2.8% paid. | A dated first-party agency dataset with a stated client count and mix. It is a selected agency client sample; weighting, cohort maturity, and exact attribution details are not fully exposed. The page defines conversion as one paid month, so rates do not establish retention or LTV. Treat as source-specific directional data. | +| [Recurly State of Subscriptions acquisition report, 2022 data](https://recurly.com/resources/report/state-of-subscriptions-acquisition/) | Recurly operational data from January-August 2022: 22 million free trials across 803 subscription sites. Broad subscription businesses, not only SaaS. Average trial length was 30 days. | Average site-level conversion reported as 37.5%; 87.4% required billing information. Sites requiring billing information averaged 39%, versus 12% without it; average trial lengths also differed (29 versus 39 days). | Large operational dataset with a clear period and site count. It is broad subscription data, not an indie SaaS benchmark, and trial models had different lengths. The report's counting unit can include multiple subscriptions per account. Use to show population and model effects, not to set a SaaS goal. | +| [Recurly 2024 State of Subscriptions release](https://recurly.com/newsroom/recurly-releases-its-2024-state-of-subscriptions-report/) | Recurly report about subscription businesses, reporting a 2023 median. The public summary does not show conversion-specific sample size or model split. | Median trial-to-paid conversion reported as 50%. | Primary source, but too broad and under-described in the summary for comparison with self-serve SaaS cohorts. Do not combine with the 2022 average or quote as a SaaS norm. | + +No primary, dated dataset found for indie SaaS split by trial model, trial length, and equal follow-up window. Common online bands such as "5-25%," "50-70%," and "2-5%" often lack a direct dataset, date, or consistent conversion definition. Mark the benchmark for that exact population as unknown. An Appcues 2022 report result appeared in search, but its linked PDF returned 404 when checked, so its figures were not used. + +## Competitor reviews + +### 1. Calclet + +URL: [Free trial conversion rate calculator](https://www.calclet.com/tools/free-trial-conversion-rate-calculator) + +- [Observed] Inputs are trial starts and paid conversions. The result shows conversion rate and remaining non-converted trials. The labels say "Trials started (period)" and "Converted to paid (same cohort)," which still leaves the actual cohort window to the user. +- [Observed] Correct: 1,180 starts and 156 paid returns 13.22% and 1,024 not converted. Edge: 0 starts and 0 paid returns 0.00%, though the rate is undefined with a zero denominator. Wrong: 0 starts and 1 paid returns 100% and -1 not converted; the input is not rejected. +- [Documented] Its accompanying article tells users to compare conversions from the same cohort. +- [Inferred] A user may trust an impossible 100% result or compare calendar periods without a fixed maturity window. +- [Unknown] No benchmark methodology or way to set a follow-up window was confirmed. + +```text +1,180 starts -> 156 paid -> 13.22%; 1,024 not converted +0 starts -> 1 paid -> 100%; -1 not converted +``` + +### 2. Agents for Data + +URL: [SaaS trial conversion rate](https://www.agentsfordata.com/saas/trial-conversion-rate) + +- [Observed] Inputs include trial users, paid conversions, trial length, and monthly ARPU. Default values of 500, 75, 14 days, and $50 produce a 15.0% rate and $3,750 monthly revenue from converted users. +- [Observed] Edge: 0 starts and 0 conversions shows 0.0%, "Below Average," and a 10/100 score. Wrong: 100 starts and 101 conversions returns 101.0%, -1 unconverted, and a 100/100 health score; there is no count validation. +- [Documented] The page labels its benchmarks "SaaS Industry Benchmarks 2024," but the benchmark section did not link a primary dataset during review. Its broad benchmark bands and model-specific figures also conflict: one section calls 15-25% healthy, while another gives opt-in average 15-25%; its card-required ranges are higher still. +- [Inferred] The score can give false confidence because its thresholds are not traceable and impossible counts are allowed. +- [Unknown] The source, sample, conversion horizon, and cohort method behind the benchmark score could not be confirmed. + +```text +500 starts -> 75 paid -> 15.0%; $50 ARPU -> $3,750 monthly revenue +100 starts -> 101 paid -> 101.0%; -1 unconverted; score 100/100 +``` + +### 3. TrialMonitor + +URL: [Trial conversion calculator](https://trialmonitor.io/tools/trial-conversion-calculator) + +- [Observed] Sliders ask for monthly trial volume, conversion rate, and monthly ARPU. Defaults of 50 starts, 10%, and $49 show 5 customers, $245 new MRR, and $2,940 ARR. The page also labels the 45 non-converters as $2,205 MRR and $26,460 ARR of "lost" revenue. +- [Observed] Edge: the minimum trial slider value is 10, so a zero-start case cannot be entered. Wrong: attempts below the 1% minimum rate are blocked by the slider. At 50 starts and 1%, the page rounds 0.5 expected customers to one customer and shows $25 MRR. +- [Documented] It labels converted-customer revenue as new MRR and annualizes it to ARR, and says its estimates exclude churn. +- [Inferred] "Lost revenue" may lead users to mistake the spend of hypothetical non-converters for revenue they would actually have earned. Rounding fractional expected customers to a whole person hides the estimate. +- [Unknown] No cohort window or benchmark source was confirmed. The slider-only inputs prevent testing other invalid numeric strings. + +```text +50 starts/mo -> 10% -> $49 monthly ARPU -> 5 customers; $245 new MRR; $2,940 ARR +50 starts/mo -> 1% -> rounds 0.5 expected customer to 1; $25 MRR +``` + +### 4. VentriTools + +URL: [Free trial conversion calculator](https://ventritools.com/tools/saas/free-trial-conversion-calculator/) + +- [Observed] Inputs are monthly trial starts, current rate, target rate, and average revenue per account. Defaults of 500 starts, 4% current, 6% target, and $79 ARPA show 20 versus 30 customers, $1,580 versus $2,370 MRR, $790 incremental MRR, and $9,480 12-month incremental ARR. +- [Observed] Edge: zero starts with current and target rates set to zero returns zero values. Wrong: entering -5 starts was accepted despite the visible minimum; with 10% current and 20% target it produced negative customer/revenue outputs and an inconsistent positive incremental result. +- [Documented] The page presents a rate-improvement scenario and says 2-8% is typical for opt-in trials, but no benchmark source was visible during review. +- [Inferred] It answers "what if I improve this rate?" rather than "what happened to this start cohort?" The user may also mistake annualized run rate for revenue earned in the next year. +- [Unknown] Cohort definition, benchmark provenance, and whether rates are rounded before revenue calculation were not confirmed. + +```text +500 starts -> 4% current / 6% target -> 20 / 30 customers; $1,580 / $2,370 MRR +-5 starts -> 10% / 20% -> negative customer and revenue values accepted +``` + +### 5. Technotize + +URL: [Trial to paid conversion calculator](https://technotize.io/tools/trial-to-paid-conversion-calculator) + +- [Observed] Inputs are trial starts and paid conversions. Correct: 100 starts and 18 conversions returns 18.00%. It also offers an inverse mode for estimating required trials. +- [Observed] Edge: 0 starts and 0 conversions prompts "Enter trials started above 0." Wrong: 100 starts and 101 conversions is rejected with "Paid conversions must be between 0 and trials." +- [Documented] Its article says freemium and trial conversion differ and recommends cohort measurement. It does not offer a separate freemium mode or MRR output. +- [Inferred] It validates the counts better than several competing calculators, but the two input counts alone cannot resolve cross-period lag or cohort maturity. +- [Unknown] No explicit follow-up window or primary source for its "opt-in often in teens, opt-out 40-60%" benchmark statement was found. + +```text +100 starts -> 18 paid -> 18.00% +100 starts -> 101 paid -> rejected: paid conversions must be between 0 and trials +``` + +## Comparison + +| Feature or answer | Calclet | Agents for Data | TrialMonitor | VentriTools | Technotize | What stack127 should do | +| --- | --- | --- | --- | --- | --- | --- | +| Core model | Starts and same-cohort paid count | Starts and paid count | Starts multiplied by rate | Monthly starts and current/target rates | Starts and paid count | Use a named start cohort and fixed elapsed window. | +| Freemium mode | No | No | No | No | Discusses it, no mode | Give freemium its own free-start cohort mode. | +| No-card/card-required context | No | Labels benchmark bands | No | Mentions opt-in benchmark | Article mentions models | Explain both in plain terms and keep the rates separate. | +| MRR from conversions | No | Revenue using ARPU | Yes, plus "lost" MRR | Current/target MRR | No | Normalize recurring prices and label new MRR; do not call non-converters lost revenue. | +| Cohort maturity or horizon | Article mentions same cohort | No confirmed horizon | No confirmed horizon | No confirmed horizon | No confirmed horizon | Show how later conversions and immature cohorts affect the result. | +| Invalid-count handling | Accepts impossible counts | Accepts conversions above starts | Slider constrains range | Accepted negative starts | Rejects conversions above starts | Reject negative counts and conversions greater than eligible starts. Define zero-denominator behavior as "not available." | +| Benchmark evidence | No verified data found | 2024 label without direct source; inconsistent bands | No verified data found | Unsourced 2-8% claim | Unsourced model ranges | Use dated, sourced figures with population, method, and caveats. | + +## Where competitors disagree + +- **Start cohort versus rolling period:** ChartMogul's cohort rate counts paid customers from the trial-start cohort, and the result may rise as the cohort matures. Stripe's Billing metric instead compares conversions and ended trials inside separate rolling 30-day windows; Stripe notes that this rolling measure can exceed 100%. Both can be valid for their stated purpose, but only the same-start-cohort formula answers the tool's cohort question. Do not call the Stripe rolling metric a cohort rate. +- **What counts as paid:** A paid event may mean an active paid subscription, a successful first invoice, or a subscription that has moved through a billing system's trial state. Stripe's lifecycle makes payment status relevant. The tool should ask the user to align the event to their billing source; a stored card is not itself paid revenue. +- **Benchmark levels:** ChartMogul's 2026 survey reports lower medians for no-card trials and freemium than for card-required trials. First Page Sage's client sample reports higher no-card trial rates. Recurly's broad subscription data shows another distribution. These populations, dates, acquisition sources, trial lengths, event definitions, and methods differ, so there is no defensible single "good SaaS rate" from these sources. +- **Model labels:** "Opt-in" and "opt-out" can be ambiguous. Use "no card required upfront" and "card required upfront; subscription starts unless canceled," then explain the model. Freemium should stay separate from a fixed-duration trial. +- **Revenue labels:** Some competitors describe the estimated spend of non-converters as lost revenue. That assumes every non-converter would have paid. Report only actual cohort conversions and their normalized recurring MRR; a hypothetical recovery scenario should be clearly labeled as a scenario. + +## How stack127 can win + +- Calculate a cohort rate from starts and conversions from the same cohort. Let users select a follow-up window and show whether the cohort has reached it. +- Keep trial-to-paid and freemium-to-paid as separate modes. For trials, distinguish no-card from card-required upfront in the explanation. Avoid using unexplained "opt-in" and "opt-out" labels alone. +- Define the paid event and entity counted. Include clear treatment for zero starts, negative numbers, conversions greater than starts, duplicate subscriptions, pending payments, and failed first invoices. +- Show a small worked example where a conversion in the following month belongs to the original trial-start cohort. Explain that recent cohorts can understate their eventual result. +- Calculate new MRR only for the converted accounts. Normalize annual billing to monthly value, exclude one-time fees, and state the timestamp used for the recurring amount. Do not call unconverted trial value "lost MRR." +- Put benchmark figures beside source, date, population, model, and method. Present survey percentiles and agency/client data as directional evidence, not a universal target. For an indie SaaS benchmark split by trial type and equal cohort age, say "unknown." +- State that trial length, sales assistance, customer type, acquisition channel, activation, and conversion horizon can change the comparison. Keep the tool focused on rate and new MRR; point to the existing CAC and MRR calculators for those separate jobs. + +## Blockers + +None for the calculation or initial page. No paid API, account, or maintained live dataset is required. The exact benchmark for indie SaaS by card model, trial length, and equal follow-up window remains unknown; use the dated sources above with their limits or omit a universal target. + +## Sources + +- [ChartMogul: Trial-to-paid conversion rate](https://help.chartmogul.com/article/219-chart-trial-to-paid-conversion-rate), checked 2026-10-10. +- [Stripe: Calculating trial conversion rate in Billing](https://support.stripe.com/questions/calculating-trial-conversion-rate-in-billing?locale=en-GB), checked 2026-10-10. +- [Stripe Subscription object](https://docs.stripe.com/api/subscriptions/object), checked 2026-10-10. +- [ChartMogul: MRR](https://chartmogul.com/saas-metrics/mrr/), checked 2026-10-10. +- [ChartMogul, Growth Unhinged, and ProductLed: 2026 SaaS Conversion Report](https://chartmogul.com/reports/saas-conversion-report/), checked 2026-10-10. +- [First Page Sage: SaaS free trial conversion rate benchmarks](https://firstpagesage.com/seo-blog/saas-free-trial-conversion-rate-benchmarks/), updated 2025-09-05, checked 2026-10-10. +- [Recurly: State of Subscriptions acquisition report](https://recurly.com/resources/report/state-of-subscriptions-acquisition/), 2022 data, checked 2026-10-10. +- [Recurly: 2024 State of Subscriptions report release](https://recurly.com/newsroom/recurly-releases-its-2024-state-of-subscriptions-report/), checked 2026-10-10. +- [Recurly: Trial performance](https://docs.recurly.com/recurly-subscriptions/docs/trial-performance), checked 2026-10-10. +- [Calclet calculator](https://www.calclet.com/tools/free-trial-conversion-rate-calculator), checked 2026-10-10. +- [Agents for Data calculator](https://www.agentsfordata.com/saas/trial-conversion-rate), checked 2026-10-10. +- [TrialMonitor calculator](https://trialmonitor.io/tools/trial-conversion-calculator), checked 2026-10-10. +- [VentriTools calculator](https://ventritools.com/tools/saas/free-trial-conversion-calculator/), checked 2026-10-10. +- [Technotize calculator](https://technotize.io/tools/trial-to-paid-conversion-calculator), checked 2026-10-10. +- [Reddit: card-required trial discussion](https://www.reddit.com/r/SaaS/comments/1s4dsd5/free_trial_credit_card_required_upfront_or_not/), checked 2026-10-10. +- [Reddit: freemium versus card upfront discussion](https://www.reddit.com/r/SaaS/comments/1t6nx4x/freemium_vs_cc_upfront_for_a_7day_trial_just/), checked 2026-10-10. +- [Reddit: trial conversion rate discussion](https://www.reddit.com/r/SaaS/comments/1ro9ggt/whats_your_trail_to_conversion_rate/), checked 2026-10-10. +- [Reddit: AppBusiness trial conversion tracking discussion](https://www.reddit.com/r/AppBusiness/comments/1upohjk/fixed_my_trial_conversion_21_44_and_just_passed/), checked 2026-10-10. +- [Appcues 2022 Benchmark Report PDF](https://try.appcues.com/hubfs/2022-Appcues-Benchmark-Report.pdf), returned 404 when checked 2026-10-10; figures not used. diff --git a/content_docs/free-trial-conversion-calculator/tool-spec.md b/content_docs/free-trial-conversion-calculator/tool-spec.md new file mode 100644 index 00000000..b677c155 --- /dev/null +++ b/content_docs/free-trial-conversion-calculator/tool-spec.md @@ -0,0 +1,476 @@ +# Free trial conversion calculator + +Route: `/free-trial-conversion-calculator` + +Research: [research.md](research.md) + +## Why people will choose this tool + +The searcher wants to know what share of the people who started a free trial, or signed up for a free plan, went on to pay. They also want to know if that number is any good. Usually they're deciding whether to change the trial: card or no card, length, or onboarding. + +Where current results fail them (see the research's competitor reviews): + +- They take "starts" and "paid" from any period. Nothing ties the paid count to the same start cohort or a fixed follow-up window, so a calendar-month ratio passes as a conversion rate. +- None of them tells you when a cohort is too young to judge. A recent cohort looks worse than it will end up. +- Most accept impossible counts. 0 starts with 1 paid gives 100%. 101 paid of 100 gives 101% and a perfect score. Negative starts are accepted. +- No-card trials, card-upfront trials, and free plans get mixed together or are labeled with bare "opt-in" and "opt-out". +- Revenue outputs call non-converters "lost revenue", round 0.5 expected customers up to a whole person, or skip annual plan normalization. +- Benchmarks are unsourced bands or a made-up health score. + +What this tool does better: + +1. It measures one start cohort against itself: starts, and paid within a named window counted from each person's own start. +2. It shows a visible **Complete** or **Provisional** state from the cohort's age, with the days left. +3. An optional list of cohorts compares months at the same age. The combined rate uses complete cohorts only. +4. It has three plain-word models: free trial with no card, free trial with a card upfront, and free plan. +5. It rejects impossible counts beside the field and never clamps. Zero starts gives "Not available", not 0% or 100%. +6. Optional new MRR uses one average monthly amount, or monthly and annual plans with annual ÷ 12. It leaves out one-time fees, shows ARR only as a run rate, and never mentions "lost" revenue. +7. Published figures for the chosen model come with source, date, population, and method. They're directional, with no score or grade, and the page says plainly that indie SaaS figures by model are unknown. + +## Product + +### Promise and scope + +- **User:** an indie hacker or solo SaaS founder with a free trial or free plan, reading counts from Stripe, Paddle, Lemon Squeezy, or their own database. +- **Job:** find the trial-to-paid (or free-to-paid) rate for one or more start cohorts, know whether each one is old enough to trust, and optionally see the new MRR those customers added. +- **Answer:** a cohort conversion rate with its window and maturity state. Optionally, a per-cohort table and new MRR with ARR run rate. + +In scope: + +- Three models: `trialNoCard`, `trialCardRequired`, `freemium`. The math is the same for all three. The model changes the count labels and the published figures shown. +- One follow-up window in days, shared by every cohort, so cohorts are compared at the same age. +- 1 to 12 cohorts. +- New MRR from paid customers, by one average monthly amount or a monthly and annual split. +- Published figures for the selected model, from the research's dated sources. + +Out of scope: forecasts or "what if the rate improves" scenarios, expected-customer estimates, churn, retention, CAC, LTV, cash collected, revenue recognition, activation, funnel attribution, rolling-window rates like Stripe's, CSV import, and saving or sharing state. Link out to `/mrr-calculator`, `/customer-acquisition-cost-calculator`, and `/customer-churn-calculator` for those jobs. + +### Technical contract + +**Definitions** + +- A cohort is a group of accounts that started a trial (or free plan) in one period, such as a calendar month. Each account counts once. +- Paid means the account's first payment succeeded within the window, counted from that account's own start. A card on file, a pending payment, or a failed first invoice isn't paid. The engine can't check this. It takes the count as given, and the interface and article explain it. +- The follow-up window `N` is a whole number of days. +- Cohort age is the number of days from the cohort's newest start to the day the paid count was taken. + +**Formulas**, all exact (bigint fractions, no floats): + +- Rate = paid ÷ starts × 100. +- Didn't pay within the window = starts − paid. Never label this "lost". +- Maturity: `complete` when age ≥ `N`. Otherwise `maturing`, with `daysLeft = N − age`. +- Combined rate, with one or more cohorts: + - If the complete cohorts have more than 0 starts in total, use only complete cohorts: `basis: "complete"`, `status: "complete"`. + - Otherwise use every cohort: `basis: "all"`. The status is `provisional` if any cohort is maturing, else `complete`. + - Combined rate = Σ paid ÷ Σ starts over the basis cohorts. It is not an average of the cohort rates. + - If Σ starts is 0, the rate is `notAvailable`. +- New MRR, when on. "Paid customers" means Σ paid across **all** listed cohorts, maturing ones included, because those payments already happened. + - `average`: MRR = paid customers × average monthly amount. + - `split`: monthly-plan customers + annual-plan customers must equal paid customers. MRR = monthly customers × average monthly price + annual customers × average annual price ÷ 12. + - ARR run rate = exact MRR × 12. Compute it from the exact MRR, not the rounded one. + +**Rounding** + +- Rates go to hundredths of a percent, half away from zero, through `roundExact` (`lib/saas-economics/unit-economics.ts`). Return `isRounded`. Return `roundedToZero` when paid > 0 but the rounded value is 0. The interface then shows `Under 0.01%`. +- Money is in cents, half away from zero, with `isRounded`. +- Counts are whole numbers and never rounded. The tool takes no fractional people and makes no expected-customer estimates. + +**Units and bounds** + +| Field | Parse | Range | +| --- | --- | --- | +| Starts, paid, monthly and annual customers | `parseWholeNumber` | 0 to 10,000,000 | +| Window | `parseWholeNumber` | 1 to 730 days | +| Cohort age | `parseWholeNumber` | 0 to 3,650 days | +| Money | `parseMoneyCents` | 0 to `SAAS_ECONOMICS_MAX_MONEY_CENTS` (1,000,000,000.00) | +| Cohort name | `parseOptionalLabel` | up to 40 characters, blank allowed | +| Currency | `isSaasEconomicsCurrency` | the listed currencies | + +Strict text: no signs, commas, spaces inside, exponents, or currency symbols. This matches the sibling engines. + +**Privacy and security:** the tool runs only in the browser. No network calls, no storage, and no URL state, because these are private business numbers. Labels render as text, never HTML. + +### Backend contract + +Same runtime rules as the other SaaS economics engines: strip-types safe, no enums or namespaces, explicit `.ts` imports, and no DOM, network, time, or randomness. Reuse `lib/saas-economics/shared.ts` (`parseWholeNumber`, `parseMoneyCents`, `parseOptionalLabel`, `isSaasEconomicsCurrency`, `SAAS_ECONOMICS_MAX_MONEY_CENTS`) and `lib/saas-economics/unit-economics.ts` (`exactFraction`, `addExact`, `multiplyExact`, `divideExact`, `roundExact`, `isWholeAtScale`) unchanged. No API route, script, dataset, or migration. + +#### `lib/free-trial-conversion-calculator/engine.ts` + +```ts +import { type SaasEconomicsCurrency } from "../saas-economics/shared.ts"; +import { type ExactFraction } from "../saas-economics/unit-economics.ts"; + +export const TRIAL_MODELS = ["trialNoCard", "trialCardRequired", "freemium"] as const; +export type TrialModel = (typeof TRIAL_MODELS)[number]; +export const TRIAL_MRR_METHODS = ["average", "split"] as const; +export type TrialMrrMethod = (typeof TRIAL_MRR_METHODS)[number]; +export const TRIAL_MAX_COHORTS = 12; + +export type TrialCohortInput = { label: string; starts: string; paid: string; ageDays: string }; +export type TrialCohortField = keyof TrialCohortInput; +export type FreeTrialConversionInput = { + model: string; + windowDays: string; + cohorts: TrialCohortInput[]; + includeMrr: boolean; + currency: string; // ignored when includeMrr is false + mrrMethod: string; // ignored when includeMrr is false + averageMonthly: string; // used by "average" + monthlyCustomers: string; // used by "split" + monthlyPrice: string; + annualCustomers: string; + annualPrice: string; +}; + +export type TrialFieldKey = + | "form" | "model" | "windowDays" | "cohorts" + | `cohorts.${number}.${TrialCohortField}` + | "currency" | "mrrMethod" | "averageMonthly" + | "monthlyCustomers" | "monthlyPrice" | "annualCustomers" | "annualPrice"; + +export type TrialConversionRate = + | { kind: "finite"; hundredths: bigint; exact: ExactFraction; isRounded: boolean; roundedToZero: boolean } + | { kind: "notAvailable"; reason: "noStarts" }; +// hundredths = percent × 100. 1322n means 13.22%. exact is the percent as a fraction. + +export type TrialMaturity = + | { status: "complete" } + | { status: "maturing"; daysLeft: bigint }; + +export type TrialCohortResult = { + index: number; // position in the input + label: string; // trimmed; "" when blank. The UI shows "Cohort 1" etc. + starts: bigint; + paid: bigint; + notPaid: bigint; // starts - paid + ageDays: bigint; + rate: TrialConversionRate; + maturity: TrialMaturity; +}; + +export type TrialCombined = { + basis: "complete" | "all"; + status: "complete" | "provisional"; + includedIndexes: number[]; // cohorts in the basis, in input order + excludedMaturing: number; // maturing cohorts left out (0 when basis is "all") + starts: bigint; // over the basis + paid: bigint; + notPaid: bigint; + rate: TrialConversionRate; +}; + +export type TrialNewMrr = { + currency: SaasEconomicsCurrency; + method: TrialMrrMethod; + paidCustomers: bigint; // across all cohorts + averageMonthlyCents: bigint | null; // "average" only + monthlyPlan: { customers: bigint; priceCents: bigint; mrrCents: bigint } | null; // "split" only, exact + annualPlan: { customers: bigint; annualPriceCents: bigint; mrrCents: bigint; isRounded: boolean } | null; // "split" only, ÷12 rounded + mrrCents: bigint; // rounded from the exact total + mrrIsRounded: boolean; + runRateArrCents: bigint; // exact MRR × 12, rounded (always whole in practice) +}; + +export type FreeTrialConversionValues = { + model: TrialModel; + windowDays: bigint; + cohorts: TrialCohortResult[]; + combined: TrialCombined; + newMrr: TrialNewMrr | null; +}; + +export type FreeTrialConversionErrors = Partial>; + +export type FreeTrialConversionCalculation = + | { status: "valid"; values: FreeTrialConversionValues; errors: Record } + | { status: "invalid"; values: null; errors: FreeTrialConversionErrors }; + +export function calculateFreeTrialConversion(input: unknown): FreeTrialConversionCalculation; +``` + +The engine collects every error in one pass and never throws for user input. A non-object input, a non-array `cohorts`, a non-object cohort, or a non-boolean `includeMrr` gives `errors.form`. MRR fields are checked only when `includeMrr` is true, and only the active method's fields. + +Error messages. The engine owns these strings, and the writing pass may reword them: + +| Case | Key | Message | +| --- | --- | --- | +| Bad payload | `form` | `Something in the form is out of date. Reset and try again.` | +| Unknown model | `model` | `Choose one of the listed options.` | +| Window blank | `windowDays` | `Enter the follow-up window in days.` | +| Window bad or out of range | `windowDays` | `Enter whole days from 1 to 730.` | +| 0 cohorts | `cohorts` | `Add at least one cohort.` | +| More than 12 cohorts | `cohorts` | `Use up to 12 cohorts.` | +| Count starts with `-` | the field | `Counts can't be negative.` | +| Count blank | the field | `Enter a count. Use 0 if there were none.` | +| Count other bad format, such as `2.5`, `1,180`, `1e3` | the field | `Use a whole number with no commas or decimals.` | +| Count above 10,000,000 | the field | `Enter a number up to 10,000,000.` | +| Paid > starts | `cohorts.i.paid` | `Paid can't be more than starts. Count only accounts from this cohort.` | +| Age blank | `cohorts.i.ageDays` | `Enter the days since the newest start.` | +| Age bad or out of range | `cohorts.i.ageDays` | `Enter whole days from 0 to 3,650.` | +| Label too long or has control characters | `cohorts.i.label` | `Use a name up to 40 characters on one line.` | +| Unknown currency or method | that key | `Choose one of the listed options.` | +| Money blank | the field | `Enter an amount.` | +| Money bad format | the field | `Use digits with up to two decimals. No signs, commas, or currency symbols.` | +| Money above max | the field | `Enter an amount up to 1,000,000,000.00.` | +| Split doesn't add up | `monthlyCustomers` | `Monthly and annual customers must add up to , the paid customers above.` For example, `…add up to 156, the paid customers above.` Only when every count parsed. | + +Negative numbers get their own message because the research shows competitors accepting them. Zero starts with zero paid is valid and gives `notAvailable`. Zero starts with paid > 0 is the "paid > starts" error. + +#### Published figures + +No engine involvement. The frontend keeps them as a typed constant in the route folder (see Ownership), copied from the research's benchmark table. They're display content, not calculation. + +### Known examples + +Calculated by hand. Use `trialNoCard` with MRR off unless stated. + +| Inputs | Expected | +| --- | --- | +| Window `30`. One cohort: starts `1180`, paid `156`, age `45` | Rate `1322n` (13.2203…%), `isRounded: true`. `notPaid: 1024n`. Maturity `complete`. Combined: the same rate, `basis: "complete"`, `status: "complete"`. (Calclet's 13.22%.) | +| As above, MRR on, USD, `average`, `49` | Paid customers `156n`. MRR `764400n` ($7,644.00), not rounded. ARR run rate `9172800n` ($91,728.00). | +| Window `30`. One cohort: starts `100`, paid `20`, age `30` | `2000n` (20.00%). Age = window gives `complete`. The article contrasts this with the 6% calendar-month ratio (6 of the 100 paid in August). The tool doesn't compute that ratio. | +| Window `14`. One cohort: starts `200`, paid `30`, age `10` | `1500n`. Maturity `maturing`, `daysLeft: 4n`. Combined `basis: "all"`, `status: "provisional"`. | +| Window `30`. Cohorts: Jul `400`/`60`/age `75`; Aug `500`/`70`/age `45`; Sep `450`/`30`/age `10` | Rows: `1500n`; `1400n`; `667n` (6.6667%) `maturing`, `daysLeft: 20n`. Combined: `basis: "complete"`, `includedIndexes: [0, 1]`, `excludedMaturing: 1`, 130 ÷ 900 gives `1444n` (14.444…%). Not the mean of the row rates (14.50%). | +| Same three cohorts, MRR on, `average`, `20` | Paid customers `160n` (Sep included). MRR `320000n`. | +| Window `30`. Cohorts `100`/`5`/age `5` and `200`/`10`/age `12` | Rows `500n`, `500n`, both maturing. Combined `basis: "all"`, `provisional`, `500n` (5.00%) from 15 ÷ 300. Corrected by coordinator after the backend check. | +| Window `30`. Cohorts `0`/`0`/age `40` and `100`/`10`/age `5` | Row 1 `notAvailable`, `complete`. The complete starts total is 0, so the basis is `all`: `provisional`, 10 ÷ 100 gives `1000n`. | +| One cohort `0`/`0`/age `40` | Valid. Rate `notAvailable`, shown as `Not available`. Combined `notAvailable`. | +| One cohort `3`/`1` and `3`/`2` (separate runs), age ≥ window | `3333n` and `6667n`, both `isRounded: true`. | +| One cohort `1`/`1` | `10000n` (100.00%), not rounded. | +| One cohort `2000000`/`1` | `0n`, `roundedToZero: true` (exact 0.00005%). Shown as `Under 0.01%`. | +| Paid `4` total. MRR `split`: monthly `3` at `10`, annual `1` at `100` | Monthly plan `3000n`. Annual plan `833n` (exact 833.33…), `isRounded: true`. MRR `3833n` ($38.33), `mrrIsRounded: true`. ARR run rate `46000n` ($460.00), from the exact MRR. Rounding first would give $459.96. | +| Paid `156`. MRR `split`: monthly `100`, annual `50` | Error on `monthlyCustomers`: `…must add up to 156…`. | +| Paid `0`. MRR `average` `49` | MRR `0n`, valid. | +| `freemium`. Window `180`. One cohort `5000`/`150`/age `200` | `300n` (3.00%), `complete`. Same math and only the model differs. | +| Maximums: starts `10000000`, paid `10000000`, window `730`, age `3650`; MRR `average` `1000000000` | `10000n`. MRR `1000000000000000000n` cents ($10,000,000,000,000,000.00). ARR run rate twelve times that. No overflow, because everything is bigint. | +| Invalid: starts `100`, paid `101` | Error on `cohorts.0.paid`. No values. (Agents for Data and Technotize case.) | +| Invalid: starts `0`, paid `1` | Error on `cohorts.0.paid`. (Calclet's 100% case.) | +| Invalid: starts `-5` | `Counts can't be negative.` (VentriTools case.) | +| Invalid: starts `2.5`, `1,180`, `1e3`, blank; window `0`, `731`, blank; age blank, `-1`; label of 41 characters; 0 cohorts; 13 cohorts; model `optIn`; MRR on with currency `JPY`, method `blend`, average `$49`, or annual price `-1` | A field-specific error for each, under the keys above. `status: "invalid"`. Several errors in one input all come back together. | +| MRR off, with every MRR field blank or garbage | Valid. MRR fields aren't checked. | + +### Ownership + +Sol builds the backend: + +- `lib/free-trial-conversion-calculator/engine.ts`. +- `tests/pages/free-trial-conversion-calculator/free-trial-conversion-calculator.test.mjs`, covering the examples above in the CAC test's style. +- The development tool-page preview row, with a clearly marked placeholder title, when the coordinator asks. + +Opus builds the frontend: + +- `app/(site)/(tools)/(saas)/free-trial-conversion-calculator/page.tsx`, `layout.tsx`, `free-trial-conversion-calculator.tsx`, `free-trial-conversion-calculator.module.css`, and `published-figures.ts`, the typed benchmark constant. +- Card art: `art/free-trial-conversion-calculator.spec.json`, `.svg`, and `.ts` through `$marker-art-draw`, a new `trialConversion` motif in `scripts/stack127/marker-art/figures.mjs`, and the key in `lib/card-art.ts`. Other agents may be editing these shared files, so check `git status` first and wait if they're touched. +- The page icon: lucide `Hourglass`. + +No URL-restore wrapper. Business counts and prices don't go in the URL. + +### Catalog placement + +Primary and only catalog: `/saas-tools`. A founder there is working through SaaS money and growth numbers. Trial conversion is the earliest funnel step before MRR and CAC, so put it next to `/customer-acquisition-cost-calculator` and `/mrr-calculator`. The coordinator sets the final order. No secondary catalog fits. There's no growth or marketing catalog, and creating one isn't needed for this tool. + +Catalog `toolSpot` key: `free-trial-conversion-calculator`. Motif name: `trialConversion`. + +- Takeaway: from a group that started together, a few paid before the window closed. +- Focal subject: a short horizontal time track with three or four small ticks. A dashed vertical gate line stands near its right end, for day N. Left of the gate, a loose cluster of five or six small hollow circles sits on the track: the starters. Just past the gate, two filled `@primary` circles: the ones who paid. +- One relationship: the gate. Paid means past the line in time, not moved by an arrow. +- Tone: calm and neutral, with accent only on the two paid circles. No currency signs, numbers, cards, funnels, arrows, hourglasses, calendars, or person glyphs. +- It must read differently from `customerChurn` (four person icons with an arrow out to one), `cohortRetention` (anchored bars), and `acquisitionCost` (person on a plinth). Wordless, and legible at card size in both themes. + +## Interface + +### Flow + +- Question: "Of the people who started in this cohort, what share paid within my window, and is the cohort old enough to trust?" +- Minimum input: model (preselected), window, and one cohort row with starts, paid, and age. +- Primary action: `Calculate conversion`. +- Lead answer: the combined conversion rate, with its Complete or Provisional state. +- Control mode: `single`. Every visible field drives the answer. `Add new MRR` is a visible checkbox that adds required fields to the main flow. The model choice changes labels and the published figures, not the math. That's documented here so the audit doesn't flag it as a no-op. + +Workbench order: + +1. `What people started`, a `UiChoiceGroup` with descriptions. These are plain words, never bare "opt-in/opt-out": + - `Free trial, no card`: `They try it without entering a card.` + - `Free trial, card upfront`: `They enter a card to start. The plan bills when the trial ends unless they cancel.` + - `Free plan`: `They sign up for a free plan with no end date.` +2. `Follow-up window`, a narrow `UiTextField` with suffix `days`. Helper: `Days each account gets to pay, counted from its own start.` +3. `Cohorts`, a group heading with one note under it: `Count each account once. Paid means the first payment went through. A card on file isn't a payment.` Then a `UiRepeatableList` with `Add cohort` (max 12). Each row is one line on desktop: + - `Cohort name`: optional, placeholder `Aug 2026`. + - `Trial starts`, or `Free signups` for the free plan model. + - `Paid within 30 days`. The number follows the window field and shows `Paid within window` while the window is blank. + - `Cohort age`, with a `days` suffix. A longer label wrapped and pushed the input out of line. + - The remove button. + + The age field gets one helper, on the first row only: `Days from the last start in this cohort to the day you counted.` Removing the only row clears its fields and keeps the row. +4. `Add new MRR from these customers`, a `UiCheckbox`. When on, below it in the main flow: + - `Currency`, a narrow `UiSelect`. + - `Price data`, a `UiChoiceGroup`: `One average amount` or `Monthly and annual plans`. + - For average: `Average monthly price` (currency suffix). Helper: `Recurring price only. Leave out setup and other one-time fees.` + - For split, two paired rows: `Monthly-plan customers` with `Average monthly price`, and `Annual-plan customers` with `Average annual price`. The same helper sits under the group. +5. `Calculate conversion` (primary) and a quiet `Reset`, in `UiActionRow`. +6. One divider, then the results. + +### Components + +Existing shared components: + +| Use | Component | +| --- | --- | +| Model, MRR method | `UiChoiceGroup` from `@/app/ui/controls` (option `description` carries the plain explanation) | +| Window, counts, age, name, money | `UiTextField` from `@/app/ui/controls`, with `inputMode="numeric"` for counts and days and `decimal` for money, `days` or currency-code `suffix`, and autocomplete, capitalization, and spellcheck off. `UiTextField`, not `UiNumberField`, so pasted text like `-5` or `1,180` reaches the strict parser and gets its message. | +| Cohort rows | `UiRepeatableList` from `@/app/ui/controls`, as on `/mrr-calculator` | +| Currency | `UiSelect` from `@/app/ui/controls` | +| MRR toggle | `UiCheckbox` from `@/app/ui/controls` | +| Actions | `UiActionRow` from `@/app/ui/display`, `UiButton` from `@/app/ui/controls` | +| Lead | `UiPrimaryResult` from `@/app/ui/display` | +| Maturity label | `UiStatusWord` from `@/app/ui/display`: `warning` tone for `Provisional`, `neutral` for `Complete` | +| Cohort table, published figures | `UiDataTable` from `@/app/ui/display` | +| New MRR facts | `UiKeyValueList` from `@/app/ui/display` | +| Change, reset, and failure messages | `UiStatus` from `@/app/ui/display` | + +**Multi-cohort decision: build it, with the shared list.** It fits existing components: `UiRepeatableList` for input rows and `UiDataTable` for output, the same pairing `/mrr-calculator` uses. It's the clearest way to show the research's core point, that a young cohort understates its final rate. It also lets the combined rate leave maturing cohorts out instead of quietly blending them in. One row is the default, so the quick single-cohort job costs nothing extra. + +**No chart.** With at most 12 rates, the table shows exact values side by side. A bar chart would draw maturing cohorts as if they were finished, and marking them differently would need a new chart feature for little gain. + +**No new or improved shared component.** The row grid, paired MRR rows, and figures note are route CSS. + +Tool-specific component, in the route file: + +- `FreeTrialConversionCalculator`: owns the raw form state, row IDs, error mapping by `TrialFieldKey`, focus, status, and result rendering. It calls only `calculateFreeTrialConversion`. It reads `published-figures.ts` for the selected model. + +### Result regions + +Each region answers a different question, and no formatted value repeats. + +1. **Lead.** `UiPrimaryResult`. + - Label: `Free trial to paid` (both trial models) or `Free plan to paid`. With several cohorts, add `, complete cohorts` when `basis` is `complete`. + - Value: `13.22%`, `Under 0.01%`, or `Not available` with no unit. + - Detail line 1, one cohort: `156 of 1,180 trial starts paid within 30 days. 1,024 didn't.` For several cohorts: `130 of 900 starts in 2 complete cohorts paid within 30 days.`, adding `1 cohort still maturing is left out.` when `excludedMaturing` > 0. For `notAvailable`: `No starts in this cohort, so there's no rate.` + - Detail line 2, maturity, starting with `UiStatusWord`: + - Complete: `Complete: every account had the full 30 days.` + - Provisional, one cohort: `Provisional: the newest starts have 4 more days. This rate can still rise.` + - Provisional, several cohorts with basis `all`: `Provisional: no cohort has had the full 30 days yet.` + - The tone stays `neutral`. No grade color on the number. +2. **Cohort table**, only with two or more cohorts. `UiDataTable` columns: `Cohort`, `Starts` (or `Signups`), `Paid`, `Rate`, `Status`, with numbers right-aligned. Status reads `Complete` or `20 days left`. Rows show in input order. A blank name shows `Cohort 1`. A rate of `notAvailable` shows `Not available`. +3. **New MRR**, when on. A small heading, `New MRR from these customers`, then a `UiKeyValueList`: + - `New MRR`: `$7,644.00`. + - `How`: `156 paid customers × $49.00`, or for split, `3 monthly × $10.00 + 1 annual × $100.00 ÷ 12`. + - `ARR run rate`: `$460.00` with `New MRR × 12. Not revenue earned this year.` + - When `mrrIsRounded`: `Rounded to the cent.` + - When a maturing cohort is listed: `Includes customers from cohorts still maturing.` +4. **Published figures for this model.** A small heading, then a `UiDataTable` with columns `Source`, `Who was measured`, `Reported`. Show only the rows for the selected model: + - ChartMogul, Growth Unhinged and ProductLed, January 2026 survey: all three models. + - First Page Sage, updated September 2025: all three. + - Recurly, 2022 data: both trial models. No free plan row. + - Leave out Recurly's 2024 50% median. The research marks it as too broad. + - Each source name links out. + - One note line under the table: `Directional only. Each source counts conversion its own way. No dated figure was found for indie SaaS by model and follow-up window.` + - The user's rate is never placed against these bands, scored, or colored. + +### States + +- **Initial.** Model `Free trial, no card`. Window blank. One blank cohort row. MRR off, with USD and `One average amount` preselected under it. No result. The published figures appear only with a result. +- **Invalid or incomplete.** Clear the old result and keep the inputs. Show one error under each invalid field. Put a list-level `cohorts` error in the list's `addDescription` slot as an error. Focus the first invalid field in reading order and add no error panel. All error fields stay visible: MRR fields only exist while the checkbox is on, and the engine only checks them then. +- **Valid.** Render the regions, focus the lead, and announce once through the focused output. +- **Zero starts.** Valid. The lead reads `Not available`. It isn't an error. +- **Provisional.** Valid. Same layout, with a warning-tone `Provisional` word and the days left. +- **Edited after a result.** Clear the result and announce once, politely: `Inputs changed. Calculate again.` Changing the model counts as an edit. Focus stays where the reader is. +- **Rows.** `Add cohort` focuses the new row's name field. At 12 rows the add button is disabled with `Up to 12 cohorts`. Removing a row focuses the next row, or `Add cohort`. Removing the only row clears it instead. +- **MRR toggle and method.** Turning it on adds fields in place, and focus stays on the checkbox. Turning it off or switching method keeps drafts. +- **Unexpected failure.** If the engine throws, keep the inputs, clear the result, and show one `UiStatus` error by the actions: `Couldn't calculate. Try again.` +- **Reset.** Restore the initial state, focus the model group, and announce `Form reset.` +- **Restored.** The clean URL never changes. A back-forward cache restore keeps the result until the next edit. +- **Long values.** `10,000,000` starts, `$10,000,000,000,000,000.00` MRR, and a 40-character cohort name fit their cells and leads without clipping. Names wrap in the table. +- **Loading, partial, and retry.** These don't apply. The calculation is synchronous and local. + +### Responsive and accessible behavior + +Desktop: + +- Shared workbench band with a measured inner width. +- The model choices sit in one row. The window field is narrow. +- Each cohort row is a five-column grid: name, starts, paid, age, remove. The remove button is in the fixed last column. Labels repeat per row, like the MRR calculator's plan rows. +- Paired MRR fields share a row. +- Results stack full width: lead, table, MRR, figures. + +Mobile (set breakpoint, as the sibling tools use): + +- Model choices stack. +- Each cohort row stacks its fields in one column, in tab order. In two columns, `Paid within 30 days` wrapped and misaligned its input. The remove button stays top-right. +- Paired MRR fields stack. Buttons go full width. +- `UiDataTable` turns into labeled rows. +- No page-level horizontal overflow. Grid children get `min-width: 0`. + +Keyboard and screen readers: + +- Tab order follows the visual order, row by row. +- Enter in any field submits. +- Every field has a visible label. Each row is a group named `Cohort 2` (or its name), so a screen reader announces "Cohort 2, Paid within 30 days". +- Errors are tied to their fields. +- One polite live region announces changes, resets, and failures. Results are announced through the focused lead only. +- The `Provisional` meaning is in words, never only in color. + +Check at desktop and mobile in both themes: + +- 1,180 / 156 with average MRR `49`. +- The three-cohort example. +- The provisional single cohort. +- 0 / 0. +- 100 / 101 and `-5` errors. +- The split MRR rounding example. +- 12 rows with long names. + +## Content + +Plan for the writing session. The engine was run with the three-cohort example (Jul 400/60/75, Aug 500/70/45, Sep 450/30/10, window 30, average price 29): lead 14.44%, complete cohorts, Sep left out with 20 days left, 160 paid customers, new MRR $4,640.00, ARR run rate $55,680.00. A reader might misread the lead as the average of the row rates, read the maturing Sep row as a drop, or read "didn't pay" as money missed. + +### Search intent and title + +The searcher wants their trial-to-paid rate, and to know if it's any good. Top results take two counts from any period, accept impossible counts, and quote unsourced "good" bands. This page ties both counts to one start cohort, says when a cohort is too young, and shows dated figures with who was measured. + +- Visible title: `Free trial conversion calculator: how many free trials turn into paid customers?` +- SEO title: `Free trial to paid conversion rate calculator` +- SEO description: names trial or free plan starts, a follow-up window, complete or provisional cohorts, and optional new MRR. No grade, no benchmark promise. +- Card: `Free trial conversion`, with the share of a trial cohort that paid in the window, and the new MRR. + +### Reader questions + +| Question | Where it's answered | +| --- | --- | +| What's my trial-to-paid rate? | Tool lead. Formula and the 6% versus 20% example in content. | +| Which counts do I enter, and from where? | Usage: cohort, paid, window, age. | +| Why does my newest month look low? | Tool `Provisional` line and table. Usage on reading the result. | +| What counts as paid? | Cohort note in the tool. Content section with the Stripe link. | +| Why doesn't this match Stripe's number? | Content: Stripe's rolling 30-day metric. | +| What's a good rate? | Content table of three dated sources by trial type, plus the in-tool figures for the chosen type. | +| Free plan or free trial? | Model choice descriptions. Content section on the longer window. | +| What MRR did they add? | Tool MRR group. Content on run rate versus revenue. | + +### Outline + +- **Intro.** Three sentences: what you get, what to have ready, the optional MRR. +- **Usage.** `What to enter`: trial type when unsure, the window, where the counts come from, cohort age with a date example, the MRR prices. `How to read the result`: complete and provisional, the combined rate, the cohort table, "didn't pay", new MRR. One `Example` with the three-cohort run. +- **Article.** `How is free trial conversion rate calculated?` (Formula, 6% vs 20% example). `What counts as a paid conversion?` (list). `Why doesn't this match Stripe's trial conversion rate?` `What is a good free trial conversion rate?` (table across types, sources list, note). `How is a free plan different from a free trial?` `Is new MRR the same as revenue?` with next steps. + +No chart and no marker art in the article. The card art is registered. + +### Left out on purpose + +- Input format, ranges, and the 12-cohort limit: field errors. +- Zero starts, `Under 0.01%`, cent rounding: result text and the spec. +- Every combined-rate fallback case: the result lines say which cohorts were used. +- Card versus no-card strategy advice, activation, CAC, churn: out of scope. Linked next steps instead. + +### Facts and limits + +- Formula and same-cohort rule: ChartMogul trial-to-paid help article. +- Paid event and trialing to active: Stripe Subscription object. Count each account once; Recurly separates subscribers from subscriptions. +- Stripe rolling 30-day rate can exceed 100%: Stripe support article. +- Benchmarks: ChartMogul, Growth Unhinged, and ProductLed (January 2026 survey, 200 products); First Page Sage (86 clients, 2022 to 2025, updated September 2025); Recurly (2022 data, 803 sites, 22 million trials). Leave out Recurly's 2024 50% median. +- Free plan six-month window: ChartMogul 2026 report. +- MRR normalization and one-time fees: ChartMogul MRR. +- Avoid: benchmarks as targets, grades, "lost" revenue, saying card-upfront is better, an indie SaaS benchmark (unknown). + +### Next steps + +`/mrr-calculator`, `/customer-acquisition-cost-calculator`, `/customer-churn-calculator`. diff --git a/content_docs/mrr-goal-calculator/research.md b/content_docs/mrr-goal-calculator/research.md new file mode 100644 index 00000000..c9ca0502 --- /dev/null +++ b/content_docs/mrr-goal-calculator/research.md @@ -0,0 +1,230 @@ +Slug recommendation: use `/mrr-goal-calculator`. Search results use "MRR goal calculator" for the broader job of turning a target into customers or a timeline. `/time-to-mrr-goal-calculator` leaves out the customer-count job, and `/10k-mrr-calculator` hardcodes one target. + +# Research: MRR goal calculator + +Route: `/mrr-goal-calculator` +Checked: 2026-10-10 +Competitors used: 5 + +## Scope + +A solo SaaS founder wants to make a revenue target concrete. At a known monthly price or ARPA, they need the paying-customer count. To estimate a timeline, they need current MRR and either a net monthly MRR growth rate or monthly new paying customers and customer churn. A traffic mode can translate the acquisition pace into visitors when the founder knows their funnel rates. + +Churn needs to be modeled when the acquisition input is gross new customers per month. With fixed additions `a` and monthly customer churn `c`, the account count tends toward `a/c`. A net MRR growth rate already includes churn when it comes from period-start and period-end MRR, so subtracting churn again would double-count losses. + +This adds a forward-looking goal and pace calculation. The existing [MRR calculator](../mrr-calculator/tool-spec.md) normalizes current plan revenue. The [ARPU / ARPA calculator](../arpu-arpa-calculator/tool-spec.md) measures revenue per user or account. The [customer churn calculator](../customer-churn-calculator/tool-spec.md) measures historical logo loss. The [break-even MRR calculator](../break-even-mrr-calculator/tool-spec.md) finds the MRR needed to cover entered costs. None answers how many customers or months it takes to reach a chosen MRR goal. The broader [SaaS metrics calculator](../saas-metrics-calculator/tool-spec.md) explicitly leaves goal dates and future forecasts out. + +It does not forecast cash collected, recognized revenue, price demand, business costs, or a founder's personal income. Current MRR and a flat monthly price are direct inputs. A customer-addition model also needs current paying accounts, or an estimate from current MRR divided by average ARPA, plus new paid accounts per month and logo churn. Traffic mode needs visitor-to-signup and signup-to-paid rates. These figures are not available to every early-stage founder. The forum posts are not a survey, so treat this input-availability judgment as an inference and keep each mode optional. + +Searches tried: + +- "MRR goal calculator" +- "time to MRR goal calculator SaaS" +- "how many customers to reach $10k MRR" +- "customers needed for MRR goal with churn" +- "traffic needed for $10k MRR" +- "SaaS MRR growth calculator" +- Google results in India also showed related searches for "SaaS MRR calculator", "recurring revenue calculator", "SaaS growth calculator", "SaaS revenue calculator", and "subscription calculator". + +Domains that appeared in these searches include buildgr.id, hitmrr.com, paydude.io, visitrack.app, showmrr.design, labs.shipquick.app, planmysaas.com, mrr-calculator.com, upgrowth.in, wobaka.com, shipcraft.app, and chartmogul.com. Search order and result features vary by location and date. + +## Search intent + +| Query | What the searcher wants | Answer they need | What current results miss | +| --- | --- | --- | --- | +| MRR goal calculator | Turn a chosen revenue milestone into a customer count and a plan to reach it. | Required active paying customers, the gap from today, and a time estimate under stated assumptions. | Some results give only the target count; others give a timeline without gross additions. | +| How many customers do I need for $10k MRR? | Convert $10,000 MRR and a plan price or average revenue per account into a count. | `ceil(10000 / monthly ARPA)`, with the price or ARPA shown beside the answer. | The simple count often assumes every account pays the same amount and says nothing about replacing churn. | +| How long until I reach $10k MRR? | Estimate months from current MRR and recent or planned growth. | A compound-growth scenario or a monthly customer model, with an unreachable state when assumptions cannot get there. | Search results sometimes give broad founder timelines. Those are not a forecast for this business. | +| MRR goal calculator with churn | Find how gross new paying customers translate into net customer growth. | A timeline that subtracts expected customer losses each month and says when the target is beyond the steady-state ceiling. | Static customer calculators omit churn. Some show one month's replacement count without showing how the requirement rises with the customer base. | +| Visitors needed for $10k MRR | Work backward from MRR through signups and paid conversion. | Required paid customers, signups, and visitors per month for a stated horizon, plus the assumptions used. | Most customer-count and time-to-goal tools stop before the funnel. | +| SaaS MRR growth calculator | Project revenue from an observed or assumed monthly MRR growth rate. | Months to target, the rate needed for a deadline, and the month-by-month values. | A percentage-growth model cannot start from $0 MRR. It also hides churn inside the net rate, so a separate churn rate should not be subtracted again. | + +Google's visible "People also ask" questions for "mrr goal calculator" were: + +- "How do you calculate MRR?" +- "What is an MRR goal?" +- "What is the formula for MRR?" +- "What is a good MRR growth rate?" + +For "how many customers to reach 10k mrr calculator", Google showed: + +- "What's a good MRR rate for startups?" +- "How do we calculate MRR?" +- "What is MRR growth rate?" +- "How to calculate average revenue per user?" + +The repeated MRR definition questions are not the main goal job. The page should answer them briefly, then give more space to customer counts, growth assumptions, churn replacement, and traffic math. A "good growth rate" answer needs a dated and segmented benchmark; a universal rate is not supported here. + +## User problems + +| Problem people repeat | Evidence link | What it means for the tool | +| --- | --- | --- | +| Founders ask how long it took to reach $10k MRR and what channel drove growth. Replies in one thread describe different timelines and channels. | [Founders: How long did it take you to hit $10k MRR?](https://www.reddit.com/r/SaaS/comments/1phxojv/founders_how_long_did_it_take_you_to_hit_10k_mrr/) | Show a scenario based on the user's own inputs. Do not present a community average as their likely date. | +| Founders mix the total customer count with the additional signups needed to replace churn. One post estimates 800 to 900 customers at $12 per month for $10k MRR, then says 50 or more extra customers per month may be needed for churn. | [Getting to $10K MRR isn't as easy as most indie developers think](https://www.reddit.com/r/SaaS/comments/1nrzgwu/getting_to_10k_mrr_isnt_as_easy_most_indie/) | Keep "customers at goal" separate from "gross new paying customers per month." At $12 per month, the count is 834 accounts before churn; churn replacement is an ongoing monthly acquisition need, not part of that one-time count. | +| Price changes the scale of the acquisition problem. A discussion compares a $25 product needing about 400 accounts with a $1,500 product needing about 7 accounts to reach $10k MRR. | [How difficult is it to get to $10k MRR?](https://www.reddit.com/r/SaaS/comments/1uomdn3/how_difficult_is_it_to_get_to_10k_mrr_and_for/) | Use the entered price or current ARPA. Do not assume that one customer count or sales motion fits every SaaS. | +| Some founders frame the target as income needed to leave a job, while others ask whether churn and business costs change the number. | [What is your target MRR for your SaaS to become your fulltime gig?](https://www.reddit.com/r/SaaS/comments/1d11iji/) | Keep this tool focused on gross recurring revenue and the customer goal. It must not imply that reaching MRR pays a founder's living costs or covers expenses; the break-even MRR tool handles entered costs. | +These are self-reported posts, not a representative survey or SaaS benchmark. They show the questions people ask, not typical times to goal. + +## Facts to get right + +| Fact | Primary source | How the tool should treat it | +| --- | --- | --- | +| MRR is recurring subscription revenue normalized to a month. Stripe says its MRR sum includes active and past-due subscriptions under its definition, excludes metered billing from its calculation, and is not GAAP revenue. | [Stripe: Understanding MRR and ARR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29) | Call the result an MRR run-rate scenario, not cash collected or accounting revenue. The input must follow one consistent billing-provider definition. | +| ARPA, also called ARPU or ARPC, is total MRR divided by the matching paying accounts for that period. | [ChartMogul: Average revenue per account](https://chartmogul.com/saas-metrics/arpa/) | Required accounts = `ceil(target MRR / monthly ARPA)`. A flat plan can use its monthly price. A tiered SaaS should use a current weighted average ARPA, so the count is an estimate if its plan mix changes. | +| Customer churn is the accounts lost during a period divided by accounts at the start. ChartMogul excludes accounts that join and churn, or churn and reactivate, in the same period in its stated formula. Revenue churn is a different metric. | [ChartMogul: Customer churn rate](https://chartmogul.com/saas-metrics/customer-churn/), [Stripe: Revenue churn](https://stripe.com/resources/more/revenue-churn-101-how-to-calculate-it-and-why-it-matters) | Use monthly customer churn with monthly new paying accounts. Do not feed revenue churn into a customer-count equation. State the model assumes churn applies to the existing customer base and a new account starts contributing at period end. | +| At constant net monthly MRR growth (g), `MRR_t = MRR_0 × (1 + g)^t`; for a positive starting MRR and growth rate, months = `ceil(log(target / current) / log(1 + g))`. This follows the discrete exponential growth model. | [OpenStax: Key equations for compound growth](https://openstax.org/books/algebra-and-trigonometry-2e/pages/6-key-equations), [OpenStax: Solving exponential growth equations](https://openstax.org/books/calculus-volume-1/pages/6-8-exponential-growth-and-decay), [ChartMogul: SaaS growth rate](https://chartmogul.com/blog/saas-growth-rate/) | Label this as net MRR growth. It already reflects the combined result of new business, expansion, contraction, and churn when calculated from period start and end MRR. Do not subtract churn a second time. A zero starting MRR makes the ratio undefined. If target is already met, time is zero. If growth is zero or negative and the goal is higher, the goal is not reached at that rate. | +| With `N_0` active accounts, `a` gross new accounts per month, and monthly customer churn `c`, a simple month-end recurrence is `N_(t+1) = N_t × (1 - c) + a`. For `0 < c < 1`, its steady state is `a / c`. The expanded form is `N_t = N_0(1-c)^t + a(1-(1-c)^t)/c`. This is a derived planning model, not a billing-system report. | [ChartMogul: Customer churn rate](https://chartmogul.com/saas-metrics/customer-churn/) for the rate definition. The recurrence and ceiling follow from the stated constant-input assumptions. | Convert a target into whole accounts first. If the target account count is at or above `a/c`, and the current count is below it, no finite month reaches it. If churn is zero, the ceiling does not apply. Keep the new-account rate, churn rate, and period all monthly. | +| A funnel's visitor-to-paid rate is the product of visitor-to-signup and signup-to-paid rates when the stages use compatible cohorts and periods. Required visitors per month = required gross new paying accounts per month divided by that product. | [Google Analytics: Analytics dimensions and metrics](https://support.google.com/analytics/table/13948007?hl=en) for user conversion metrics. The multiplication is derived from the two conditional conversion rates. | Ask for clearly defined rates over matching time windows. Zero at either conversion stage means no finite traffic volume reaches the target. Do not treat visits, unique visitors, signups, and paid accounts as interchangeable. | + +### Worked model check + +At $29 monthly ARPA, $10,000 MRR requires `ceil(10000 / 29) = 345` paying accounts. With 15 new paying accounts per month, no churn reaches 345 in 23 months. At 3% monthly customer churn, the steady-state ceiling is `15 / 0.03 = 500` accounts, and the recurrence first reaches 345 in month 39. A $15,000 goal needs 518 accounts at that ARPA, above the 500-account ceiling, so this scenario never reaches it. The numbers assume fixed ARPA and constant additions and churn each month. + +## Competitor reviews + +All five pages below were operated in the T3 browser on 2026-10-10. "Correct" means a hand-checkable example under the page's stated assumptions. The checks describe displayed behavior, not a review of source code or privacy claims. + +### 1. Paydude, How Many Customers Do I Need? + +URL: [paydude.io/resources/tools/how-many-customers-do-i-need-calculator](https://www.paydude.io/resources/tools/how-many-customers-do-i-need-calculator) + +- **Observed** The page asks for monthly price and a revenue goal, with a toggle for MRR or ARR. It shows a whole-customer count, equivalent MRR and ARR, an optional one-year pace, and milestone counts. +- **Observed** With $50 per month and a $10,000 MRR target, it showed 200 paying customers, $120,000 ARR, and 17 customers per month to reach the target in one year. +- **Observed** With a $0 MRR target and $100 monthly price, the lead count displayed as a dash, while MRR and ARR were both $0 and the daily pace was zero. With a negative $10 price, the main count was also a dash and milestone counts became zero. Neither case showed a field error. +- **Documented** The page says it assumes each customer pays the same price and nobody churns. It calls the count a floor and adds an explanation of churn separately. +- **Inferred** Its count and pace are useful static baselines, but the user must calculate present MRR, churn replacement, or visitors elsewhere. +- **Unknown** The page does not document how negative values or a zero target should be handled. + +```text +correct: $50/mo + $10,000 MRR -> 200 customers; 17/mo for a 12-month target +edge: $100/mo + $0 target -> no customer count; $0 MRR and $0 ARR +invalid: $-10/mo + $10,000 MRR -> no lead count; milestone counts show 0 +``` + +### 2. BuildGrid, MRR Goal Calculator + +URL: [buildgr.id/tools/mrr-goal-calculator](https://buildgr.id/tools/mrr-goal-calculator) + +- **Observed** It asks for target MRR, current MRR, average revenue per customer, new customers per month, and monthly customer churn. Results include required customers, additional customers, time at current pace, monthly acquisition needed for a 12-month target, first-month churn replacements, and a progress line. +- **Observed** With a $1,000 target, $0 current MRR, $50 ARPA, 10 new accounts per month, and 0% churn, it showed 20 accounts at goal, 20 additional accounts, and 2 months. +- **Observed** At the same target with 100% monthly churn and 10 new accounts per month, it showed "Not reachable." The 12-month plan required 20 new accounts per month. Typing ARPA 0 changed the input to its minimum of 1 without an error. Typing 101% churn left it at the maximum of 100%. +- **Documented** The page says it compounds churn month by month and describes the inputs as staying in the browser. I did not audit network traffic. +- **Inferred** This is the closest one-page match for the non-traffic task. Its current-customer estimate is derived from current MRR and ARPA, not an entered account count. +- **Unknown** The visible explanation does not define whether signups can churn during their acquisition month. + +```text +correct: $1,000 goal + $0 current + $50 ARPA + 10 new/mo + 0% churn -> 20 accounts; 2 months +edge: 20 target accounts + 10 new/mo + 100% churn -> Not reachable +invalid: ARPA 0 -> input silently becomes 1; churn 101% -> input silently becomes 100% +``` + +### 3. HitMRR, Time to MRR Goal + +URL: [hitmrr.com/tools/time-to-mrr-goal](https://www.hitmrr.com/tools/time-to-mrr-goal) + +- **Observed** It asks for current MRR, target MRR, and monthly growth rate. It shows months, arrival month, a projection chart, and the growth rate needed for a month deadline. +- **Observed** With current MRR of $1,000, a $10,000 goal, and 10% monthly growth, it showed 25 months. Its month 24 point was $9,850, which confirms the target is reached in month 25. +- **Observed** With current MRR at $0 and a positive growth rate, it showed "Never at this rate" and no arrival month. Entering current MRR as -$100 was accepted by the number field, despite its `min=0` attribute, and also produced "Never at this rate." +- **Documented** Its FAQ gives `MRR next month = MRR this month × (1 + growth rate)` and rounds the logarithmic months-to-goal formula up. +- **Inferred** The percentage mode is useful when the founder knows net MRR growth but does not know customer acquisition and logo churn separately. The page's total-MRR multiplier acts as a net rate. +- **Unknown** There is no field for price, customer additions, or churn, so the calculator cannot explain which of those caused the assumed growth. + +```text +correct: $1,000 current + $10,000 goal + 10% growth/mo -> 25 months +edge: $0 current + $10,000 goal + 10% growth/mo -> Never at this rate +invalid: -$100 current + $10,000 goal + 10% growth/mo -> accepted; Never at this rate +``` + +### 4. VisitTrack, Traffic to Revenue Calculator + +URL: [visitrack.app/tools/traffic-to-revenue-calculator](https://visitrack.app/tools/traffic-to-revenue-calculator) + +- **Observed** It asks for target MRR, months, price per paying customer per month, optional current MRR, visitor-to-signup conversion, signup-to-paid conversion, and optional monthly customer churn. Results include paying customers, new customers per month, signups, visitors per month, total period traffic, and traffic needed to hold the target. +- **Observed** Its example used $10,000 MRR, $29 per month, 12 months, 3% visitor-to-signup, 15% signup-to-paid, and 4% monthly churn. It showed 345 paying accounts, 36 new accounts per month, 238 signups per month, 7,919 visitors per month, 95,020 total visitors, and 3,067 monthly visitors to hold the target. +- **Observed** Setting churn to 0% reduced the requirement to 29 new accounts and 6,389 visitors per month. Setting visitor-to-signup conversion to 0% removed the numeric outputs and showed: "Can't calculate yet. With a 0% conversion rate no amount of traffic reaches the target." +- **Documented** The page says inputs stay in the browser and that the URL updates as values change so a visitor can bookmark or share them. I did not independently audit network traffic. +- **Inferred** This is the most complete traffic-mode example in the search set. It depends on users having funnel rates and uses one flat ARPA for the whole goal. +- **Unknown** The visible text does not explain how it aligns visitors and conversions by cohort when a signup converts to paid later. + +```text +correct: $10,000 goal + $29 ARPA + 12 months + 3% signup + 15% paid + 4% churn -> 7,919 visitors/mo +edge: same inputs with 0% churn -> 6,389 visitors/mo +invalid: 0% visitor-to-signup conversion -> clear no-finite-traffic message +``` + +### 5. ShowMRR, How Many Customers Do I Need? + +URL: [showmrr.design/calculators/customers-needed](https://www.showmrr.design/calculators/customers-needed) + +- **Observed** This is a ramen-profitability calculator rather than a gross MRR goal calculator. It asks for monthly income goal, price, payment fee percentage, monthly cost per account, and monthly churn. It shows required accounts, churn replacements, gross MRR needed, and counts by several price points. +- **Observed** With a $1,000 income goal, $10 price, no fees, no per-account cost, and no churn, it showed 100 paying accounts and zero new accounts needed for churn. When cost equaled price, it showed no count and said price must exceed fees and costs. +- **Observed** A negative $100 monthly goal was accepted. The lead result stayed blank, but several rows in the price comparison table displayed negative customer counts. +- **Documented** The page runs in the browser and presents its customer count after payment fees and per-account costs. +- **Inferred** Its fee and cost adjustment answers a different question from gross MRR attainment. It can still help someone who means personal income when they say "$10k goal." +- **Unknown** It does not project a date to goal or visitors needed. + +```text +correct: $1,000 income + $10 price + 0% fees + $0 cost + 0% churn -> 100 customers +edge: $10 price + $10 cost -> no finite customer count +invalid: -$100 income goal -> negative counts appear in price table +``` + +## Comparison + +| Feature or answer | Paydude | BuildGrid | HitMRR | VisitTrack | ShowMRR | What stack127 should do | +| --- | --- | --- | --- | --- | --- | --- | +| Main inputs | Price, goal, MRR/ARR | Goal, current MRR, ARPA, additions, churn | Current MRR, goal, growth rate | Goal, horizon, ARPA, current MRR, funnel, churn | Income goal, price, fees, cost, churn | Make the MRR goal and current MRR distinct from optional planning inputs. | +| Customer count | Static count | Required and additional accounts | None | Required paying accounts | After fees and account costs | Round required accounts up. Explain that a tiered plan uses average ARPA. | +| Growth over time | Optional pace without churn | Adds accounts with monthly churn | Compounds monthly MRR growth | Solves a monthly traffic pace with churn | None | Keep net MRR growth and gross new accounts as different assumptions. | +| Churn | Excluded, called a floor | Modeled as customer churn | Included only if reflected in the entered MRR growth rate | Customer churn raises acquisition and traffic needs | Replacements to hold the customer count | Model churn for a customer-addition timeline and show the `new accounts / churn rate` ceiling. Do not apply logo churn to a net MRR growth rate again. | +| Traffic | None | None | None | Visitor-to-signup and signup-to-paid rates | None | Treat traffic as an optional extension and show each step, not one opaque visit count. | +| Zero or impossible goal | Zero target leaves count blank | High churn can be unreachable | Zero start MRR cannot grow by a percentage | Zero conversion has an explicit message | Contribution at or below zero returns no finite count | Use explicit zero, undefined, and unreachable states. | +| Input errors | Negative price accepted | Invalid numeric values silently clamp to field bounds | Negative starting MRR accepted | Zero conversion handled clearly | Negative goal creates negative comparison counts | Reject invalid values or show a direct field message. Never present negative customer counts. | + +## Where competitors disagree + +- A static customer count can omit churn if it is clearly presented as the number of active accounts needed at the target. It cannot answer the timeline or gross acquisition pace. Paydude labels its result a no-churn floor, which is a fair limitation. +- A constant percentage growth model should use net MRR growth measured from period start and end. That already includes the combined effect of new MRR, expansion, contraction, and churn. Adding customer churn on top would count some losses twice. A fixed gross-new-account model needs customer churn because its acquisition input is before losses. +- Customer churn and revenue churn are not interchangeable. Use logo churn with account counts and use net MRR growth with MRR. A SaaS with large price tiers, expansion, or downgrades may have a different revenue path from its customer-count path. +- With `a` new paid accounts per month and churn `c`, the steady-state account count is `a/c` under the recurrence in Facts to get right. If target accounts equal that value, the path approaches the target but does not reach it in a finite month from below. A calculator should call this unreachable instead of returning an arbitrarily large date. +- At $29 ARPA, a $10,000 MRR goal needs 345 accounts, while $10,000 ARR would need only 29 accounts at the same monthly price. Keep MRR and ARR goal periods explicit. +- Some tools accept negative numeric input or silently clamp it. A negative starting MRR or a negative number of customers is not a meaningful forecast input. Zero current MRR is meaningful, but percentage growth from zero is undefined. + +## How stack127 can win + +- Answer the exact "$10k MRR" customer-count question with the entered price or monthly ARPA and round up to a whole account. +- Add current MRR so the output can show the amount of MRR and paying accounts still needed. Do not infer an exact actual account count from MRR divided by average ARPA without labeling it as an estimate. +- Let a founder with countable new sales enter new paying accounts per month and monthly customer churn. Show lost accounts, net change, the month goal is first crossed, and the acquisition ceiling. Mark targets at or above the ceiling as unreachable. +- Offer a separate net MRR growth-rate calculation for founders who know their recent overall rate but do not have reliable new-account and churn data. State that this rate already includes revenue gains and losses from all customer movements. +- Offer traffic planning only when a user has the conversion rates. Work through visitors, signups or trials, and paid accounts; show period and monthly totals. Do not assume a visitor count or conversion benchmark. +- Say which inputs are actuals and which are plans. Current MRR, monthly price or ARPA, new paid accounts, churn, visitor-to-signup rate, and signup-to-paid rate are useful inputs people may have. Keep the rates optional by task; no single founder will know every funnel and churn figure. +- Make the zero-MRR case usable with account additions or traffic. For percentage growth, say that a zero base has no defined growth ratio. +- Explain that customer-count timelines assume a stable monthly ARPA, fixed gross account additions, and a fixed monthly logo churn rate. Keep plan changes, expansion, downgrades, annual cash collection, business costs, and personal income outside that simple model. +- Link naturally to the current MRR, ARPA, churn, and break-even tools. This tool calculates a goal and route to it, while those tools help define today's numbers or a different cost target. +- Avoid universal claims about how long SaaS products take to reach $10k MRR. A scenario result is conditional on its entered assumptions. + +## Blockers + +None. The three calculation modes can use values entered by the visitor. No paid API, maintained dataset, account, background job, or external approval is needed. Traffic results depend on the visitor's own conversion data and do not require an external benchmark. + +## Sources + +- [Stripe: Understanding MRR and ARR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29), checked 2026-10-10. +- [ChartMogul: Average revenue per account](https://chartmogul.com/saas-metrics/arpa/), checked 2026-10-10. +- [ChartMogul: Customer churn rate](https://chartmogul.com/saas-metrics/customer-churn/), checked 2026-10-10. +- [Stripe: Revenue churn](https://stripe.com/resources/more/revenue-churn-101-how-to-calculate-it-and-why-it-matters), checked 2026-10-10. +- [OpenStax: Key equations for compound growth](https://openstax.org/books/algebra-and-trigonometry-2e/pages/6-key-equations), checked 2026-10-10. +- [OpenStax: Solving exponential growth equations](https://openstax.org/books/calculus-volume-1/pages/6-8-exponential-growth-and-decay), checked 2026-10-10. +- [ChartMogul: SaaS growth rate](https://chartmogul.com/blog/saas-growth-rate/), checked 2026-10-10. +- [Google Analytics: Analytics dimensions and metrics](https://support.google.com/analytics/table/13948007?hl=en), checked 2026-10-10. +- [Google results for "mrr goal calculator"](https://www.google.com/search?q=mrr+goal+calculator), PAA and related searches checked 2026-10-10. +- [Google results for "how many customers to reach 10k mrr calculator"](https://www.google.com/search?q=how+many+customers+to+reach+10k+mrr+calculator), PAA checked 2026-10-10. +- [Founders: How long did it take you to hit $10k MRR?](https://www.reddit.com/r/SaaS/comments/1phxojv/founders_how_long_did_it_take_you_to_hit_10k_mrr/), checked 2026-10-10. +- [Getting to $10K MRR isn't as easy as most indie developers think](https://www.reddit.com/r/SaaS/comments/1nrzgwu/getting_to_10k_mrr_isnt_as_easy_most_indie/), checked 2026-10-10. +- [How difficult is it to get to $10k MRR?](https://www.reddit.com/r/SaaS/comments/1uomdn3/how_difficult_is_it_to_get_to_10k_mrr_and_for/), checked 2026-10-10. +- [What is your target MRR for your SaaS to become your fulltime gig?](https://www.reddit.com/r/SaaS/comments/1d11iji/), checked 2026-10-10. +- [Paydude calculator](https://www.paydude.io/resources/tools/how-many-customers-do-i-need-calculator), checked 2026-10-10. +- [BuildGrid calculator](https://buildgr.id/tools/mrr-goal-calculator), checked 2026-10-10. +- [HitMRR calculator](https://www.hitmrr.com/tools/time-to-mrr-goal), checked 2026-10-10. +- [VisitTrack calculator](https://visitrack.app/tools/traffic-to-revenue-calculator), checked 2026-10-10. +- [ShowMRR calculator](https://www.showmrr.design/calculators/customers-needed), checked 2026-10-10. diff --git a/content_docs/mrr-goal-calculator/tool-spec.md b/content_docs/mrr-goal-calculator/tool-spec.md new file mode 100644 index 00000000..695f0c83 --- /dev/null +++ b/content_docs/mrr-goal-calculator/tool-spec.md @@ -0,0 +1,676 @@ +# MRR goal calculator + +Route: `/mrr-goal-calculator` + +Research: [research.md](research.md) + +## Why people will choose this tool + +The searcher has a target, usually "$10k MRR", and wants it made concrete: how many paying customers that is at their price, and how long it takes at the pace they can manage. Some also want the traffic that pace needs. + +Where the current top results fail them: + +- Each tool answers one slice. Paydude gives a no-churn count, HitMRR gives a growth-rate timeline only, VisitTrack covers traffic but not a date, and BuildGrid has no traffic. The founder moves numbers between three tabs. +- Churn is either left out, or mixed into the one-time customer count. Forum posts add "50 more a month for churn" to the total, which mixes customers at goal with monthly acquisition. +- A goal at or above the `new customers ÷ churn` ceiling gets a huge date or a vague "not reachable" with no reason. +- Bad input passes quietly. BuildGrid clamps ARPA 0 to 1 and churn 101% to 100%. HitMRR accepts negative MRR. ShowMRR shows negative customer counts. +- Growth from $0 MRR shows "Never at this rate", as if the rate were the problem. +- MRR and ARR targets blur. $10k ARR at $29 is 29 customers, not 345. + +What this tool does better: + +1. It always answers the count first: customers needed = goal ÷ monthly ARPA, rounded up, with the goal period (MRR or ARR) stated in the answer. +2. One timeline section, two methods. New customers a month plus monthly churn, or net MRR growth rate. Each uses its own formula, and churn is never subtracted from a net rate. +3. Each method can solve either way: when you reach the goal at a pace, or the pace you need for a deadline. +4. It keeps three numbers apart: customers at goal, new customers a month, and customers lost each month at goal. +5. It names the ceiling. When the goal is at or above `new ÷ churn`, it says the goal is out of reach at this pace, shows where the count levels off, and says what pace would work. +6. An optional traffic step turns the monthly pace into signups and visitors a month, one stage at a time. +7. It rejects bad input beside the field, never clamps, and never shows a negative count. Growth from $0 gets a specific message that points to the customer method. +8. No benchmarks and no "typical time to $10k". Every date depends only on the numbers entered. + +## Product + +### Promise and scope + +The primary user is an indie hacker or solo SaaS founder with a revenue target. They know their price or average revenue per account, and maybe their current customers, new customers a month, churn, or recent MRR growth. + +The tool gives them the paying customers the goal needs. Optionally it adds a month the goal is reached, or the pace a deadline needs, and the traffic that pace takes. + +Launch scope: + +- One currency, used as a label only. +- A goal amount with a period: per month (MRR) or per year (ARR). +- Monthly ARPA. This is the plan price for one flat plan, or the average for tiered plans. +- An optional timeline, by `customers` (current paying customers, new customers a month, monthly customer churn) or by `growth` (current MRR, net monthly MRR growth). Each solves for `date` or `pace`. +- Optional traffic: visitor-to-signup and signup-to-paid rates. Customer method only. + +Exclusions: + +- Price tiers, plan-mix changes, expansion, and downgrades in the customer method. It uses one ARPA throughout. +- Revenue churn in the customer method. It takes logo churn only. +- Using a churn rate and a growth rate together. +- Ramps, seasonality, or pace that changes month to month. +- Calendar dates. Results are month numbers from today, so the engine has no clock. +- Costs, profit, founder income, cash collected, recognized revenue, taxes, fees. The break-even MRR tool handles costs. +- Benchmarks, "good growth rate" grades, community averages. +- URL sharing, export, saved scenarios. + +### Technical contract + +**Fields and units.** All money is in the selected currency. ARPA, pace, churn, and growth are monthly. + +| Input | Accepted value | Notes | +| --- | --- | --- | +| Currency | `USD`, `EUR`, `GBP`, `INR`, `CAD`, `AUD`. Default `USD`. | Formatting only. | +| Goal amount | Money above `0`, at most `1,000,000,000.00`. | Required. | +| Goal period | `month` (MRR) or `year` (ARR). Default `month`. | A yearly goal counts as amount ÷ 12 per month, kept exact. | +| Monthly ARPA | Money above `0`, at most `1,000,000,000.00`. | Required. | +| Timeline method | `customers` or `growth`. Default `customers`. | | +| Solve for | `date` or `pace`. Default `date`. | Applies to the active method. | +| Current paying customers | Whole number `0` to `1,000,000,000`. | Customer method. `0` is valid. | +| New paying customers a month | Whole number `0` to `1,000,000,000`. | Customer method, `date` only. Gross additions, before churn. | +| Monthly customer churn | Percent `0` to `100`, up to two decimals. | Customer method, both solve modes. Logo churn. | +| Current MRR | Money `0` to `1,000,000,000.00`, but `0` is an error in this method. | Growth method. | +| Net monthly MRR growth | Percent `-100` to `1000`, up to two decimals. A leading `-` is allowed. | Growth method, `date` only. | +| Deadline | Whole number of months, `1` to `600`. | Either method, `pace` only. One shared field. | +| Visitor-to-signup rate | Percent `0` to `100`, up to two decimals. | Traffic, customer method only. | +| Signup-to-paid rate | Percent `0` to `100`, up to two decimals. | Traffic, customer method only. | + +**Parsing.** Use `lib/saas-economics/shared.ts`: `parseMoneyCents`, `parsePercentHundredths`, `parseWholeNumber`, `divideCeil`, `divideHalfAwayFromZero`, and `isSaasEconomicsCurrency`. Trim outer whitespace. Reject signs (except growth), commas, currency symbols, `%`, exponents, interior spaces, and a third decimal. Whole numbers take digits only. Blank is never zero. + +**Active fields.** Only the active method's fields are read, and only those its solve mode needs: + +| Method / solve | Fields read | +| --- | --- | +| `customers` / `date` | current customers, new customers a month, churn | +| `customers` / `pace` | current customers, churn, deadline | +| `growth` / `date` | current MRR, growth | +| `growth` / `pace` | current MRR, deadline | + +Inactive fields keep their drafts in the form, and the engine ignores them. + +**Optional groups.** The timeline and traffic are all-or-nothing groups. + +- If every field the active timeline reads is blank, the timeline is skipped. That's a valid partial result, not an error. +- If any of them has a value, every one is required, and a blank gets its own field error. +- Traffic works the same way with its two rates. It is read only when the method is `customers`. +- If traffic has a value while the timeline is skipped, the error is `traffic`. + +**Notation.** `d = 10000`. + +| Symbol | Meaning | +| --- | --- | +| `G` | Goal in cents | +| `A` | ARPA in cents | +| `N0` | Current customers | +| `a` | New customers a month | +| `h` | Churn in hundredths of a percent, so `c = h / d` | +| `M0` | Current MRR in cents | +| `g` | Growth in hundredths of a percent | +| `T` | Deadline in months | +| `v` | Visitor-to-signup rate in hundredths | +| `s` | Signup-to-paid rate in hundredths | + +Keep everything exact in `bigint`. No floating point, logarithms, or `Math.pow` anywhere in the engine. + +**Goal.** + +- Monthly goal, exact, in twelfths of a cent: `G12 = 12 × G` for `month`, and `G` for `year`. +- `monthlyGoalCents = divideHalfAwayFromZero(G12, 12)`. `monthlyGoalIsRounded` is true when `G12 % 12 ≠ 0`. +- `annualGoalCents = G12` for both periods. Twelve times the monthly goal is always whole cents. +- `customersNeeded R = divideCeil(G12, 12 × A)`. Always at least 1. +- `mrrAtCustomersNeededCents = R × A`. This can be a little above the goal. + +**Month convention.** + +- Month `0` is today, the values entered. +- Month `t` is the state at the end of the `t`-th month from now. +- In the customer method, churn applies to the customers at the start of each month. New customers join at month end and don't churn in the month they join: + + `N(t+1) = N(t) × (1 − c) + a` + + Closed form, for `h > 0`: + + `N(t) = [a × d × (d^t − q^t) + N0 × h × q^t] ÷ (h × d^t)`, where `q = d − h`. + + For `h = 0`: `N(t) = N0 + a × t`. + + `N(t)` is fractional, an expected value. Compute `d^t` and `q^t` incrementally, not with repeated exponentiation. +- In the growth method: + + `MRR(t) = M0 × ((d + g) ÷ d)^t` + +- "Reached in month `t`" means the first `t` from `0` to `600` where `N(t) ≥ R`, or `12 × MRR(t) ≥ G12`. +- The horizon cap is `MRR_GOAL_MAX_MONTHS = 600`, which is 50 years. The engine never looks past month 600. + +Comparisons stay exact: + +- Customers: `a·d·(d^t − q^t) + N0·h·q^t ≥ R·h·d^t`. +- Growth: `12·M0·(d+g)^t ≥ G12·d^t`. + +**Customer method, `date`.** + +- `remainingCustomers = max(0, R − N0)`. +- When `h > 0`, the ceiling is `a × d ÷ h` accounts, exact. Return `ceilingFloor = floor(a·d / h)` and `ceilingIsWhole`. +- When `h > 0`, the monthly loss at goal is `lostPerMonthAtGoal = divideCeil(R × h, d)`. It's `0` when `h = 0`. This is the pace that just holds the goal. + +Outcome, checked in this order: + +1. `N0 ≥ R`: `alreadyThere`, month `0`. + - If `h > 0` and `N0 × h > a × d`, the count is shrinking. Return `fallsBelowInMonth`, the first `t ≤ 600` with `N(t) < R`, or `null` if none. + - If `N0 × h ≤ a × d`, or `h = 0`, it holds or grows, and `fallsBelowInMonth` is `null`. +2. `h = 0` and `a = 0`: `unreachable`, reason `noNewCustomers`. +3. `h > 0` and `R × h ≥ a × d`: `unreachable`, reason `ceiling`. The count levels off at `a ÷ c`, which is at or below the goal. + - Coordinator decision after the backend check: at `c = 100%` (`h = d`), `N(t) = a` for every t ≥ 1, so a goal with `R ≤ a` is `reached` in month 1 and is not unreachable. The `R × h = a × d` case is unreachable only when `c < 100%`. + - `paceToReach = floor(R × h / d) + 1` is the smallest whole pace whose ceiling is above `R`. It only says the goal becomes reachable at that pace, not when. +4. Otherwise, the first `t ≤ 600` with `N(t) ≥ R` gives `reached` with `month: t`. If there's none, the outcome is `beyondHorizon`. + +**Customer method, `pace`.** Find the smallest whole `a` with `N(T) ≥ R`: + +- `h = 0`: `requiredPace = max(0, divideCeil(R − N0, T))`. +- `h > 0`: `requiredPace = max(0, divideCeil((R·d^T − N0·q^T) × h, d × (d^T − q^T)))`. When `h = d` (100%), `q^T = 0`, so this reduces to `R`. +- The same formula covers `N0 ≥ R`. With churn the base shrinks, so the pace that holds the goal to month `T` may be above `0`. The flag `alreadyThere` is set from `N0 ≥ R`. +- Also return `lostPerMonthAtGoal` as above. + +**Growth method.** + +- `M0 = 0` is a field error with code `zeroBase`. Growth from zero has no defined ratio. +- `remainingMrrCents = max(0, monthlyGoalCents − M0)`, shown from the rounded monthly goal. +- `date` mode: + - `M0 × 12 ≥ G12` gives `alreadyThere`, month `0`. + - Otherwise `g ≤ 0` gives `unreachable`, reason `noGrowth` when `g = 0` and `shrinking` when `g < 0`. + - Otherwise the first `t ≤ 600` that reaches the goal gives `reached`. If there's none, the outcome is `beyondHorizon`. +- `pace` mode: + - `requiredGrowthHundredths` is the smallest whole `g` from `0` to `100000` with `12·M0·(d+g)^T ≥ G12·d^T`. Find it by binary search with exact comparison. + - If even `g = 100000` (1000%) falls short, the outcome is `aboveMaxRate`. It returns `growthHundredths = 100000n`, which the interface shows as "more than 1000% a month" (coordinator decision). + - If `M0 × 12 ≥ G12`, the result is `0` with `alreadyThere`. + +**Path, for the chart and the table.** + +- `path` lists months `0..end`, where `end = max(keyMonth, 12)`. +- `keyMonth` is: + - the reached month, for `reached`; + - `T`, for `pace` modes; + - `fallsBelowInMonth ?? 0`, for `alreadyThere`; + - `120`, for `unreachable`; + - `600`, for `beyondHorizon`. +- `pace` modes use the required pace or rate. +- `aboveMaxRate` returns an empty `path`, and the frontend shows no chart. Growth `unreachable` with `g = −100%` returns its path as computed, where MRR drops to 0. +- Customer points are `{ month, customers: floor(N(t)), mrrCents: floor(N(t)) × A }`. Floor never shows the goal as met before it is. +- Growth points are `{ month, mrrCents }`, rounded half away from zero. +- At most 601 points. + +**Traffic.** Only when the timeline has a monthly pace `P`. That's `a` in `customers/date`, or `requiredPace` in `customers/pace`. + +- If `P = 0`: signups and visitors are `0`. +- Else if `s = 0`: `noFiniteTraffic`, stage `signupToPaid`. +- Else if `v = 0`: `noFiniteTraffic`, stage `visitorToSignup`. Check `s` first, because it's the later stage. +- Else: + - `signupsPerMonth = divideCeil(P × d, s)`. + - `visitorsPerMonth = divideCeil(P × d × d, v × s)`. Work from `P`, not from the rounded signups, so rounding never compounds. + - `visitorsToGoal = visitorsPerMonth × months`, where `months` is the reached month or `T`. It's `null` when there's no finite month (`unreachable`, `beyondHorizon`, or `alreadyThere` in `date` mode). + +**Never return** `Infinity`, `NaN`, `-0`, a negative count, a negative pace, or a month above 600. + +**Action and handling.** `Calculate` is the only primary action. The writing pass may name it, for example `Calculate goal`. + +- A valid submit shows the results and focuses the lead. +- An invalid submit clears any old result, keeps every raw input, shows each error under its field, and focuses the first invalid field in reading order. +- Editing any input clears the result and announces `Inputs changed. Calculate again.` That includes changing method, solve mode, goal period, or currency. +- Reset restores defaults with every field blank, focuses the goal amount, and announces `Form reset.` + +**Privacy and security.** Everything runs in the browser. No value goes to a server, the URL, storage, logs, or analytics. The clean URL never changes. No API route, dataset, or job. + +### Backend contract + +Reuses `lib/saas-economics/shared.ts` as it is today. Runtime rules match the sibling engines: strip-types safe, explicit `.ts` imports, no DOM, network, clock, or randomness. + +#### `lib/mrr-goal-calculator/engine.ts` + +```ts +import { type SaasEconomicsCurrency } from "../saas-economics/shared.ts"; + +export const MRR_GOAL_MAX_MONTHS = 600; +export const MRR_GOAL_MAX_COUNT = 1_000_000_000n; +export const MRR_GOAL_METHODS = ["customers", "growth"] as const; +export type MrrGoalMethod = (typeof MRR_GOAL_METHODS)[number]; +export const MRR_GOAL_SOLVE_FOR = ["date", "pace"] as const; +export type MrrGoalSolveFor = (typeof MRR_GOAL_SOLVE_FOR)[number]; +export const MRR_GOAL_PERIODS = ["month", "year"] as const; +export type MrrGoalPeriod = (typeof MRR_GOAL_PERIODS)[number]; + +// Every field is the raw string from the form. Inactive fields may hold drafts and are ignored. +export type MrrGoalInput = { + currency: string; + goalAmount: string; + goalPeriod: string; + monthlyArpa: string; + method: string; + solveFor: string; + currentCustomers: string; + newCustomersPerMonth: string; + churnPercent: string; + currentMrr: string; + growthPercent: string; + deadlineMonths: string; + visitorToSignupPercent: string; + signupToPaidPercent: string; +}; + +export type MrrGoalFieldKey = keyof MrrGoalInput | "traffic" | "form"; + +export type MrrGoalErrorCode = + | "missing" | "format" | "range" | "zero" | "zeroBase" | "option" | "trafficNeedsTimeline" | "form"; + +export type MrrGoalErrors = Partial>; + +export type MrrGoalGoal = { + period: MrrGoalPeriod; + enteredCents: bigint; + monthlyGoalCents: bigint; // half away from zero + monthlyGoalIsRounded: boolean; + annualGoalCents: bigint; + customersNeeded: bigint; // ceil, >= 1 + mrrAtCustomersNeededCents: bigint; // customersNeeded × ARPA +}; + +export type MrrGoalCustomerPoint = { month: number; customers: bigint; mrrCents: bigint }; +export type MrrGoalGrowthPoint = { month: number; mrrCents: bigint }; + +export type MrrGoalCustomerTimeline = { + method: "customers"; + solveFor: MrrGoalSolveFor; + currentCustomers: bigint; + churnHundredths: bigint; + remainingCustomers: bigint; // max(0, R − N0) + lostPerMonthAtGoal: bigint; // 0 when churn is 0 + ceiling: { floor: bigint; isWhole: boolean } | null; // null when churn is 0 + pace: bigint; // entered pace (date) or required pace (pace) + outcome: + | { kind: "reached"; month: number } + | { kind: "alreadyThere"; fallsBelowInMonth: number | null } + | { kind: "unreachable"; reason: "noNewCustomers" } + | { kind: "unreachable"; reason: "ceiling"; paceToReach: bigint } + | { kind: "beyondHorizon" } + | { kind: "paceForDeadline"; deadlineMonths: number; alreadyThere: boolean }; + path: MrrGoalCustomerPoint[]; +}; + +export type MrrGoalGrowthTimeline = { + method: "growth"; + solveFor: MrrGoalSolveFor; + currentMrrCents: bigint; + remainingMrrCents: bigint; + growthHundredths: bigint; // entered (date) or required (pace); signed + outcome: + | { kind: "reached"; month: number } + | { kind: "alreadyThere" } + | { kind: "unreachable"; reason: "noGrowth" | "shrinking" } + | { kind: "beyondHorizon" } + | { kind: "rateForDeadline"; deadlineMonths: number; alreadyThere: boolean } + | { kind: "aboveMaxRate"; deadlineMonths: number }; + path: MrrGoalGrowthPoint[]; +}; + +export type MrrGoalTraffic = + | { + kind: "finite"; + visitorToSignupHundredths: bigint; + signupToPaidHundredths: bigint; + pace: bigint; + signupsPerMonth: bigint; + visitorsPerMonth: bigint; + visitorsToGoal: bigint | null; + months: number | null; + } + | { kind: "noFiniteTraffic"; stage: "visitorToSignup" | "signupToPaid" }; + +export type MrrGoalValues = { + currency: SaasEconomicsCurrency; + monthlyArpaCents: bigint; + goal: MrrGoalGoal; + timeline: MrrGoalCustomerTimeline | MrrGoalGrowthTimeline | null; // null = skipped + traffic: MrrGoalTraffic | null; // null = off, or growth method +}; + +export type MrrGoalCalculation = + | { status: "valid"; values: MrrGoalValues; errors: Record } + | { status: "invalid"; values: null; errors: MrrGoalErrors }; + +export function calculateMrrGoal(input: unknown): MrrGoalCalculation; +``` + +`calculateMrrGoal` accepts `unknown`, collects every error in one pass, and never throws for user input. A non-object gives `errors.form`. A missing string property counts as blank. + +Error codes and messages. The engine owns these strings, and the writing pass may reword them: + +| Field | Case | Code | Message | +| --- | --- | --- | --- | +| goalAmount | blank | `missing` | `Enter your revenue goal.` | +| goalAmount | `0` | `zero` | `Enter a goal above 0.` | +| monthlyArpa | blank | `missing` | `Enter monthly ARPA or your monthly price.` | +| monthlyArpa | `0` | `zero` | `Enter an ARPA above 0.` | +| any money | bad format | `format` | `Use digits with up to two decimals. No signs, commas, or currency symbols.` | +| any money | above max | `range` | `Enter an amount up to 1,000,000,000.00.` | +| currentCustomers, newCustomersPerMonth | blank | `missing` | `Enter a whole number. Use 0 if none.` | +| currentCustomers, newCustomersPerMonth | bad format | `format` | `Use digits only, without commas, signs, or decimals.` | +| currentCustomers, newCustomersPerMonth | above max | `range` | `Enter a number from 0 to 1,000,000,000.` | +| churnPercent | blank | `missing` | `Enter monthly churn. Use 0 if no one cancels.` | +| churnPercent, rates | bad or out of range | `format` / `range` | `Enter a percent from 0 to 100, with up to two decimals.` | +| currentMrr | blank | `missing` | `Enter your current MRR.` | +| currentMrr | `0` | `zeroBase` | `A growth rate can't grow $0. Use new customers and churn instead.` | +| growthPercent | blank | `missing` | `Enter your monthly MRR growth.` | +| growthPercent | bad or out of range | `format` / `range` | `Enter a percent from -100 to 1000, with up to two decimals.` | +| deadlineMonths | blank | `missing` | `Enter the number of months.` | +| deadlineMonths | bad, `0`, or above 600 | `format` / `range` | `Enter whole months from 1 to 600.` | +| visitorToSignupPercent, signupToPaidPercent | blank while the other has a value | `missing` | `Enter this rate, or clear both rates.` | +| traffic | rates entered, timeline skipped | `trafficNeedsTimeline` | `Fill in the customer timeline first. Traffic is worked out from its monthly pace.` | +| currency, goalPeriod, method, solveFor | not a listed value | `option` | `Choose one of the listed options.` | +| form | non-object input | `form` | `Something in the form is out of date. Reset and try again.` | + +The `zeroBase` message uses `$` only as an example. The frontend may swap in the currency symbol. + +### Known examples + +Calculated independently with exact fractions. USD, monthly goal, `date`, and no traffic unless stated. + +| Inputs | Expected | +| --- | --- | +| Goal `10000`, ARPA `29`, timeline blank | `customersNeeded 345`; MRR at 345 `$10,005.00`; `timeline: null`. | +| Goal `10000`, ARPA `12` | `834`. | +| Goal `10000` per **year**, ARPA `29` | Monthly goal `$833.33`, `monthlyGoalIsRounded: true`; annual `$10,000.00`; `customersNeeded 29` (exact 28.74). | +| Goal `10000`, ARPA `29`, customers: current `0`, new `15`, churn `0` | `reached`, month `23`; `ceiling: null`; `lostPerMonthAtGoal 0`; `remainingCustomers 345`. Path month 23 customers `345`. | +| Same, churn `3` | `reached`, month `39`. Exact N(38) = 342.86, N(39) = 347.57. Ceiling `500`, `isWhole: true`; `lostPerMonthAtGoal 11` (exact 10.35). Path floors at months 1, 2, 3, 12, 24, 39: `15, 29, 43, 153, 259, 347`. | +| Goal `15000`, ARPA `29`, current `0`, new `15`, churn `3` | `customersNeeded 518`; `unreachable`, reason `ceiling`; ceiling `500`; `paceToReach 16`. Path to month 120. | +| Goal `10000`, ARPA `29`, current `120`, new `20`, churn `2.5` | `remainingCustomers 225`; ceiling `800`; `reached`, month `16`. Path floors at months 15 and 16: `334, 346`. | +| Goal `10000`, ARPA `29`, current `400`, new `5`, churn `3` | `alreadyThere`; ceiling `166` (`isWhole: false`, exact 166.67); `fallsBelowInMonth 9`. | +| Goal `10000`, ARPA `29`, current `10`, new `0`, churn `0` | `unreachable`, reason `noNewCustomers`. | +| Goal `29000`, ARPA `29`, current `0`, new `1`, churn `0` | `customersNeeded 1000`; `beyondHorizon`. Month 1000 is past the 600 cap. Path months 0 to 600. | +| Goal `10000`, ARPA `50`, customers `pace`: current `0`, churn `0`, deadline `12` | `customersNeeded 200`; `requiredPace 17` (exact 16.67); `paceForDeadline`. | +| Goal `10000`, ARPA `29`, customers `pace`: current `0`, churn `4`, deadline `12`; traffic visitor→signup `3`, signup→paid `15` | `requiredPace 36` (exact 35.63). Pace 35 reaches in month 13, pace 36 in month 12. Signups `240`, visitors `8,000` a month, `visitorsToGoal 96,000` over 12 months. VisitTrack shows 7,919 because it uses the fractional 35.63 customers. | +| Same, signup→paid `0` | Traffic `noFiniteTraffic`, stage `signupToPaid`. | +| Growth: current MRR `1000`, growth `10`, goal `10000` | `reached`, month `25`. Path month 24 `$9,849.73`, month 1 `$1,100.00`, month 3 `$1,331.00`. `remainingMrrCents` `$9,000.00`. | +| Growth `pace`: current MRR `1000`, goal `10000`, deadline `24` | `requiredGrowthHundredths 1007` (10.07%). At 10.07%, month 24 is `$10,001.27`. At 10.06% it's `$9,979.49`. | +| Growth `pace`: current MRR `1000`, goal `10000`, deadline `1` | `900.00%`, so `90000` hundredths, `rateForDeadline`. | +| Growth `pace`: current MRR `0.01`, goal `1000000000`, deadline `1` | `aboveMaxRate`. | +| Growth: current MRR `1000`, growth `0.3`, goal `10000` | `beyondHorizon`. The exact first month is 769. | +| Growth: current MRR `1000`, growth `0` / `-2` | `unreachable`, `noGrowth` / `shrinking`. | +| Growth: current MRR `12000`, growth `5`, goal `10000` | `alreadyThere`, month 0; `remainingMrrCents 0`. | +| Invalid | One error per field, `status: "invalid"`: goal blank or `0`; ARPA `0`; goal `$10,000`, `1e4`, `10000.001`; churn `101` or `-1`; current customers `12.5` or `1,200`; growth current MRR `0` (`zeroBase`); growth `1000.01`; deadline `0` or `601`; one traffic rate blank; traffic set with the timeline blank (`traffic`); only new customers filled in `customers/date` (errors on current customers and churn); method `weekly`. A blank timeline and blank traffic are valid. | + +### Ownership + +Sol builds the backend: + +- `lib/mrr-goal-calculator/engine.ts`. Reuse `lib/saas-economics/shared.ts` and don't change it. +- `tests/pages/mrr-goal-calculator/mrr-goal-calculator.test.mjs`, covering the examples above. +- The development tool-page row, when the coordinator asks. + +Opus builds the frontend: + +- `app/(site)/(tools)/(saas)/mrr-goal-calculator/page.tsx`, `layout.tsx`, `mrr-goal-calculator.tsx`, and `mrr-goal-calculator.module.css`. +- `app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator.{spec.json,svg,ts}`, plus its line in `lib/card-art.ts`. + +No URL-restore wrapper. + +### Catalog placement + +Primary and only catalog: `/saas-tools`. It's the forward-looking goal and pace question. The MRR, ARPA, churn, break-even, and metrics tools measure today's numbers or a cost target, and none of them plans a goal. Place it next to the break-even MRR calculator. The coordinator sets the final order for the batch. No secondary catalog fits. + +Catalog `toolSpot` key: `mrr-goal-calculator`. Suggested motif name: `goalFlag`. + +- Takeaway: a monthly climb of paying customers meets a set target. +- Focal subject: a small pennant flag on a short pole, planted on a horizontal dashed target line in the upper right. +- One supporting relationship: a rising line of evenly spaced dots, one per month, climbs from the lower left and meets the dashed line exactly at the flag pole's foot. +- Tone: aspirational but calm. Accent on the flag only, neutral ink for the dots and line. +- Keep out: numbers, currency signs, coins, people icons, steps, bars or columns, and a curve that flattens below the line. The LTV card already uses an asymptote, CAC payback a staircase, and gross margin columns. +- Wordless, and legible at card size in both themes. + +## Interface + +### Flow + +- Question: "How many paying customers do I need for my revenue goal, and how long will it take me to get there?" +- Lead answer: paying customers needed, always. +- Primary action: `Calculate`. The writing pass sets the final label. +- Control mode: `single`. Each method's fields stay visible in the main flow. Traffic sits in a `UiDisclosure`, because it's an optional refinement that does nothing when blank. Its summary shows the two rates when they're set. + +Workbench order: + +1. **Goal.** Goal amount, with a currency-code suffix, beside a `per month (MRR)` / `per year (ARR)` choice. Then monthly ARPA, with this helper: `For yearly plans, divide the yearly price by 12.` The currency select sits at the top right of this group, and above it on mobile. +2. **Timeline.** Marked optional. Two compact `UiChoiceGroup` rows: + - Method: `New customers and churn` / `MRR growth rate`. + - Solve for: `When I reach it` / `Pace for a deadline`. + + Then only the fields the pair reads, in this order: + - Customers, date: current paying customers, new paying customers a month, monthly customer churn. Units sit in the labels (`New paying customers a month`, `Deadline in months`), because a long suffix wraps inside a narrow field. + - Customers, pace: current paying customers, monthly customer churn, deadline in months. + - Growth, date: current MRR, net monthly MRR growth. + - Growth, pace: current MRR, deadline in months. + + Helpers: + - Under churn: `Share of paying customers who cancel each month. Not revenue churn.` + - Under growth: `Net change in total MRR. Churn is already in it.` + + Both prevent the most common mix-up in the research. +3. **Traffic needed**, a `UiDisclosure`. Customer method only, and hidden for growth with drafts kept. It holds the visitor-to-signup and signup-to-paid rates, each with a `%` suffix. +4. `Calculate` and a quiet `Reset`, in a `UiActionRow`. +5. One divider, then results. + +### Components + +Existing shared components. No new shared component is needed: + +| Use | Component | +| --- | --- | +| Currency | `UiSelect` from `@/app/ui/controls` | +| Goal period, method, solve for | `UiChoiceGroup` from `@/app/ui/controls` | +| Money, counts, percents, months | `UiTextField` from `@/app/ui/controls`, with `inputMode="decimal"` or `"numeric"`, a suffix (currency code, `%`, `months`, `a month`), autocomplete and spellcheck off | +| Traffic | `UiDisclosure` from `@/app/ui/controls` | +| Actions | `UiActionRow` from `@/app/ui/display`, `UiButton` from `@/app/ui/controls` | +| Lead | `UiPrimaryResult` from `@/app/ui/display` | +| Timeline and traffic figures | `UiMetricGroup` from `@/app/ui/display` | +| Progress toward goal today | `UiProgressBar` from `@/app/ui/display` | +| Month chart | `UiLiveChart` from `@/app/ui/charts/chart-client`, kind `line` | +| Month-by-month table | `UiDataTable` from `@/app/ui/display`, inside a `UiDisclosure` | +| Assumptions | `UiKeyValueList` from `@/app/ui/display` | +| Change, reset, failure | `UiStatus` from `@/app/ui/display` | + +Why no new component: + +- The chart's goal and ceiling are flat extra series in a `line` spec, so no reference-line feature is needed. +- A month axis is a numeric x with `xFormat: "number"`. +- This is the first tool page to use `UiLiveChart`, so the frontend check must confirm it at mobile width in both themes. + +Tool-specific component, in the route file: + +- `MrrGoalCalculator` owns raw input strings, error mapping by `MrrGoalFieldKey`, focus, status, and result rendering. It calls only `calculateMrrGoal`. + +### Result regions + +1. **Lead.** `UiPrimaryResult`, label `Paying customers needed`, value `345`. + - Detail: `At $29.00 a month, 345 customers bring in $10,005.00 MRR.` + - For ARR: `$10,000.00 ARR is $833.33 a month. At $29.00, 29 customers bring in $841.00 MRR.` + - Tone `neutral`. +2. **Timeline answer.** Shown when the timeline isn't skipped. A short sentence heading, then a `UiMetricGroup`. + + | Outcome | Heading | Metrics | + | --- | --- | --- | + | Customers, reached | `Reached in month 39` (`about 3 years 3 months`) | `Customers still needed` 345 · `Lost each month at goal` 11 · `Ceiling at this pace` 500. The entered pace shows only in the assumptions list. | + | Customers, already there | `You're already there` | If `fallsBelowInMonth`: `At this pace you drop below the goal in month 9. Customers level off near 166.` | + | Unreachable, ceiling | `Out of reach at this pace` | Detail: `With 15 new customers a month and 3% churn, you level off at 500 customers, short of 518. Add at least 16 a month to reach it.` When the ceiling equals the goal: `you get close to 500 but never reach it.` | + | Unreachable, no new customers | `Out of reach at this pace` | `With no new customers and no churn, the count stays at 10.` | + | Beyond horizon | `Not within 50 years` | `At this pace the goal is more than 600 months away.` | + | Customers, pace | `Add 36 new customers a month` | `to reach 345 by month 12` · `Lost each month at goal` 14 · `Customers still needed` 345 | + | Growth, reached | `Reached in month 25` | `MRR still needed` $9,000.00. The entered rate shows only in the assumptions list. | + | Growth, unreachable | `Out of reach at this rate` | `MRR doesn't grow at 0% a month.` or `MRR shrinks at -2% a month.` | + | Growth, pace | `Grow MRR 10.07% a month` | `to reach $10,000.00 by month 24` | + | Growth, above max | `Needs more than 1000% a month` | `Try a later deadline.` | + + - Month spans show as `about N years M months` when 12 or more, with whole months only. + - Tone stays `neutral`. "Out of reach" isn't an error state. + - When the timeline is skipped, show one line instead: `Add your current numbers to see when you'd reach it.` +3. **Today's progress.** + - Customer method with `N0 > 0`: `UiProgressBar` labeled `Customers today toward goal`, with `valueText` `120 of 345`. + - Growth method: labeled `MRR today toward goal`. + - Skip it when already there, because a full bar repeats the heading. +4. **Chart.** `UiLiveChart`, `kind: "line"`, `xFormat: "number"`. + - Customer method: `format: "number"`, title `Paying customers by month`. + - Series `Paying customers` from `path.customers`. + - Series `Customers needed`, flat at `R`. + - Series `Ceiling at this pace`, flat, only when the outcome is `unreachable/ceiling`. + - Growth method: `format: "currency"`, title `MRR by month`. + - Series `MRR`, cents ÷ 100. + - Series `Goal`, flat at the monthly goal. + - Pace modes use the required pace or rate. Caption: `At 36 new customers a month` or `At 10.07% a month`. + - No `range`. Hover and series toggles help read a crossing month. + - No chart for `alreadyThere` without `fallsBelowInMonth`, in either method, and none for a flat path. The month table follows the chart and is hidden when the chart is. + - The shared chart formats `currency` as US dollars. For other currencies the MRR chart uses `number` and names the code in its title. +5. **Traffic.** Customer method with rates set. A `UiMetricGroup`: `Signups a month` 240 · `Visitors a month` 8,000 · `Visitors until goal` 96,000. + - For `noFiniteTraffic`, one line: `At 0% signup-to-paid, no amount of traffic adds paying customers.` +6. **Month by month.** A `UiDisclosure` named `Month by month`, holding a `UiDataTable`. + - Customer columns: `Month`, `Paying customers`, `MRR`. Growth columns: `Month`, `MRR`. + - The goal month's row is marked with text, `Goal reached`, not color alone. + - This gives exact numbers for paths over 60 points, where the chart drops its hidden table. +7. **Assumptions.** A `UiKeyValueList`: `Goal`, `Monthly ARPA`, and the method's inputs. + - One formula line per method. Customers: `Next month = customers × (1 − churn) + new customers.` Growth: `Next month = MRR × (1 + growth).` + +Facts aren't repeated: + +- The customer count appears only in the lead. +- The pace appears in the timeline metrics or heading, never both. +- The chart's goal line restates `R` visually, which is the relationship it shows. +- The traffic pace isn't repeated, because the timeline already shows it. + +### States + +- **Initial.** Everything blank. USD, `month`, `customers`, `date`. Traffic collapsed. No result. +- **Invalid.** + - Clear the result and keep every value. + - Field errors go under their fields. + - A `traffic` error goes under the disclosure's first rate. Expand the disclosure if it's collapsed. + - Focus the first invalid field in reading order: goal, ARPA, then timeline fields, then the rates. +- **Valid.** Show the results, focus the lead, and let the focused output announce it. +- **Partial.** A skipped timeline shows the lead plus the add-numbers line. A growth method shows no traffic. +- **Unreachable, beyond horizon, above max rate.** Valid results in the same layout. +- **Edited after result.** Clear the result, keep focus, and announce `Inputs changed. Calculate again.` once. +- **Method or solve switch.** Swap the visible fields and keep every draft. Hidden fields are never validated, so no hidden error can happen. +- **Unexpected failure.** Keep the inputs and show `UiStatus` danger by the actions: `Couldn't calculate. Try again.` +- **Reset.** Restore initial values, focus the goal amount, and announce `Form reset.` +- **Restored.** The clean URL never changes. A back-forward cache restore clears on the next edit. +- **Long values.** `1,000,000,000.00` goals and 100-billion customer counts show in full, never abbreviated. They wrap inside metrics without page overflow. A 600-row table scrolls inside its disclosure. +- **Loading and retry.** Don't apply. The calculation is synchronous. + +### Responsive and accessible behavior + +Desktop: + +- Use the measured workbench. +- Goal amount and goal period share a row. ARPA sits beside the currency select. +- The two choice rows sit one above the other. +- Timeline fields share a row of three, or two in growth. +- Results stack in the order above. The chart is full width at about 280px tall. + +Mobile: + +- Every field stacks in task order, and choice rows wrap their options. +- Buttons go full width. +- The chart keeps full width, and series names wrap under it. +- `UiDataTable` turns into labeled rows. No page-level horizontal overflow, and grid children get `min-width: 0`. + +Keyboard and screen readers: + +- Tab order is currency, goal, goal period (arrows), ARPA, method (arrows), solve for (arrows), visible timeline fields, the traffic toggle and rates, then the actions. +- Enter in a field submits. +- One polite live region announces changes, resets, and failures. The result is announced through the focused lead. +- The chart carries its own hidden table up to 60 points, and the month table covers longer paths. + +Check at desktop and mobile in both themes: + +- $10k at $29, with 15 a month at 0% and at 3%. +- $15k unreachable. +- Already there and shrinking. +- An ARR goal. +- Customer pace with traffic. +- Growth at 10%, and growth pace. +- Growth from 0, for the error. +- A 600-month path. +- Pasted `$10,000`. + +--- + +## Content + +Plan for the writing session. The page words live in the route's `content/*.mdx` files, and this section doesn't repeat them. + +The tool was run through the engine with the known examples above, not in a browser. The result sentences come from the route file. + +### Search intent and metadata direction + +The searcher has a revenue target, usually "$10k MRR". They want to know how many paying customers that is at their price, and how long it takes at a pace they can manage. Top results answer one slice each, leave churn out or add it to the one-time count, and give no reason when a goal can't be reached. + +- Visible title: `MRR goal calculator: how many customers do you need, and when?` +- SEO title: `MRR goal calculator: customers and time to $10k MRR`. "$10k MRR" is the phrase people search. +- SEO description: `Find how many paying customers your MRR or ARR goal needs, and when you'll reach it from new customers and churn or your MRR growth rate.` +- Card name: `MRR goal`. Card description: `Find how many paying customers your revenue goal needs, and the month you'll reach it.` + +No benchmark, typical growth rate, or "time to $10k" claim anywhere, including the metadata. + +### Page promise and reader questions + +After the page, the reader knows their customer count, can tell it apart from the monthly pace, and knows why churn can put a goal out of reach. + +| Reader question | Answered by the tool | Where the page answers it | +| --- | --- | --- | +| How many customers do I need for $10k MRR? | Lead, rounded up, with the MRR those customers bring. | Article: formula, a price table for $10k MRR, the ARR case. | +| What price or ARPA do I enter? | Field label and hint. | Usage: one plan vs several, yearly plans, link to ARPA and MRR tools. | +| How long will it take? | Timeline heading, month count. | Usage worked example. Article: no typical answer, your pace decides. | +| How does churn change it? | Path, chart, `Lost each month at goal`. | Article: the month formula, 23 vs 39 months. | +| Why does it say out of reach? | Heading and the level-off sentence. | Article: `new ÷ churn`, 15 ÷ 3% = 500, the $15k goal, the 16 a month pace. | +| Is "customers needed" the same as new customers a month? | Lead vs timeline metrics. | Article section with a three-row table. | +| Should I use a growth rate instead? | Method switch, growth helper. | Article: net rate already has churn in it; can't start from 0. | +| How much traffic do I need? | Traffic metrics. | Article: funnel formula, 8,000 vs 7,919. | +| Is $10k MRR $10k income? | Not answered by the tool. | Article: run rate, not cash or income; link break-even MRR. | + +### Recommended page structure and formats + +**Intro.** Two sentences and a short third: what you get, what to have ready, the timeline is optional. + +**Usage.** `What to enter`: the goal period, price or ARPA, which timeline method to pick, where new customers, churn, and growth rate come from, and the deadline mode. Short traffic note. `How to read the result`: lead rounds up, month numbering, the three timeline metrics, and one `Example` with $10k, $29, 15 a month, 3% churn. + +**Article, in this order:** + +1. `How many customers do you need for $10k MRR?` Formula box, price table for $10k MRR, the ARR case at $29. +2. `How does churn slow you down?` Formula box for the month step. 15 a month: month 23 at 0%, month 39 at 3%. +3. `Why is my goal out of reach?` The ceiling, the 15 ÷ 3% = 500 example, the $15k goal at 518, pace 16 reaching in month 117. Point to `Pace for a deadline` for a date. +4. `Are customers at goal and new customers a month the same?` Table of the three numbers. +5. `Should you plan from a growth rate or from new customers?` Net rate includes churn. $1,000 at 10%, month 25, 10.07% for 24 months. Zero start. +6. `How much traffic does your goal need?` Formula box, 36 a month at 3% and 15%, 8,000 vs VisitTrack's 7,919. +7. `How long does it take to reach $10k MRR?` No typical time; self-reported threads are not a survey. +8. `Is $10k MRR the same as $10k income?` No. Stripe definition, yearly plans, costs. Next links. + +No article chart and no marker art. The tool already draws the path and the ceiling line. + +### Left out on purpose + +- The 600-month cap, the 1,000% growth cap, 100% churn, and month 0 at goal. Result headings and the spec handle them. +- Input format rules. Field errors handle them. +- Why the chart floors customer counts. The month table shows the exact numbers. +- A "good growth rate" or churn benchmark. Research found no dated, segmented source. + +### Claims to keep, and claims to avoid + +Keep: counts round up; the goal says MRR or ARR; churn is never taken off a net growth rate; the ceiling is `new ÷ churn`; customers at goal, new a month, and lost a month at goal are three numbers; traffic comes from a whole-number pace; results are scenarios from the entered numbers. + +Avoid: typical times to $10k MRR, community averages, good or bad rates, calling MRR cash, revenue, profit, or income, and saying the minimum pace reaches the goal by a date. + +### Source roles and links + +- [Stripe: Understanding MRR and ARR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29) for MRR as a normalized monthly amount, not GAAP revenue. Checked 2026-10-10 in research. +- [ChartMogul: Customer churn rate](https://chartmogul.com/saas-metrics/customer-churn/) for customer churn as customers lost ÷ customers at the start. +- [ChartMogul: Average revenue per account](https://chartmogul.com/saas-metrics/arpa/) for ARPA as MRR ÷ paying customers. +- The 345, 23, 39, 500, 518, 16, 117, 36, 240, 8,000, 25, and 10.07% figures come from this spec's known examples and an engine run on 2026-10-10. The price table is `ceil(10000 ÷ price)`. +- VisitTrack's 7,919 comes from the research's competitor review. +- Founder threads in research show the question, not a typical time. They aren't linked. + +Next stack127 pages: `/mrr-calculator`, `/arpu-arpa-calculator`, `/customer-churn-calculator`, `/churn-rate-converter`, `/free-trial-conversion-calculator`, `/break-even-mrr-calculator`. diff --git a/content_docs/startup-runway-calculator/research.md b/content_docs/startup-runway-calculator/research.md new file mode 100644 index 00000000..9b41d137 --- /dev/null +++ b/content_docs/startup-runway-calculator/research.md @@ -0,0 +1,239 @@ +Slug recommendation: /startup-runway-calculator. Google results for "runway calculator" mix startup cash, personal savings, investment products, and airport runway questions. "Startup runway calculator" states the job clearly. "Burn rate calculator" can sound like a rate-only tool and overlaps with the existing burn multiple page. + +# Research: Startup runway calculator + +Route: /startup-runway-calculator (chosen by coordinator 2026-10-10) +Checked: 2026-10-10 +Competitors used: 7 + +## Scope + +This tool answers: "How long will my business cash last?" It should calculate current gross cash outflow, current net cash burn, and a simple runway estimate. It should also let a founder project customer cash receipts and expenses month by month, then show when cash reaches zero and when the business first covers its costs. + +The audience is indie hackers and solo SaaS founders, mostly bootstrapped. They may not have a finance team or a detailed budget. Keep the base result useful with three figures: cash available now, monthly cash collected from customers, and monthly cash paid out. Let users add revenue growth and expense growth for a forecast. + +This is a business cash tool. It does not model a founder's household savings, predict fundraising, or replace a weekly cash forecast. An annual subscription collected upfront changes cash sooner than it changes recognized revenue. A monthly estimate also cannot model payment delays, a one-off tax bill, or a hire starting in a specific month unless those events are entered separately. + +Queries tried: "startup runway calculator", "runway calculator", "startup burn rate calculator", "burn rate calculator startup", "cash runway calculator how long will cash last", "how do you calculate startup runway", "startup runway with revenue growth", and "default alive calculator". + +The Google result checks were made from an India location on 2026-10-10. The startup-specific results included Trezy, Futureproof, Pilot, StartupWire, and several general calculator sites. "Runway calculator" also returned personal savings and investing tools. "Burn rate calculator startup" returned tools focused on gross and net burn, as well as runway. Trezy, Futureproof, Pilot, Kenodo, Legendev, Calculator Academy, and SaaS Runway were tested below. Carta's page titled "Burn rate calculator" loaded as a formula guide without a usable calculator in the browser, so it is a source for definitions rather than one of the seven tested tools. + +## Search intent + +| Query | What the searcher wants | Answer they need | What current results miss | +| --- | --- | --- | --- | +| startup runway calculator | A quick number of months and a cash-zero date for a startup | Cash in bank, gross cash outflow, customer receipts, and a clear result | Many pages use MRR as if it were cash collected. Few explain owner pay, known commitments, or the forecast assumptions. | +| runway calculator | A generic estimate of how long money lasts | The startup-specific cash question, separated from personal savings and airport runway uses | The query has mixed intent. The page should use "startup runway calculator" as its main search phrase. | +| burn rate calculator startup | Gross burn, net burn, and runway | Show total cash out, customer cash in, net burn, and months until cash reaches zero | Several tools stop at burn rate or use static inputs without a cash trajectory. | +| how do you calculate startup runway | A formula and a worked example | Explain cash divided by positive monthly net burn, then show what changes when monthly cash flows change | The simple formula assumes cash flow stays constant. It does not answer what growth or expense increases do. | +| startup runway with revenue growth | A month-by-month forecast | Show how revenue growth changes monthly net burn and cash, with a break-even month | Results often hide the first-month timing or treat a hopeful growth rate as a prediction. | +| default alive calculator | Whether the company reaches profitability before cash runs out | Show the first cash-flow break-even month and whether cash stays above zero until then | Some tools call current cash-flow break-even "default alive", even when the user entered no growth forecast. | + +Google's People also ask panel for "how do you calculate startup runway" showed "How to calculate runway for a startup?" and "How to calculate the runway?" It also showed "What is the 80/20 rule for startups?" and "Is it true that 90% of startups fail?", which are unrelated to this tool. For "runway calculator", it included "How do I calculate the cash runway for a business?" For "burn rate calculator startup", the panel included "How can I calculate my burn rate?", "What is a good burn rate for a startup?", and "What is the formula to calculate burn?" The related searches included "startup runway calculator India", "startup runway calculator Excel", "startup runway calculator free", "burn rate formula", "runway calculator startup", "daily burn rate calculator", and "cash runway formula". + +The top results do not answer these questions well: + +- Does runway use MRR or cash customers paid this month? +- Should founder pay count if the founder has not started taking a salary? +- What happens when monthly revenue reaches monthly cash costs before cash reaches zero? +- What if costs grow along with revenue? +- How should I read a result that says "infinite" or "36+ months"? + +## User problems + +| Problem people repeat | Evidence link | What it means for the tool | +| --- | --- | --- | +| A founder asked whether a positive $500 monthly surplus meant 50 months of runway, and whether the number should be dollars or months. Replies also pointed out that the founder had not included a salary and might need to model likely future costs. | [Reddit: runway field in an incubator application](https://www.reddit.com/r/startups/comments/wam4aa/) | Put "months" beside the result. Explain that a zero or positive net cash flow has no finite cash-out date under unchanged assumptions. Prompt founders to include pay and costs they expect to take on. | +| A SaaS founder said cash divided by burn felt too static as MRR grew. They wanted to combine growth, churn, and planned hires without turning a spreadsheet into a full model. | [Reddit: calculating runway with month-over-month revenue growth](https://www.reddit.com/r/SaaS/comments/1s0fcvf/how_do_you_calculate_runway_when_your_revenue_is/) | Keep the constant-burn answer. Add an optional month-by-month view with revenue and expense growth. State that a growth rate is an assumption and can make the estimate look longer than a downside path. | +| A micro-SaaS founder asked what recurring expenses other builders paid for and how to estimate the cushion needed to start. Replies ranged from a $65 monthly server bill to a few hundred dollars for servers and marketing tools. | [Reddit: monthly burn for a micro-SaaS](https://www.reddit.com/r/microsaas/comments/1fftgg9/) | Let a founder enter one monthly outflow total. If the tool accepts expense groups, include hosting, APIs, software, marketing, contractor costs, taxes, and founder pay. Do not bake in venture-backed payroll assumptions or fixed stack prices. | +| A founder who had left a job and funded an early product with personal money and free credits said they had almost no runway after unpaid pilots and slow customer decisions. They asked how to decide whether to continue or take a job. | [Reddit: founder with almost no runway](https://www.reddit.com/r/startups/comments/1tbwz6t/) | Show the cash-zero timing and a conservative path clearly. Do not count verbal customer interest, unpaid pilots, hoped-for funding, or free credits as recurring cash receipts. | + +## Facts to get right + +| Fact | Primary source | How the tool should treat it | +| --- | --- | --- | +| Gross burn is monthly cash paid out. Positive net cash burn is gross cash outflow minus cash collected from customers. Exclude investment and loan proceeds from customer revenue. | [Carta's burn rate calculator](https://carta.com/learn/resources/burn-rate-calculator/), [Pilot's cash-balance calculator method](https://pilot.com/blog/burn-rate-calculation-startup-runway-calculator) | Keep gross and net figures visible. Use the same period for each. A three-month average can smooth a one-time bill, but users should be able to enter a forward monthly estimate when it better reflects planned costs. | +| Static runway is available cash divided by positive monthly net burn. | [Pilot's runway formula](https://pilot.com/blog/burn-rate-calculation-startup-runway-calculator), [CFI's cash runway formula and example](https://corporatefinanceinstitute.com/resources/valuation/cash-runway-explained/) | Show this as the constant-burn baseline. Keep the sign convention clear: positive net burn means cash is shrinking. If net cash burn is zero or negative, show no finite zero date at the current rate. | +| For a growth-adjusted cash forecast, update receipts and outflows by their monthly growth rates, then roll the balance forward: `receipts_m = receipts_(m-1) × (1 + revenue_growth)`, `outflow_m = outflow_(m-1) × (1 + expense_growth)`, `cash_m = cash_(m-1) + receipts_m - outflow_m`. | Trezy's documented monthly revenue compounding and Calculator Academy's documented revenue and expense growth options; [Paul Graham's default-alive test](https://paulgraham.com/aord.html) is a related forecast question, not this exact equation. | Define today's values as month 0 and apply both rates to month 1 onward. Show the rates and month convention. Reject growth below -100% if using this multiplicative model. Do not show an exact infinite runway when the forecast has a finite horizon. | +| A runway forecast follows cash flows, while MRR tracks recurring revenue. Customer receipts and cash payments can occur at different times from revenue recognition and accounting expenses. | [Stripe on subscription revenue recognition](https://stripe.com/resources/more/revenue-recognition-for-subscription-based-businesses-explained), [SBA on cash-flow forecasting](https://legacy.sba.gov/event/89487) | For a cash runway result, use customer cash collected and cash actually paid out. Stripe gives the example of a $1,200 annual subscription paid upfront, with $100 recognized as revenue each service month. Do not silently treat MRR as cash received. | +| Paul Graham defines "default alive" by asking whether a startup reaches profitability before cash runs out, assuming expenses stay constant and revenue keeps growing at its recent rate. | [Paul Graham, "Default Alive or Default Dead?"](https://paulgraham.com/aord.html) | If using this label, show the assumed revenue path, expense path, break-even month, and cash-zero result. For an input with zero net cash flow, say "cash-flow break-even", not "profitable". | +| A month-over-month growth rate compounds. Ten percent monthly revenue growth compounds to about 214% over twelve months. | [Trezy's runway calculator](https://www.trezy.io/en-us/runway-calculator), which displays 214% annual growth for 10% monthly growth | Label rates as monthly. Show the assumption and avoid describing the projection as a prediction. Allow negative revenue growth down to -100%; reject rates below -100% if the model compounds revenue. Validate cash as non-negative; zero cash means the business has no cash runway today, even if later modeled cash flow is positive. | +| Expense growth can be modeled as a monthly assumption, but hiring and annual bills often create step changes instead of smooth compounding. | [Calculator Academy's growth options](https://calculator.academy/startup-burn-rate-runway-calculator/), [SBA cash-flow forecasting topics](https://legacy.sba.gov/event/89487) | Let users set expense growth for a simple scenario. Mark known one-time bills and planned hires as separate cash events if the model supports them. If not, state that a percentage-based forecast will miss those steps. | +| There is no universal "healthy runway" threshold for bootstrapped founders. Published guidance varies by business and fundraising context. | [CFI's cash runway guide](https://corporatefinanceinstitute.com/resources/valuation/cash-runway-explained/), [Paul Graham's default-alive essay](https://paulgraham.com/aord.html) | Avoid a red/amber/green verdict based on an unqualified 6, 12, or 18 month rule. The useful result is whether cash runs out before the stated cash-flow break-even or a founder-chosen deadline. | +| Stripe's 2.9% + $0.30 fee applies to domestic online card payments under its US standard pricing, not every payment method or market. | [Stripe pricing](https://stripe.com/pricing) | Do not hardcode a universal payment fee. Include processor fees in cash outflow or let the user enter their own fee. | + +## Competitor reviews + +### 1. Trezy + +URL: [Trezy startup runway calculator](https://www.trezy.io/en-us/runway-calculator) + +- [Observed] The calculator asks for cash, monthly recurring revenue, monthly expenses, and monthly revenue growth. It shows a chart, a monthly cash summary, a runway result, a break-even month, and a default-alive statement. +- [Observed] Correct example with its loaded values: $200,000 cash, $15,000 monthly revenue, $30,000 monthly expenses, and 10% growth gave 36+ months of runway, break-even in month 8, and "default alive". The chart covers months 0 to 35. +- [Observed] Edge case: $1,000 cash, $30,000 revenue, $30,000 expenses, and 0% growth gave infinite runway and $0 net burn. The text said revenue exceeds expenses even though they were equal. +- [Observed] Wrong input: cash of -$1,000, revenue of $0, expenses of $30,000, and 0% growth was accepted. It reported one month of runway and a zero date in November 2026. +- [Documented] Its formula grows base revenue by the monthly rate raised to the month number. It displays the opening balance as month 0. It does not model expense growth. +- [Inferred] MRR may overstate cash received when a customer prepays annually. Its 36-month cap also makes "36+" a horizon result rather than an exact runway. +- [Unknown] I did not verify whether inputs leave the browser or are saved to an account. + + 200000 cash, 15000 revenue, 30000 expenses, 10% growth -> 36+ months; break-even month 8 + 1000 cash, 30000 revenue, 30000 expenses, 0% growth -> infinite; $0 burn; says revenue exceeds expenses + -1000 cash, 0 revenue, 30000 expenses, 0% growth -> accepted; 1 month runway + +### 2. Legendev + +URL: [Legendev startup runway calculator](https://legendev.org/tools/runway-calculator/) + +- [Observed] It asks for cash, gross monthly burn, monthly revenue, and monthly revenue growth. Results include a run-out date, net burn, cut-burn scenarios, and a month-by-month cash table. +- [Observed] Correct example: $500,000 cash, $60,000 gross burn, $10,000 revenue, and 5% monthly revenue growth returned 10.6 months of runway and $50,000 month-one net burn. +- [Observed] Edge case: changing revenue growth to 100% showed infinite runway and "default alive" from month 4, when revenue first covered the fixed $60,000 spend. +- [Observed] Wrong input: clearing the cash field showed "Enter the cash you have in the bank" and no calculation. +- [Documented] Its revenue formula uses the starting revenue for month 1 and applies growth in later months. The result includes a prorated final month. The page states that inputs stay in the browser. +- [Inferred] Fixed expenses make the forecast easy to check, but it cannot show a planned hire, cost inflation, or a one-time bill. +- [Unknown] I did not inspect its source code to verify the storage claim. + + 500000 cash, 60000 gross burn, 10000 revenue, 5% monthly growth -> 10.6 months; month-one net burn $50000 + 500000 cash, 60000 gross burn, 10000 revenue, 100% monthly growth -> infinite; revenue covers expenses in month 4 + blank cash -> validation message; no result + +### 3. Pilot + +URL: [Pilot startup runway calculator](https://pilot.com/calculators/startup-runway-calculator) + +- [Observed] The page has an embedded calculator with cash balance, monthly cash expenditure, and monthly recurring revenue. It shows net burn, runway, and a chart or table. +- [Observed] Correct example from its loaded values: $500,000 cash, $50,000 monthly spend, and $10,000 revenue returned $40,000 net burn and 12 months of runway. +- [Observed] Edge case: $1,000 cash, $50,000 spend, and $60,000 revenue returned cash-flow positive status and infinite runway. +- [Observed] Wrong input: entering "abc" for cash left the text in the field and the calculator treated cash as $0. With the edge-case revenue and expenses still set, it continued to report infinite runway. +- [Documented] Pilot's supporting guide recommends using several months of bank cash balances to estimate average burn, with three months as a minimum and up to twelve months available. +- [Inferred] The page's MRR label may cause a founder with annual prepayments to enter recognized recurring revenue instead of the cash received. +- [Unknown] The public calculator has no revenue or expense growth controls. + + 500000 cash, 50000 expenses, 10000 revenue -> $40000 net burn; 12 months + 1000 cash, 50000 expenses, 60000 revenue -> positive cash flow; infinite runway + "abc" cash -> treated as $0; no validation; stale positive-cash-flow result remains + +### 4. Futureproof + +URL: [Futureproof startup runway calculator](https://www.runfutureproof.com/startup-runway-calculator) + +- [Observed] The form asks for cash, monthly revenue, and monthly expenses. It shows a static runway, zero-cash date, and scenarios that cut or raise burn by 25%. +- [Observed] Correct example from its loaded values: $500,000 cash, $30,000 revenue, and $80,000 expenses returned 10 months and $50,000 monthly net burn. Its scenario cards showed 13 months after a 25% burn cut and 8 months after a 25% increase. +- [Observed] Edge case: $500,000 cash, $80,000 revenue, and $80,000 expenses showed infinite runway, $0 net burn, and no zero date. +- [Observed] Wrong input: setting cash to -$1,000 with equal revenue and expenses was accepted, and the result still said cash-flow positive with infinite runway. +- [Documented] The page says its calculator uses static numbers. The burn scenarios change monthly spend; they do not grow revenue month by month. +- [Inferred] The scenario cards help a founder compare cuts but do not represent the timing of a specific hire or bill. +- [Unknown] Its bank-connected tracking product requires a signup, so I did not inspect it. + + 500000 cash, 30000 revenue, 80000 expenses -> 10 months; $50000 net burn + 500000 cash, 80000 revenue, 80000 expenses -> infinite; $0 net burn + -1000 cash, 80000 revenue, 80000 expenses -> accepted; infinite runway + +### 5. Kenodo + +URL: [Kenodo startup burn rate and runway calculator](https://kenodo.com/tools/startup-burn-rate-runway-calculator) + +- [Observed] It asks for cash, total monthly expenses, monthly revenue, and monthly revenue growth. It compares current-value runway with a growth-adjusted runway and shows a break-even month. +- [Observed] The page's default currency was euros. Correct example: €600,000 cash, €50,000 expenses, €10,000 revenue, and 8% growth returned 15 months at current values, infinite runway with growth, and break-even in month 22. +- [Observed] Edge case: €600,000 cash, €50,000 expenses, €50,000 revenue, and 0% growth showed "Already break-even" and no cash-out date. +- [Observed] Wrong input: -€1,000 cash, €50,000 expenses, €0 revenue, and 0% growth returned 0.0 months and a zero-funds date in the current month, with no validation error. +- [Documented] Its notes say to exclude uncommitted financing and to handle a known one-time expense by subtracting it from cash or spreading it across monthly expenses. The calculator does not model expense growth. +- [Inferred] Its 9, 12, and 18 month status zones target fundraising advice more than a bootstrapped founder's cash-to-profit decision. +- [Unknown] The assumptions behind its growth-adjusted horizon are not fully described on the page. + + €600000 cash, €50000 expenses, €10000 revenue, 8% revenue growth -> 15 months static; infinite modeled; break-even month 22 + €600000 cash, €50000 expenses, €50000 revenue, 0% growth -> already break-even; no zero date + -€1000 cash, €50000 expenses, €0 revenue, 0% growth -> accepted; 0.0 months + +### 6. Calculator Academy + +URL: [Calculator Academy startup burn rate and runway calculator](https://calculator.academy/startup-burn-rate-runway-calculator/) + +- [Observed] It has three modes: runway from burn, maximum monthly burn for a target runway, and cash needed for a target runway. The advanced options include revenue growth, expense growth, and net new ARR. +- [Observed] Correct example: $500,000 cash, $120,000 expenses, and $40,000 monthly receipts returned $80,000 net burn, 6.3 months of runway, and a zero date of April 18, 2027. +- [Observed] A second correct forecast: $500,000 cash, $60,000 expenses, $10,000 revenue, 10% revenue growth, and 0% expense growth returned 10 months static and 11.8 months with growth. +- [Observed] Edge case: changing revenue to $120,000 against $120,000 expenses gave $0 net cash flow and "Default alive", without a finite runway or date. +- [Observed] Wrong input: after reset, calculating with blank cash showed "Enter your cash on hand to calculate runway." +- [Documented] The page says the advanced monthly rates compound and supports both expense and revenue growth. It also includes burn-multiple and target-runway calculations. +- [Inferred] Its three solve modes and burn multiple make it useful as a general finance calculator, but the main runway answer has to compete with several other tasks on one page. +- [Unknown] I did not verify whether the share feature includes form values. + + 500000 cash, 120000 expenses, 40000 revenue -> $80000 net burn; 6.3 months; April 18, 2027 + 500000 cash, 60000 expenses, 10000 revenue, 10% revenue growth, 0% expense growth -> 10 months static; 11.8 months projected + 500000 cash, 120000 expenses, 120000 revenue -> $0 net cash flow; "Default alive" + blank cash -> validation message; no result + +### 7. SaaS Runway + +URL: [SaaS Runway calculator](https://saasrunway.app/) + +- [Observed] It is tailored to solo SaaS costs. Inputs include cash, MRR, infrastructure, subscriptions, API spend, and monthly transaction count. It estimates payment fees and calls the result "Default alive". +- [Observed] Correct example: $10,000 cash, $800 revenue, $45 infrastructure, $80 subscriptions, $20 API spend, and 30 transactions returned $767.80 net revenue, $145 gross burn, and $622.80 monthly surplus. +- [Observed] Edge case: setting cash, revenue, all expenses, and transaction count to zero still returned infinite runway and an "Excellent" status. +- [Observed] Wrong input: a transaction count of -1 was clamped back to 0 without an error message. +- [Documented] The page uses a 2.9% plus $0.30 per transaction payment fee and lists example cloud, software, and AI API costs. Stripe documents 2.9% plus $0.30 for domestic online card payments under US standard pricing, with other rates for other payment types and markets. +- [Inferred] The cost categories fit a solo SaaS founder, but the price presets and "Default alive" result can create false precision. The tool has no monthly growth forecast. +- [Unknown] I did not verify whether the fee and service-price examples match each user's country or plan. + + 10000 cash, 800 revenue, 45 infrastructure, 80 subscriptions, 20 API, 30 transactions -> $767.80 net revenue; $145 gross burn; $622.80 surplus + 0 cash, 0 revenue, 0 expenses, 0 transactions -> infinite runway; "Excellent" + -1 transaction -> clamped to 0; no message + +## Comparison + +The table compares five of the most varied tested tools. The Pilot and Futureproof reviews above cover two more static calculators. + +| Feature or answer | Trezy | Legendev | Kenodo | Calculator Academy | SaaS Runway | What stack127 should do | +| --- | --- | --- | --- | --- | --- | --- | +| Base inputs | Cash, MRR, expenses, growth | Cash, gross burn, revenue, growth | Cash, expenses, revenue, growth | Cash, expenses, revenue | Cash, MRR, cost groups, transactions | Keep cash, cash receipts, and total outflow clear; make growth optional. | +| Revenue growth | Yes, 10% loaded | Yes | Yes | Optional monthly rate | No | Show the static answer beside the forecast. | +| Expense growth | No | No | No | Optional monthly rate | No | Model expense growth; state that hires and one-time bills can step instead of compound. | +| Cash timeline | Chart and table, 36 months | Monthly table and final-month proration | Static and growth-adjusted result | Zero date and dynamic runway | Current surplus and date | Show month-by-month cash and label any projection horizon. | +| Break-even | Month and default-alive label | Revenue crossover | Break-even month | Growth-adjusted break-even | Current positive/zero status | Show break-even month separately from cash-zero date. | +| Bootstrapped fit | Startup and MRR wording | General startup | Startup and agency wording | General finance modes | Solo SaaS expense categories | Explain founder pay, hosting, APIs, subscriptions, and actual cash collected without fixed price assumptions. | + +## Where competitors disagree + +- Positive net burn has two sign conventions in search results. Carta defines net burn as gross burn minus revenue, a positive number when cash is shrinking. CFI's page prints the reverse subtraction in one line, then uses a positive $70,000 burn in its example and divides $250,000 by that positive amount. The consistent interpretation is expenses minus customer cash receipts. Use that sign convention and state it next to the result. +- Static runway and growth-adjusted runway answer different questions. Static runway is a quick baseline that assumes current cash flows stay fixed: `cash available ÷ (monthly cash outflow - monthly customer receipts)`, when the denominator is positive. The dynamic result is a forecast under stated growth assumptions; use the month-by-month recurrence above. The Reddit SaaS question above describes the need for both: the founder wants the simple answer, then asks how to include revenue growth and planned hires. +- Month numbering changes the result. Trezy uses growth raised to the month number after showing month 0. Legendev holds month 1 at the starting revenue, then compounds. Both can be reasonable if the starting month is defined. Stack127 should show today's balance as month 0 and state whether the first projected month applies one growth step. +- "Default alive" is used inconsistently. Paul Graham's test asks whether the company reaches profitability before cash runs out, assuming expenses stay constant and revenue keeps growing at its recent rate. Some tools label zero net burn or current cash generation "default alive" without testing a path. Show "cash-flow positive now", "cash-flow break-even now", or "would reach cash-flow break-even before cash runs out" as separate outcomes. +- Some calculators ask for MRR or recurring revenue; others ask for customer cash collected. For cash runway, timing matters. Stripe's annual subscription example shows why an earned monthly revenue figure can differ from a cash payment received upfront. Ask for cash collected or label the MRR-based forecast as an estimate. +- Runway health bands vary. Calculator pages use under 6 months, under 9 months, or under 12 months as danger points, while others promote 18 to 24 months after a raise. These are fundraising heuristics, not a universal rule for a bootstrapped SaaS. + +## How stack127 can win + +- Answer the present-day question first. Show cash available, gross cash outflow, net cash burn, and static months at current rates. +- Keep static and projected runway side by side. Let founders enter monthly revenue growth and expense growth, with 0% as the no-change assumption. State which month first applies growth. +- Show the month-by-month cash path, the first month cash reaches zero, and the first month customer receipts cover cash costs. If cash never reaches zero within the modeled horizon, say "No cash-zero date in this projection" or "More than N months"; do not imply an exact infinite forecast. +- Separate positive cash flow, zero cash flow, and a projected cash-flow break-even. Do not use "default alive" for an equal revenue and expense input or for a result with no growth path. +- Treat MRR as a recurring-revenue metric. For cash runway, tell users to use customer payments actually collected, including annual prepayments in the month received, or explain that a monthly MRR input is only a proxy. +- Let a founder use one expense total. Give examples that fit a solo SaaS business, including founder pay, contractors, hosting and API use, subscriptions, payment fees, marketing, and taxes. Avoid hardcoded cost or payment-fee assumptions. +- Make the forecast honest about missing events. A monthly expense-growth rate will not capture a hire that starts in month 4, an annual bill, or a large tax payment. Support a planned cash event if scope allows, or name this limit next to the projection. +- Show a worked example and the formula. Explain that growth rates stay constant in the model and do not predict real sales. Offer a downside assumption so founders can see how a slower revenue path changes the date. +- Link the related tools by their actual job: [burn multiple](/burn-multiple-calculator) for cash spent per new ARR, [break-even MRR](/break-even-mrr-calculator) for the monthly recurring revenue needed to cover a cost list, and [SaaS metrics](/saas-metrics-calculator) for one month's MRR and retention picture. + +## Blockers + +None. The tool needs no paid API, maintained dataset, background job, or account. + +## Sources + +- [Paul Graham, "Default Alive or Default Dead?"](https://paulgraham.com/aord.html), checked 2026-10-10. +- [Carta burn rate calculator and formulas](https://carta.com/learn/resources/burn-rate-calculator/), checked 2026-10-10. +- [Pilot burn rate and runway calculator method](https://pilot.com/blog/burn-rate-calculation-startup-runway-calculator), checked 2026-10-10. +- [CFI cash runway formula and example](https://corporatefinanceinstitute.com/resources/valuation/cash-runway-explained/), checked 2026-10-10. +- [Stripe revenue recognition for subscriptions](https://stripe.com/resources/more/revenue-recognition-for-subscription-based-businesses-explained), checked 2026-10-10. +- [SBA cash-flow projection workshop](https://legacy.sba.gov/event/89487), checked 2026-10-10. +- [Stripe pricing](https://stripe.com/pricing), checked 2026-10-10. +- [Trezy runway calculator](https://www.trezy.io/en-us/runway-calculator), checked 2026-10-10. +- [Legendev runway calculator](https://legendev.org/tools/runway-calculator/), checked 2026-10-10. +- [Pilot startup runway calculator](https://pilot.com/calculators/startup-runway-calculator), checked 2026-10-10. +- [Futureproof startup runway calculator](https://www.runfutureproof.com/startup-runway-calculator), checked 2026-10-10. +- [Kenodo startup burn rate and runway calculator](https://kenodo.com/tools/startup-burn-rate-runway-calculator), checked 2026-10-10. +- [Calculator Academy startup burn rate and runway calculator](https://calculator.academy/startup-burn-rate-runway-calculator/), checked 2026-10-10. +- [SaaS Runway calculator](https://saasrunway.app/), checked 2026-10-10. +- [Reddit founder's runway question](https://www.reddit.com/r/startups/comments/wam4aa/), checked 2026-10-10. +- [Reddit SaaS question about revenue growth and runway](https://www.reddit.com/r/SaaS/comments/1s0fcvf/how_do_you_calculate_runway_when_your_revenue_is/), checked 2026-10-10. +- [Reddit micro-SaaS burn and expense discussion](https://www.reddit.com/r/microsaas/comments/1fftgg9/), checked 2026-10-10. +- [Reddit founder with almost no runway](https://www.reddit.com/r/startups/comments/1tbwz6t/), checked 2026-10-10. + diff --git a/content_docs/startup-runway-calculator/tool-spec.md b/content_docs/startup-runway-calculator/tool-spec.md new file mode 100644 index 00000000..6d079073 --- /dev/null +++ b/content_docs/startup-runway-calculator/tool-spec.md @@ -0,0 +1,658 @@ +# Startup runway calculator + +Route: `/startup-runway-calculator` + +Research: [research.md](research.md) + +## Why people will choose this tool + +The searcher is a bootstrapped founder asking "how long will my business cash last, and do I cover my costs before it runs out?" They have three numbers at most: cash in the bank, what customers paid this month, and what went out. Some also have a growth rate they hope for and a cost they know is coming. + +Where the current top results fail them: + +- They ask for MRR, not cash collected. An annual prepayment then shows up as a twelfth of itself, and a founder can't tell which number to use. +- Bad input passes. Trezy, Futureproof, and Kenodo accept negative cash. Pilot treats `abc` as $0 and keeps a stale result. SaaS Runway clamps `-1` to `0` silently. +- "Infinite runway" and "36+ months" hide a projection horizon. Equal cash in and out gets "revenue exceeds expenses" (Trezy) or "default alive" (Calculator Academy, SaaS Runway). +- Only Calculator Academy grows expenses, and nobody models a hire starting in month 4, a yearly bill, or a known inflow. Kenodo tells you to fake a one-off cost by subtracting it from cash. +- The month growth first applies is rarely stated, so two tools give 10.6 and 12.6 months for the same inputs. +- Health bands such as "under 6 months is danger" come from fundraising advice and don't fit a bootstrapped business. + +What this tool does better: + +1. Three required inputs give gross burn, net burn, runway in months, and the cash-out month. It asks for cash collected, not MRR. +2. An optional forecast with revenue growth, expense growth, and up to 10 planned changes: a monthly cost or income starting in a given month, a yearly bill, or a one-off payment. No tested competitor has planned changes. +3. Today's runway and the forecast runway sit side by side, with a month-by-month cash path and chart. +4. Four outcomes stay apart: cash-flow positive now, break-even now, covers costs before cash runs out, and runs out first. When cash runs out before break-even, it says how much more cash would bridge the gap. +5. One stated month rule: month 1 uses the numbers as entered, and growth first applies in month 2. This matches Legendev and Calculator Academy, so a founder who checks against them gets the same answer. +6. A fixed 60-month horizon, stated in the answer: "No cash-out date in the next 60 months", never "infinite". +7. Every invalid value is rejected beside its field. Nothing is clamped or treated as zero. +8. No health bands, no "good runway" verdict, no "default alive" label in the result, and no hardcoded fees or price presets. + +## Product + +### Promise and scope + +The primary user is an indie hacker or solo SaaS founder, usually bootstrapped, checking how long business cash lasts. Founder pay counts as cash paid out when they take it. + +The tool gives them runway at today's numbers and, optionally, under a forecast they set, with the month cash runs out and the month customer cash first covers costs. + +Launch scope: + +- One currency, used as a label only. The same six currencies as the other SaaS tools. +- Three required amounts: cash available now, cash collected from customers in a typical month now, and total cash paid out in a typical month now. +- An optional forecast: + - Monthly revenue growth and monthly expense growth, as percents. + - Up to 10 planned changes. Each is money in or money out, an amount, a start month from 1 to 60, and how often: once, every month from then on, or every 12 months from then on. An optional short name. +- A 60-month projection with the month-by-month cash path. + +Exclusions: + +- Personal or household savings, and founder living costs outside the business. A founder who pays themselves includes that pay in cash paid out. +- Fundraising, loans as financing, debt, overdrafts, interest, and negative opening cash. +- Payment timing within a month, invoice delays, and weekly cash forecasts. The model is monthly. +- Cost cuts on a date. A planned change adds money in or money out. It doesn't lower a base figure. The usage copy can say to lower the base outflow if a cut is already made. +- Planned changes that grow. Their amounts stay flat. +- Churn, MRR movements, and recognized revenue. Customer cash collected is the only income input. +- Payment fees, tax rates, and service prices. The user includes them in cash paid out. +- Health bands, "good runway" rules, benchmarks, and a "default alive" label in the result. +- URL sharing, export, and saved scenarios. + +### Technical contract + +**Fields and units.** All money is in the selected currency. Every flow is per month. + +| Input | Accepted value | Notes | +| --- | --- | --- | +| Currency | `USD`, `EUR`, `GBP`, `INR`, `CAD`, `AUD`. Default `USD`. | Formatting only. | +| Cash now | Money `0` to `1,000,000,000.00`. | Required. `0` is valid. | +| Cash collected a month | Money `0` to `1,000,000,000.00`. | Required. Customer payments received, including annual prepayments in the month they arrive. Not loans or investment. | +| Cash paid out a month | Money `0` to `1,000,000,000.00`. | Required. Everything that leaves the account: founder pay, contractors, hosting, APIs, software, payment fees, marketing, taxes. | +| Revenue growth | Percent `-100` to `100`, up to two decimals, leading `-` allowed. | Optional. Monthly, compounding. | +| Expense growth | Percent `-100` to `100`, up to two decimals, leading `-` allowed. | Optional. Monthly, compounding. | +| Planned change: direction | `in` or `out`. Default `out`. | | +| Planned change: amount | Money above `0`, at most `1,000,000,000.00`. | | +| Planned change: start month | Whole number `1` to `60`. | Month 1 is the first month from today. | +| Planned change: frequency | `once`, `monthly`, or `yearly`. Default `once`. | `monthly` repeats every month from the start month. `yearly` repeats every 12 months from it. | +| Planned change: name | Up to 60 characters. Optional. | Blank shows `Planned change N`. | + +At most 10 planned changes. + +**Parsing.** Use `lib/saas-economics/shared.ts` as it is today: `parseMoneyCents`, `parsePercentHundredths`, `parseWholeNumber`, `parseOptionalLabel`, `divideHalfAwayFromZero`, `divideCeil`, and `isSaasEconomicsCurrency`. + +- Trim outer whitespace. +- Reject commas, currency symbols, `%`, exponents, interior spaces, and a third decimal. +- Money never takes a sign. A leading `-` on money gets its own `negative` code so the message can say why. +- Whole numbers take digits only. +- Blank is never zero for the three required amounts. + +**Forecast group.** + +- The forecast is on when either growth field has a value or at least one planned change counts. +- A planned change counts unless its name, amount, and start month are all blank. A row with all three blank is ignored. Direction and frequency always have values. +- A counted row needs a valid amount and start month. Each blank or bad one gets its own field error. +- When the forecast is on, a blank growth field means `0%`. The result returns a `wasBlank` flag so the assumptions list can say `0% (left blank)`. A founder adding only a hire shouldn't have to type two zeros. +- When the forecast is off, the result has `projection: null`. + +**Notation.** `d = 10000`. + +| Symbol | Meaning | +| --- | --- | +| `C` | Cash now in cents | +| `R` | Cash collected a month in cents | +| `X` | Cash paid out a month in cents | +| `g` | Revenue growth in hundredths of a percent | +| `e` | Expense growth in hundredths of a percent | + +Keep everything exact in `bigint`. No floating point, logarithms, or `Math.pow` in the engine. + +**Today's numbers.** These ignore the forecast. + +- `grossBurnCents = X`. +- `netBurnCents = X − R`, signed. Positive means cash shrinks. This is the sign convention the page states. +- If `X − R > 0`, the outcome is `burning`: + - `runwayTenths = floor(10 × C ÷ (X − R))`. Always round down, so runway is never overstated. 12.59 shows `12.5`. + - `runwayIsExact` is true when `(10 × C) mod (X − R) = 0`. + - `cashOutMonth = divideCeil(C, X − R)`, a `bigint`. It's the month cash runs out in: `13` for 12.5 months, `10` for exactly 10.0. With `C = 0` it's `0`, meaning there's no cash for this month's gap. + - No cap. A tiny burn can give thousands of months. The frontend shows it in years. +- If `X − R = 0`, the outcome is `breakEven`. The balance holds at `C`. +- If `X − R < 0`, the outcome is `cashFlowPositive`, with `surplusCents = R − X`. + +**Month convention.** The coordinator asked for one clear rule. + +- Month `0` is today. Its balance is `C`, with no flows. +- Month `t` is the `t`-th month from today, for `t` from 1 to 60. Its balance is the cash left at its end. +- Month 1 uses the amounts as entered. Growth first applies in month 2: + + `collected(t) = round(R × (d + g)^(t−1) ÷ d^(t−1))` + + `paidOut(t) = round(X × (d + e)^(t−1) ÷ d^(t−1))` + +- `round` is half away from zero to whole cents. Compute it from the exact power, never by compounding a rounded value. Keep `(d + g)^(t−1)` and `d^(t−1)` as running `bigint` products. +- `planned(t)` is the signed sum of the planned changes in month `t`: `+amount` for `in`, `−amount` for `out`. + - `once`: month `t = start`. + - `monthly`: every `t ≥ start`. + - `yearly`: every `t ≥ start` with `(t − start) mod 12 = 0`. + - Planned amounts never grow. +- `change(t) = collected(t) − paidOut(t) + planned(t)`. +- `balance(t) = balance(t−1) + change(t)`. Balances are exact sums of the rounded month values, so the month table adds up by hand. +- `atTodaysRate(t) = C − t × (X − R)`, for the comparison line. It may go negative. +- The horizon is `STARTUP_RUNWAY_HORIZON_MONTHS = 60`. The engine never looks past month 60. + +Why month 1 holds the entered amounts: the founder enters what a normal month looks like now, so next month is that month. Applying growth from month 1 would mean today's burn never happens in the forecast. With `C = 0`, a burning founder with growth would then show no cash-out at all. This rule is also more cautious. It matches Legendev and Calculator Academy, and gives Calculator Academy's 11.8 months for its own example. + +**Cash out, in the forecast.** + +- The cash-out month is the first `t` from 1 to 60 where `balance(t) ≤ 0` and `change(t) < 0`. +- Within that month, flows count as spread evenly. The exact runway is `(t − 1) + balance(t−1) ÷ (−change(t))`. +- `runwayTenths = 10 × (t − 1) + floor(10 × balance(t−1) ÷ (−change(t)))`, rounded down. +- `runwayIsExact` is true when `(10 × balance(t−1)) mod (−change(t)) = 0`. +- `cashOutMonth = t`. The only exception is when `balance(t−1) = 0` and `t = 1`, with `C = 0`. Then runway is `0` and `cashOutMonth = 0`. +- With steady flows, the cash-out month is the ceiling of the exact runway, the same as the static rule. A one-off payment can move it later than the ceiling. With `C = 0`, no monthly flows, and a $1 payment in month 2, cash lasts all of month 1, so runway is `1.0` and the cash-out month is `2`. Result wording takes the month from `cashOut.month`, never from the runway. +- If no month qualifies, there's no cash out within 60 months. +- With 0% growth and no planned changes, this gives exactly the static runway. + +**Covering costs.** This compares the recurring flows only. One-off and yearly changes affect cash, not break-even. + +- `recurringIn(t) = collected(t) + ` the sum of `monthly` `in` changes active in `t`. +- `recurringOut(t) = paidOut(t) + ` the sum of `monthly` `out` changes active in `t`. +- Month `t` covers costs when `recurringIn(t) ≥ recurringOut(t)`. +- `firstCoversCostsMonth` is the first `t` from 1 to 60 that covers costs, or `null`. +- `coversCostsFromMonth` is the first month of the last run of covering months, when that run reaches month 60. If month 60 doesn't cover costs, it's `null`. This is the lasting break-even month. A hire that pushes costs above income again moves it later. Example 6 shows this. + +**Lowest balance and cash shortfall.** + +- `lowestBalance` is the smallest `balance(t)` for `t` from 0 to 60, with its month. Ties take the earliest month. +- `cashShortfallCents = −lowestBalance.cents` when the lowest balance is below 0 and `coversCostsFromMonth` isn't `null`. Otherwise it's `null`. +- It's the extra cash today that would keep the balance at or above zero until costs are covered for good. It's only shown when the path recovers, because a path that keeps falling has no amount that's "enough", only one that lasts to an arbitrary horizon. + +**Forecast outcome.** Checked in this order: + +1. A cash-out month exists: `runsOut`, with `cashOutMonth`, `runwayTenths`, `runwayIsExact`, and `coversCostsFromMonth`. + - If `coversCostsFromMonth` is after the cash-out month, cash runs out before costs are covered. + - If it's at or before the cash-out month, a planned payment pushed cash below zero even though monthly costs are covered. +2. `coversCostsFromMonth = 1`: `coversCostsThroughout`. Every month from 1 to 60 covers costs. +3. `coversCostsFromMonth > 1`: `coversCostsBeforeCashOut`, with that month. If `firstCoversCostsMonth` is earlier, costs were covered, then weren't, then were again. +4. Otherwise: `lastsBeyondHorizon`. Cash stays above zero for 60 months, but costs aren't covered for good by month 60. + +`matchesToday` is true when both growth rates are 0 and no planned change counts. The forecast then repeats today's numbers. + +**Never return** `Infinity`, `NaN`, `-0`, a negative runway, a month above 60 in the forecast, or a clamped value. + +**Action and handling.** + +- `Calculate` is the only primary action. The writing pass names it. +- A valid submit shows the results and focuses the lead. +- An invalid submit clears any old result, keeps every raw input, shows each error under its field, and focuses the first invalid field in reading order. +- Editing any input clears the result and announces `Inputs changed. Calculate again.` once. +- Reset restores the initial state, focuses cash now, and announces `Form reset.` + +**Privacy and security.** Everything runs in the browser. No value goes to a server, the URL, storage, logs, or analytics. The clean URL never changes. No API route, dataset, or job. + +### Backend contract + +Reuses `lib/saas-economics/shared.ts` as it is today. Runtime rules match the sibling engines: strip-types safe, explicit `.ts` imports, no DOM, network, clock, or randomness. + +#### `lib/startup-runway-calculator/engine.ts` + +```ts +import { type SaasEconomicsCurrency } from "../saas-economics/shared.ts"; + +export const STARTUP_RUNWAY_HORIZON_MONTHS = 60; +export const STARTUP_RUNWAY_MAX_PLANNED_CHANGES = 10; +export const STARTUP_RUNWAY_NAME_MAX_LENGTH = 60; +export const STARTUP_RUNWAY_DIRECTIONS = ["in", "out"] as const; +export type StartupRunwayDirection = (typeof STARTUP_RUNWAY_DIRECTIONS)[number]; +export const STARTUP_RUNWAY_FREQUENCIES = ["once", "monthly", "yearly"] as const; +export type StartupRunwayFrequency = (typeof STARTUP_RUNWAY_FREQUENCIES)[number]; + +// Every field is the raw string from the form. +export type StartupRunwayPlannedChangeInput = { + id: string; // stable row id from the frontend, used in error keys + name: string; + direction: string; + amount: string; + startMonth: string; + frequency: string; +}; + +export type StartupRunwayInput = { + currency: string; + cashNow: string; + collectedPerMonth: string; + paidOutPerMonth: string; + revenueGrowthPercent: string; + expenseGrowthPercent: string; + plannedChanges: StartupRunwayPlannedChangeInput[]; +}; + +export type StartupRunwayFieldKey = + | "form" | "currency" | "cashNow" | "collectedPerMonth" | "paidOutPerMonth" + | "revenueGrowthPercent" | "expenseGrowthPercent" | "plannedChanges" + | `plannedChanges.${string}.name` | `plannedChanges.${string}.direction` + | `plannedChanges.${string}.amount` | `plannedChanges.${string}.startMonth` + | `plannedChanges.${string}.frequency`; + +export type StartupRunwayErrorCode = + | "missing" | "format" | "negative" | "range" | "zero" | "option" | "limit" | "duplicateId" | "form"; + +export type StartupRunwayErrors = Partial>; + +export type StartupRunwayToday = + | { + kind: "burning"; + netBurnCents: bigint; // > 0 + runwayTenths: bigint; // floor; 125n = 12.5 months + runwayIsExact: boolean; + cashOutMonth: bigint; // ceil(C ÷ net burn); 0n when cash is 0 + } + | { kind: "breakEven" } + | { kind: "cashFlowPositive"; surplusCents: bigint }; // > 0 + +export type StartupRunwayPlannedChange = { + id: string; + name: string; // trimmed, or "Planned change N" (N = position among counted rows) + nameWasBlank: boolean; + direction: StartupRunwayDirection; + amountCents: bigint; // > 0 + startMonth: number; // 1..60 + frequency: StartupRunwayFrequency; +}; + +export type StartupRunwayMonth = { + month: number; // 0..60 + collectedCents: bigint; // 0 in month 0 + paidOutCents: bigint; // 0 in month 0 + plannedCents: bigint; // signed; positive = money in; 0 in month 0 + changeCents: bigint; // signed; collected − paidOut + planned + balanceCents: bigint; // signed + atTodaysRateCents: bigint; // signed; C − month × (X − R) + coversCosts: boolean | null; // null in month 0 +}; + +export type StartupRunwayCashOut = { + month: number; // 0..60; 0 only when cash now is 0 + runwayTenths: bigint; + runwayIsExact: boolean; +}; + +export type StartupRunwayProjection = { + revenueGrowthHundredths: bigint; // signed + revenueGrowthWasBlank: boolean; + expenseGrowthHundredths: bigint; // signed + expenseGrowthWasBlank: boolean; + plannedChanges: StartupRunwayPlannedChange[]; // counted rows, input order + matchesToday: boolean; + cashOut: StartupRunwayCashOut | null; + firstCoversCostsMonth: number | null; + coversCostsFromMonth: number | null; + lowestBalance: { month: number; cents: bigint }; + cashShortfallCents: bigint | null; // > 0 when present + outcome: + | { kind: "runsOut" } // details in cashOut and coversCostsFromMonth + | { kind: "coversCostsThroughout" } + | { kind: "coversCostsBeforeCashOut"; month: number } + | { kind: "lastsBeyondHorizon" }; + path: StartupRunwayMonth[]; // always 61 entries, months 0..60 +}; + +export type StartupRunwayValues = { + currency: SaasEconomicsCurrency; + cashNowCents: bigint; + collectedPerMonthCents: bigint; + grossBurnCents: bigint; // = paid out a month + netBurnCents: bigint; // signed; positive = cash shrinks + today: StartupRunwayToday; + projection: StartupRunwayProjection | null; // null = forecast off +}; + +export type StartupRunwayCalculation = + | { status: "valid"; values: StartupRunwayValues; errors: Record } + | { status: "invalid"; values: null; errors: StartupRunwayErrors }; + +export function calculateStartupRunway(input: unknown): StartupRunwayCalculation; +``` + +`calculateStartupRunway` accepts `unknown`, collects every error in one pass, and never throws for user input. + +- A non-object input gives `errors.form`. +- A missing string property counts as blank. +- A missing or non-array `plannedChanges` counts as an empty list. +- A row that isn't an object, or has a non-string or empty `id`, gives `errors.form`. +- Two rows with the same `id` give `errors.plannedChanges` with code `duplicateId`. +- More than 10 rows, counted or not, give `errors.plannedChanges` with code `limit`. The frontend never sends more than 10. + +Error codes and messages. The engine owns these strings, and the writing pass may reword them: + +| Field | Case | Code | Message | +| --- | --- | --- | --- | +| cashNow | blank | `missing` | `Enter the cash you have now. Use 0 if none.` | +| collectedPerMonth | blank | `missing` | `Enter the cash customers paid in a month. Use 0 if none.` | +| paidOutPerMonth | blank | `missing` | `Enter the cash you pay out in a month. Use 0 if none.` | +| any money | leading `-` | `negative` | `Enter 0 or more. This tool doesn't model overdrafts or debt.` | +| any money | bad format | `format` | `Use digits with up to two decimals. No commas or currency symbols.` | +| any money | above max | `range` | `Enter an amount up to 1,000,000,000.00.` | +| revenueGrowthPercent, expenseGrowthPercent | bad format or out of range | `format` / `range` | `Enter a monthly rate from -100 to 100, with up to two decimals.` | +| plannedChanges.*.amount | blank | `missing` | `Enter an amount, or remove this change.` | +| plannedChanges.*.amount | `0` | `zero` | `Enter an amount above 0.` | +| plannedChanges.*.startMonth | blank | `missing` | `Enter the month it starts, from 1 to 60.` | +| plannedChanges.*.startMonth | bad format, `0`, or above 60 | `format` / `range` | `Enter a whole month from 1 to 60.` | +| plannedChanges.*.name | control characters | `format` | `Remove line breaks and special characters from the name.` | +| plannedChanges.*.name | over 60 characters | `range` | `Use 60 characters or fewer.` | +| plannedChanges.*.direction, frequency; currency | not a listed value | `option` | `Choose one of the listed options.` | +| plannedChanges | more than 10 rows | `limit` | `You can add up to 10 planned changes.` | +| plannedChanges | repeated row id | `duplicateId` | `Something in the form is out of date. Reset and try again.` | +| form | non-object input or bad row | `form` | `Something in the form is out of date. Reset and try again.` | + +The negative-money message applies to planned-change amounts too. The direction choice is how to enter money in. + +### Known examples + +Calculated independently with exact fractions, using the month rule above. USD. "Forecast off" means both growth fields blank and no planned changes. + +| # | Inputs | Expected | +| --- | --- | --- | +| 1 | Cash `500000`, collected `10000`, paid out `50000`, forecast off | Gross burn `$50,000.00`; net burn `$40,000.00`; `burning`, `runwayTenths 125` (12.5), `runwayIsExact true`, `cashOutMonth 13`; `projection null`. | +| 2 | Cash `500000`, collected `10000`, paid out `60000`, revenue growth `10`, expense growth `0` | Today: net burn `$50,000.00`, `runwayTenths 100`, exact, `cashOutMonth 10`. Forecast: `runsOut`; cash out month `12`, `runwayTenths 118` (exact 11.8043…, = 37,146,880 ÷ 3,146,883), not exact. `firstCoversCostsMonth 20`, `coversCostsFromMonth 20`. Lowest balance month `19`, `-$128,409.11`; `cashShortfallCents 12840911`. Path: month 1 collected `$10,000.00`, balance `$450,000.00`; month 2 collected `$11,000.00`, balance `$401,000.00`; month 11 collected `$25,937.42`, balance `$25,311.67`; month 12 collected `$28,531.17`, balance `-$6,157.16`; month 20 collected `$61,159.09`, balance `-$127,250.02`. Matches Calculator Academy's 11.8. | +| 3 | Same as 2 with revenue growth `5` | `runsOut`; cash out month `11`, `runwayTenths 105` (exact 10.5897…). Covers costs from month `38`. Lowest month `37`, `-$703,718.62`. Legendev shows 10.6 because it rounds to nearest; this tool rounds down. | +| 4 | Cash `1000`, collected `60000`, paid out `50000`, forecast off | `cashFlowPositive`, `surplusCents 1000000`; net burn `-$10,000.00`. | +| 5 | Cash `1000`, collected `30000`, paid out `30000`, forecast off | `breakEven`; net burn `$0.00`. Not "cash-flow positive". | +| 6 | Cash `20000`, collected `3000`, paid out `2500`, revenue growth `5`, expense growth blank; changes: out `2000` monthly from month `4` (a hire); out `1200` yearly from month `3` (an annual renewal) | Today `cashFlowPositive`, surplus `$500.00`. Forecast: `expenseGrowthWasBlank true`, rate `0`. No cash out. `firstCoversCostsMonth 1`, `coversCostsFromMonth 10` (month 9 collects `$4,432.37` against `$4,500.00`; month 10 collects `$4,653.98`). Outcome `coversCostsBeforeCashOut`, month `10`. Lowest month `9`, `$17,379.70`; `cashShortfallCents null`. Month 3 planned `-$1,200.00`, balance `$20,757.50`; month 15 planned `-$3,200.00`, balance `$20,835.69`. | +| 7 | Cash `30000`, collected `5000`, paid out `10000`, revenue growth `10`, expense growth `0` | Today 6.0 months, exact, `cashOutMonth 6`. Forecast: no cash out. Covers costs from month `9`. Lowest month `8`, `$7,179.45`. `coversCostsBeforeCashOut`, month `9`. | +| 8 | Cash `100000`, collected `6000`, paid out `8000`, revenue growth `3`, expense growth `5` | Today `runwayTenths 500`, `cashOutMonth 50`. Forecast `runsOut`, cash out month `20`, `runwayTenths 196` (exact 19.659…). `coversCostsFromMonth null`, `cashShortfallCents null`. | +| 9 | Cash `0`, collected `500`, paid out `1000`, forecast off | `burning`, `runwayTenths 0`, exact, `cashOutMonth 0`. | +| 10 | Cash `0`, collected `900`, paid out `1000`, revenue growth `20`, expense growth `0` | Today `runwayTenths 0`, `cashOutMonth 0`. Forecast `runsOut`, cash out month `0`, `runwayTenths 0`. Covers costs from month `2`. Lowest month `1`, `-$100.00`; `cashShortfallCents 10000`. | +| 11 | Cash `500000`, collected `9900`, paid out `10000`, revenue growth `0`, expense growth `0` | Today `runwayTenths 50000`, `cashOutMonth 5000`. Forecast `matchesToday true`, `lastsBeyondHorizon`; month 60 balance `$494,000.00`. | +| 12 | Cash `100000`, collected `5000`, paid out `4000`, forecast: one change, out `150000` once in month `6` | Today `cashFlowPositive`. Forecast `runsOut`, cash out month `6`. Balance month 5 `$105,000.00`; month 6 change `-$149,000.00`, balance `-$44,000.00`; `runwayTenths 57` (exact 5.7046…). `coversCostsFromMonth 1`, so a planned payment caused it. Lowest month `6`, `-$44,000.00`; `cashShortfallCents 4400000`. | +| 13 | Revenue growth `-100`, cash `10000`, collected `1000`, paid out `500` | Valid. Month 1 collects `$1,000.00`; months 2 to 60 collect `$0.00`. | + +Invalid, each with `status: "invalid"` and one error per named field: + +- Cash `-1000`: `cashNow` `negative`. +- Cash blank: `cashNow` `missing`. Cash `abc`, `$500,000`, `500,000`, `1e5`, or `100.001`: `format`. +- Paid out `1000000000.01`: `range`. +- Revenue growth `-100.01` or `100.5%`: `revenueGrowthPercent` `range` or `format`. +- A planned change with amount `500` and blank month: its `startMonth` `missing`. Month `61` or `0`: `range`. Month `4.5`: `format`. +- A planned change with amount `0`: `zero`. Amount `-200`: `negative`. +- A planned change with name, amount, and month all blank: ignored, and the forecast stays off if nothing else is set. Valid. +- Frequency `weekly` or direction `both`: `option`. +- 11 rows: `plannedChanges` `limit`. +- Input `null`: `form`. + +### Ownership + +Sol builds the backend: + +- `lib/startup-runway-calculator/engine.ts`. Reuse `lib/saas-economics/shared.ts` and don't change it. +- `tests/pages/startup-runway-calculator/startup-runway-calculator.test.mjs`, covering the examples above, including the full month 0 to 20 path for example 2. +- The development tool-page row, when the coordinator asks. + +Opus builds the frontend: + +- `app/(site)/(tools)/(saas)/startup-runway-calculator/page.tsx`, `layout.tsx`, `startup-runway-calculator.tsx`, and `startup-runway-calculator.module.css`. +- `app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator.{spec.json,svg,ts}`, plus its line in `lib/card-art.ts`. Other agents are adding lines there for sibling tools, so the coordinator should order those edits. + +No URL-restore wrapper. + +### Catalog placement + +Primary and only catalog: `/saas-tools`. The reader's job is "will my SaaS business cash last until it covers its costs?" It sits beside the burn multiple calculator, which measures cash spent per new ARR, and the break-even MRR calculator, which finds the MRR a cost list needs. Neither says when cash runs out. The coordinator sets the final order for the batch. + +No secondary catalog in the codebase fits a general founder-finance task. If one is planned later, this tool belongs there too. + +Catalog `toolSpot` key: `startup-runway-calculator`. Suggested motif name: `runwayLiftoff`. + +- Takeaway: get airborne, covering your costs, before the runway, your cash, ends. +- Focal subject: an airstrip drawn in perspective, from the lower left toward the right. It has two edge lines, a dashed center line, and a clear end: a short row of bars across the strip, then nothing. +- One supporting relationship: a small paper plane lifting off the strip, clearly before the end bars, with a short dashed climb path from the strip to the plane. +- Tone: calm and hopeful, not alarming. Accent on the plane and its climb path only. Neutral ink for the strip. +- Keep out: numbers, currency signs, coins, calendars, clocks, hourglasses, fuel gauges, flames, charts or axes, and any crash or cliff. The MRR goal card uses a rising line of dots, and burn multiple and the other SaaS cards use scales, stacks, and curves. +- Wordless, and legible at card size in both themes. The strip's end and the plane's gap above it must still read at the smallest card width. + +Page header icon: `PlaneTakeoff` from `lucide-react`, which matches the card. + +## Interface + +### Flow + +- Question: "How long will my business cash last, and will customer cash cover my costs before it runs out?" +- Minimum input: cash now, cash collected a month, cash paid out a month. +- Lead answer: runway in months. When the forecast is on, today's runway and the forecast runway show side by side as one comparison. +- Primary action: `Calculate`. The writing pass sets the final label. +- Control mode: `disclosure`. The three required fields and the two growth fields stay in the main flow. Planned changes sit in a `UiDisclosure`, because they're an optional refinement that does nothing when empty. Its summary shows the count, such as `2 planned`. + +Workbench order: + +1. **Today.** Cash now, with the currency select at the top right of the group, and above it on mobile. Then cash collected a month and cash paid out a month, on one row. + - Helper under cash collected: `Payments received, not MRR. Count a yearly plan in the month it's paid.` This prevents the main mix-up in the research. + - Helper under cash paid out: `Everything that leaves the account, including your own pay.` + - The writing pass may tighten both. +2. **Forecast**, marked optional. Revenue growth and expense growth on one row, each with a `% a month` suffix. One line of plain text under the row: `Month 1 uses the numbers above. Growth starts in month 2.` This is the rule competitors hide, and it changes the answer. +3. **Planned changes**, a `UiDisclosure` holding a `UiRepeatableList`. + - Each row: direction (`Money out` / `Money in`), amount with a currency-code suffix, start month with a `month` prefix label, frequency (`Once` / `Every month` / `Every 12 months`), and an optional name. + - `Add a planned change` sits after the rows. It's disabled at 10, with the note `Up to 10 planned changes.` + - A new row gets direction `out` and frequency `once`, and focus goes to its amount. +4. `Calculate` and a quiet `Reset`, in a `UiActionRow`. +5. One divider, then results. + +### Components + +Existing shared components. No new shared component is needed: + +| Use | Component | +| --- | --- | +| Currency | `UiSelect` from `@/app/ui/controls` | +| Money, percents, months, names | `UiTextField` from `@/app/ui/controls`, with `inputMode="decimal"` or `"numeric"`, a suffix (currency code, `% a month`), autocomplete and spellcheck off | +| Planned change direction and frequency | `UiSelect` from `@/app/ui/controls`. Two selects keep a row compact. A choice group per row would wrap badly on mobile. | +| Planned changes | `UiDisclosure` and `UiRepeatableList` from `@/app/ui/controls`. The list already handles add, remove, and focus, as in the break-even MRR calculator. | +| Actions | `UiActionRow` from `@/app/ui/display`, `UiButton` from `@/app/ui/controls` | +| Lead, one or two | `UiPrimaryResult` from `@/app/ui/display` | +| Burn figures and forecast facts | `UiMetricGroup` from `@/app/ui/display` | +| Cash path chart | `UiLiveChart` from `@/app/ui/charts/chart-client`, kind `line` | +| Month-by-month table | `UiDataTable` from `@/app/ui/display`, inside a `UiDisclosure` | +| Assumptions | `UiKeyValueList` from `@/app/ui/display` | +| Change, reset, failure | `UiStatus` from `@/app/ui/display` | + +Why no new component: + +- The side-by-side lead is two `UiPrimaryResult` outputs in a route grid. +- Zero cash needs no reference line. The y axis shows `$0`, and the two series show where each path crosses it. +- The month axis is a numeric x with `xFormat: "number"`, the same as the MRR goal tool. +- `git status` shows no other agent has changed `app/ui/` or the gallery, but none of it needs changing. + +Tool-specific component, in the route file: + +- `StartupRunwayCalculator` owns raw input strings, planned-change rows with stable ids, error mapping by `StartupRunwayFieldKey`, focus, status, the calendar month label, and result rendering. It calls only `calculateStartupRunway`. + +### Result regions + +1. **Lead.** + - Forecast off: one `UiPrimaryResult`, label `Runway at today's numbers`. + - Forecast on, and `matchesToday` false: two `UiPrimaryResult` outputs side by side. `At today's numbers` comes first, then `With your forecast`. They're one comparison, so neither repeats elsewhere. + - Forecast on, and `matchesToday` true: the single lead, plus one line: `Your forecast matches today's numbers.` No chart or second lead. + - Tone stays `neutral` in every case. Running out isn't an input error, and there are no health colors. + + Value and detail by outcome. The writing pass sets the final words. The facts are fixed: + + | Outcome | Value | Detail | + | --- | --- | --- | + | Today `burning` | `12.5 months` | `Cash runs out in month 13, around November 2027.` | + | Today `burning`, cash `0` | `0 months` | `There's no cash to cover this month's $500.00 gap.` | + | Today `breakEven` | `Break-even` | `Cash in equals cash out, so the balance holds at $1,000.00.` | + | Today `cashFlowPositive` | `Cash-flow positive` | `Cash grows by $10,000.00 a month.` | + | Forecast `runsOut`, covers later | `11.8 months` | `Cash runs out in month 12. Customer cash covers costs from month 20.` | + | Forecast `runsOut`, never covers | `19.6 months` | `Cash runs out in month 20. Costs aren't covered by month 60.` | + | Forecast `runsOut`, planned payment | `5.7 months` | `A planned payment takes cash below zero in month 6.` | + | Forecast `coversCostsBeforeCashOut` | `Covers costs in month 9` | `Cash stays above zero. Lowest point $7,179.45 in month 8.` When `firstCoversCostsMonth` is earlier: `Costs are covered again from month 10.` | + | Forecast `coversCostsThroughout` | `Covers costs every month` | `No cash-out date in the next 60 months.` | + | Forecast `lastsBeyondHorizon` | `More than 60 months` | `No cash-out date in the next 60 months, but costs still aren't covered.` | + + Number and date rules: + - Runway shows one decimal from `runwayTenths`. When not exact, `about` can lead the detail. `0 months` shows without a decimal. + - A runway of 12 months or more adds `about N years M months` from the whole months, such as `5,000.0 months (about 416 years)`. + - The calendar month is shown only for runway up to 60 months, from the browser's local date when `Calculate` runs. Add the whole months to today, then the tenths times 3 days, and show `Month YYYY` only. It's always labeled `around`. It's never stored or put in the URL. + - `cashOutMonth 0` reads as `now`, never `month 0`. + +2. **Cash gap.** Shown only when `cashShortfallCents` isn't `null`. One `UiMetricGroup` item: `Extra cash needed to get there`, with the value `$128,409.11`, and the unit `lowest point in month 19`. It's the bootstrapper's real follow-up: how much to bridge. It isn't shown when the path never recovers. + +3. **Monthly burn.** One `UiMetricGroup`: + - `Cash paid out (gross burn)`: `$50,000.00`. + - `Cash collected`: `$10,000.00`. + - `Net burn`: `$40,000.00`. When negative, the label becomes `Net cash in` with a positive value. When zero, the value is `$0.00`. + - These restate inputs on purpose. Gross and net burn are the terms searchers use, and this is the only place they appear. + +4. **Chart.** Forecast on, and `matchesToday` false. `UiLiveChart`, `kind: "line"`, `format: "currency"`, `xFormat: "number"`, title `Cash balance by month`. + - Series `With your forecast` from `path.balanceCents ÷ 100`. + - Series `At today's numbers` from `path.atTodaysRateCents ÷ 100`. + - Months 0 to an end month: `min(60, max(12, keyMonth + 3))`. `keyMonth` is the largest of the cash-out month, `coversCostsFromMonth`, and the lowest-balance month. `lastsBeyondHorizon` shows all 60. + - No `range`. Hover reads both values at a month, and series toggles help. + - Caption names the rates, such as `Revenue +10% a month, costs +0% a month, 2 planned changes.` + +5. **Month by month.** Forecast on. A `UiDisclosure` named `Month by month`, holding a `UiDataTable` of all 61 months. + - Columns: `Month`, `Collected`, `Paid out`, `Planned`, `Balance`. `Planned` shows `—` when it's 0. Number columns align right. + - Rows are marked in text, not color alone: `Cash runs out` on the cash-out month, and `Covers costs` on `coversCostsFromMonth`. Use a short suffix in the month cell, such as `12 · Cash runs out`. + - This gives exact numbers, because the chart has over 60 values and drops its hidden table. + +6. **Assumptions.** A `UiKeyValueList`. Forecast off: currency and the month rule are left out, and only one line shows: `Runway = cash now ÷ (cash paid out − cash collected), with both held flat.` Forecast on: + - `Revenue growth` `10% a month`, or `0% a month (left blank)`. + - `Expense growth`, the same way. + - `Planned changes`: each as `Hire · money out · $2,000.00 every month from month 4`. + - `Month rule`: `Month 1 uses the numbers you entered. Growth applies from month 2.` + - `Horizon`: `60 months`. + +Facts aren't repeated: + +- The runway figure appears only in the lead. +- The cash-out month appears in the lead detail and as a marked table row, which is the row the reader needs to find. +- The lowest balance appears in the lead detail or the cash gap, never both. When the cash gap shows, the lead detail doesn't mention the lowest point. +- Planned changes appear once, in assumptions. The table shows their monthly effect, which is a different fact. + +### States + +- **Initial.** Everything blank. USD. Planned changes collapsed, with no rows. No result. +- **Invalid.** + - Clear the result and keep every value. + - Field errors go under their fields. + - A planned-change error opens the disclosure if it's collapsed. + - Focus the first invalid field in reading order: cash now, collected, paid out, the growth rates, then each planned-change row from left to right. + - A `limit` or `duplicateId` error shows by the add action. +- **Valid.** Show the results, focus the lead (the first output when there are two), and let the focused output announce it. +- **Partial.** Forecast off shows the lead, monthly burn, and the one-line assumption. No chart or table. +- **Runs out now.** Cash `0` while burning is a valid result, `0 months`, not an error. +- **Edited after result.** Clear the result, keep focus, and announce `Inputs changed. Calculate again.` once. That includes adding or removing a row. +- **Disclosure collapsed with rows.** The summary shows the count, so a hidden change that drives the answer is never invisible. +- **Unexpected failure.** Keep the inputs and show `UiStatus` danger by the actions: `Couldn't calculate. Try again.` +- **Reset.** Restore the initial state, remove every row, focus cash now, and announce `Form reset.` +- **Restored.** The clean URL never changes. A back-forward cache restore clears on the next edit. +- **Long values.** `$1,000,000,000.00` inputs, 100% growth balances with 30 or more digits, and `5,000.0 months` all show in full, never abbreviated. They wrap inside metrics without page overflow. Table cells may scroll inside the table frame. +- **Loading and retry.** Don't apply. The calculation is synchronous. + +### Responsive and accessible behavior + +Desktop: + +- Use the measured workbench. +- Collected and paid out share a row. The two growth rates share a row. +- A planned-change row fits on one line: direction, amount, month, frequency, and name, with remove in a fixed last column. +- The two leads sit side by side in equal columns. +- Results stack in the order above. The chart is full width at about 280px tall. + +Mobile: + +- Every field stacks in task order. +- A planned-change row wraps at a set breakpoint into two lines: direction and amount, then month, frequency, and name. Remove stays in a fixed column at the top right. +- The two leads stack, today's first. +- Buttons go full width. +- The chart keeps full width, and series names wrap under it. +- `UiDataTable` turns into labeled rows. No page-level horizontal overflow, and grid children get `min-width: 0`. + +Keyboard and screen readers: + +- Tab order: currency, cash now, collected, paid out, revenue growth, expense growth, the planned-changes toggle, each row's fields and remove action, add, then the actions. +- Enter in a field submits. +- Remove buttons are named `Remove planned change 2`, or by name when one is set. +- One polite live region announces changes, resets, and failures. The result is announced through the focused lead. +- The month table gives exact chart values, because the chart has over 60 values. + +Check at desktop and mobile in both themes: + +- Example 1, forecast off. +- Example 2: side by side, with the cash gap. +- Example 6: planned changes, covered again. +- Example 12: a one-off payment drives cash out. +- Examples 4 and 5: positive and break-even. +- Example 9: zero cash. +- Example 11: matches today, and the long static runway. +- Pasted `$500,000` and `-1000`, as invalid inputs. +- 10 planned-change rows, with add disabled. + +--- + +## Content + +Written with `skills/stack127/tools/stack127-tool-writing/SKILL.md` and `stack127_docs/writing-style.md`. + +### Search intent + +1. **What the searcher wants:** a bootstrapped founder wants to know how many months their business cash lasts, the month it runs out, and whether customer cash catches up with costs first. +2. **Where top results fail:** they ask for MRR, so annual prepayments are counted wrong. They print "infinite runway" or "default alive" for break-even. They hide the month growth starts. They apply fundraising health bands to bootstrapped businesses. None model a hire or a yearly bill. +3. **What this page does better:** it asks for cash collected and explains why, keeps today's runway beside the forecast, states the month rule, handles planned changes, and gives the extra cash needed to reach break-even. + +### Title and search text + +- Title: `Startup runway calculator: how long will your cash last?` +- SEO title: `Startup runway calculator: how long your cash lasts` +- SEO description: `See how many months your business cash lasts and the month it runs out. Add revenue growth, a hire, or a yearly bill to see if you cover costs first.` +- Card name: `Startup runway calculator` +- Card description: `See how many months your cash lasts, and whether customer cash covers your costs before it runs out.` + +### Question map + +| Reader question | Answered in | +| --- | --- | +| How long will my cash last? | Tool lead, with the cash-out month | +| What do I enter, and where do I find it? | Usage: "What to enter" | +| Do I count my own pay? | Field hint, then article | +| MRR or cash collected? | Field hint, then article, with Stripe's $1,200 annual example | +| When does growth start, and why month 2? | Form helper, then usage | +| How do I add a hire, a yearly renewal, or a one-off bill? | Usage table | +| What does each result mean? | Usage table of outcomes, then the worked example | +| How is runway calculated? Gross vs net burn? | Article formula and sign convention | +| What is a good runway? | Article: no universal number; Paul Graham's test described and linked | +| Why does another calculator give a different number? | Article: MRR, growth start month, rounding, horizon | +| What doesn't it cover? | Article closing section with next tools | + +### Outline + +- **Intro:** what you get, the three figures to have ready, and the optional forecast. +- **Usage:** + - `What to enter`: bullets for the three figures. Use a three-month average when months vary. + - `Adding a forecast`: monthly compounding rates, the month 2 rule and why, a table of planned changes (hire, renewal, one-off bill, money in). Amounts don't grow. A cut already made goes in the base figure. + - `How to read the result`: today vs forecast, rounding down, a table of the four forecast outcomes, the extra cash figure, and one worked example. +- **Article:** + - `How is startup runway calculated?` Formula box, gross vs net burn, the sign convention, the monthly forecast rule. + - `Should you use MRR or cash collected?` Stripe's $1,200 example. + - `Should you count your own pay?` Yes, and how to add pay you'll start later. + - `What is a good runway for a bootstrapped startup?` No universal number. Paul Graham's default alive test, described accurately and linked, and how to run it here. + - `Why does another calculator show a different runway?` A short list. + - `What this calculator doesn't cover` and next tools. + +### Left out on purpose + +- Input format rules and limits: field errors. +- The `C = 0` cases, the month-0 wording, and the covered-then-uncovered case: result lines. +- Exact rounding of grown amounts and the within-month spread: spec only. The article says runway rounds down and the cash-out month is a whole month. +- Calendar month estimation: the result labels it "around". +- Health bands, benchmarks, payment fee rates, and a "default alive" label: not used anywhere. + +### Facts and sources + +- Gross and net burn definitions: [Carta](https://carta.com/learn/resources/burn-rate-calculator/). +- Three months of bank data to estimate burn: [Pilot](https://pilot.com/blog/burn-rate-calculation-startup-runway-calculator). +- $1,200 annual plan recognized at $100 a month: [Stripe](https://stripe.com/resources/more/revenue-recognition-for-subscription-based-businesses-explained). +- Default alive: [Paul Graham](https://paulgraham.com/aord.html). Expenses constant, revenue growing at its recent rate, profitable before cash runs out. +- 10% a month compounds to about 214% a year: arithmetic, 1.1^12 ≈ 3.14. +- Worked example numbers come from the engine: cash 20,000, collected 3,000, paid out 6,000, revenue growth 6%, a 1,200 renewal yearly from month 3. Today 6.6 months, cash out month 7. Forecast 8.4 months, cash out month 9, covers costs from month 13, extra cash 2,590.17 with the lowest point in month 12. + +Claims to avoid: "infinite", "default alive" as a label, any healthy runway number, built-in fees, predictions of real sales. + +Links: `/break-even-mrr-calculator`, `/mrr-goal-calculator`, `/burn-multiple-calculator`, `/saas-metrics-calculator`. diff --git a/lib/annual-plan-discount-calculator/engine.ts b/lib/annual-plan-discount-calculator/engine.ts new file mode 100644 index 00000000..d2d65639 --- /dev/null +++ b/lib/annual-plan-discount-calculator/engine.ts @@ -0,0 +1,306 @@ +import { + type SaasEconomicsCurrency, + SAAS_ECONOMICS_MAX_MONEY_CENTS, + divideCeil, + isSaasEconomicsCurrency, + parseMoneyCents, + parsePercentHundredths, +} from "../saas-economics/shared.ts"; +import { + type ExactFraction, + addExact, + compareExact, + cumulativeGrossProfit, + divideExact, + exactFraction, + isWholeAtScale, + multiplyExact, + roundExact, +} from "../saas-economics/unit-economics.ts"; + +export const ANNUAL_PLAN_OFFER_MODES = ["annualPrice", "discount", "monthsFree"] as const; +export type AnnualPlanOfferMode = (typeof ANNUAL_PLAN_OFFER_MODES)[number]; +export const ANNUAL_PLAN_HORIZONS = [12, 24, 36] as const; +export type AnnualPlanHorizon = (typeof ANNUAL_PLAN_HORIZONS)[number]; +export const ANNUAL_PLAN_FEE_PRESET_IDS = ["none", "stripeCard", "stripeCardBilling", "paddle", "lemonSqueezy", "custom"] as const; +export type AnnualPlanFeePresetId = (typeof ANNUAL_PLAN_FEE_PRESET_IDS)[number]; + +export type AnnualPlanFeePreset = { + id: Exclude; + label: string; + percentHundredths: bigint; + fixedCents: bigint; + currency: "USD"; + scope: string; + sourceUrl: string; + checked: "2026-10-10"; +}; +export const ANNUAL_PLAN_FEE_PRESETS: readonly AnnualPlanFeePreset[] = [ + { + id: "stripeCard", label: "Stripe, card", percentHundredths: 290n, fixedCents: 30n, + currency: "USD", scope: "Standard US online card rate. Leaves out Stripe Billing's 0.7%.", + sourceUrl: "https://stripe.com/pricing", checked: "2026-10-10", + }, + { + id: "stripeCardBilling", label: "Stripe, card and Billing", percentHundredths: 360n, fixedCents: 30n, + currency: "USD", scope: "Card rate plus Stripe Billing's pay-as-you-go 0.7%.", + sourceUrl: "https://stripe.com/pricing", checked: "2026-10-10", + }, + { + id: "paddle", label: "Paddle", percentHundredths: 500n, fixedCents: 50n, + currency: "USD", scope: "Pay-as-you-go checkout rate. Custom plans may differ.", + sourceUrl: "https://www.paddle.com/pricing", checked: "2026-10-10", + }, + { + id: "lemonSqueezy", label: "Lemon Squeezy, subscription", percentHundredths: 550n, fixedCents: 50n, + currency: "USD", scope: "5% + $0.50, plus 0.5% on subscription payments. Leaves out the 1.5% fees for international and PayPal payments.", + sourceUrl: "https://docs.lemonsqueezy.com/help/getting-started/fees", checked: "2026-10-10", + }, +]; + +export type AnnualPlanInput = { + currency: string; + monthlyPrice: string; + offerMode: string; + annualPrice: string; + discountPercent: string; + monthsFree: string; + monthlyChurnPercent: string; + annualRenewalPercent: string; + feePreset: string; + feePercent: string; + feeFixed: string; +}; +export type AnnualPlanFieldKey = "form" | keyof AnnualPlanInput; +export type AnnualPlanErrors = Partial>; +export type AnnualPlanMoney = { cents: bigint; exact: ExactFraction }; +export type AnnualPlanPercent = { tenths: bigint; nearZero: boolean; exact: ExactFraction }; +export type AnnualPlanMonths = { tenths: bigint; exact: boolean }; + +export type AnnualPlanOffer = { + monthlyPriceCents: bigint; + annualPriceCents: bigint; + annualPriceRounded: boolean; + listAnnualCents: bigint; + customerSavingsCents: bigint; + discount: AnnualPlanPercent; + monthsFree: AnnualPlanMonths; + mrr: { monthlyPlanCents: bigint; annualPlan: AnnualPlanMoney; differenceCents: bigint }; + invoiceEquivalent: { months: AnnualPlanMonths; wholePayments: bigint }; +}; +export type AnnualPlanFeeAmounts = { feesCents: bigint; netCents: bigint }; +export type AnnualPlanUpfront = { + monthly: { grossCents: bigint; fee: AnnualPlanFeeAmounts | null }; + annual: { grossCents: bigint; fee: AnnualPlanFeeAmounts | null }; + differenceCents: bigint; +}; +export type AnnualPlanHorizonResult = { + months: AnnualPlanHorizon; + monthly: { grossCents: bigint; expectedChargesHundredths: bigint; fee: AnnualPlanFeeAmounts | null }; + annual: { grossCents: bigint; expectedChargesHundredths: bigint; fee: AnnualPlanFeeAmounts | null } | null; + differenceCents: bigint | null; + netDifferenceCents: bigint | null; + annualVsMonthly: "more" | "same" | "less" | null; + breakEvenDiscount: AnnualPlanPercent | null; +}; +export type AnnualPlanComparison = { + monthlyChurnHundredths: bigint; + annualRenewalHundredths: bigint | null; + horizons: AnnualPlanHorizonResult[]; + runningTotals: { month: number; monthlyCents: bigint; annualCents: bigint | null }[]; +}; +export type AnnualPlanFee = { + preset: Exclude; + percentHundredths: bigint; + fixedCents: bigint; + edited: boolean; +}; +export type AnnualPlanValues = { + currency: SaasEconomicsCurrency; + offerMode: AnnualPlanOfferMode; + offer: AnnualPlanOffer; + upfront: AnnualPlanUpfront; + fee: AnnualPlanFee | null; + comparison: AnnualPlanComparison | null; +}; +export type AnnualPlanCalculation = + | { status: "valid"; values: AnnualPlanValues; errors: Record } + | { status: "invalid"; values: null; errors: AnnualPlanErrors }; + +function moneyValue(exact: ExactFraction): AnnualPlanMoney { + return { cents: roundExact(exact), exact }; +} + +function percentValue(exact: ExactFraction): AnnualPlanPercent { + const tenths = roundExact(exact, 1000n); + return { tenths, nearZero: exact.numerator !== 0n && tenths === 0n, exact }; +} + +function monthsValue(exact: ExactFraction): AnnualPlanMonths { + return { tenths: roundExact(exact, 10n), exact: isWholeAtScale(exact, 1n) }; +} + +export function calculateAnnualPlanDiscount(inputValue: unknown): AnnualPlanCalculation { + const formError = "Something in the form is out of date. Reset and try again."; + if (inputValue === null || typeof inputValue !== "object" || Array.isArray(inputValue)) { + return { status: "invalid", values: null, errors: { form: formError } }; + } + const input = inputValue as Record; + const errors: AnnualPlanErrors = {}; + const fields: (keyof AnnualPlanInput)[] = [ + "currency", "monthlyPrice", "offerMode", "annualPrice", "discountPercent", "monthsFree", + "monthlyChurnPercent", "annualRenewalPercent", "feePreset", "feePercent", "feeFixed", + ]; + if (fields.some(key => typeof input[key] !== "string")) errors.form = formError; + const optionError = "Choose one of the listed options."; + const currency = typeof input.currency === "string" ? input.currency.trim() : input.currency; + if (!isSaasEconomicsCurrency(currency)) errors.currency = optionError; + const offerMode = input.offerMode as AnnualPlanOfferMode; + if (!ANNUAL_PLAN_OFFER_MODES.includes(offerMode)) errors.offerMode = optionError; + const feePreset = input.feePreset as AnnualPlanFeePresetId; + if (!ANNUAL_PLAN_FEE_PRESET_IDS.includes(feePreset)) errors.feePreset = optionError; + + function money(key: "monthlyPrice" | "annualPrice" | "feeFixed", missing: string): bigint | null { + const fixed = key === "feeFixed"; + const parsed = parseMoneyCents(input[key], { min: 0n, max: fixed ? 10000n : SAAS_ECONOMICS_MAX_MONEY_CENTS }); + if (!parsed.ok) { + errors[key] = parsed.reason === "missing" ? missing + : parsed.reason === "range" ? fixed ? "Enter a fixed fee up to 100.00." : "Enter an amount up to 1,000,000,000.00." + : "Use digits with up to two decimals. No signs, commas, or currency symbols."; + return null; + } + if (!fixed && parsed.value === 0n) { + errors[key] = key === "monthlyPrice" ? "Enter a monthly price above 0." : "Enter an annual price above 0."; + return null; + } + return parsed.value; + } + + function percent(key: "discountPercent" | "monthsFree" | "monthlyChurnPercent" | "annualRenewalPercent" | "feePercent", max: bigint, message: string, missing = message): bigint | null { + const parsed = parsePercentHundredths(input[key], { min: 0n, max }); + if (!parsed.ok) { + errors[key] = parsed.reason === "missing" ? missing : message; + return null; + } + return parsed.value; + } + + const monthlyPriceCents = money("monthlyPrice", "Enter the monthly price."); + let annualPriceCents: bigint | null = null; + let annualPriceRounded = false; + if (offerMode === "annualPrice") annualPriceCents = money("annualPrice", "Enter the annual price."); + else if (offerMode === "discount" || offerMode === "monthsFree") { + const discountMode = offerMode === "discount"; + const key = discountMode ? "discountPercent" : "monthsFree"; + const value = percent(key, discountMode ? 9999n : 1199n, + discountMode ? "Enter a number from 0 to 99.99, without the % sign. To charge more than 12 monthly payments, choose Annual price." + : "Enter months free from 0 to 11.99.", + discountMode ? "Enter the annual discount." : "Enter the number of months free."); + if (monthlyPriceCents !== null && value !== null) { + const derived = discountMode + ? exactFraction(12n * monthlyPriceCents * (10000n - value), 10000n) + : exactFraction(monthlyPriceCents * (1200n - value), 100n); + annualPriceCents = roundExact(derived); + annualPriceRounded = !isWholeAtScale(derived, 1n); + if (annualPriceCents === 0n) { + errors[key] = discountMode ? "This makes the annual price 0.00. Use a smaller discount." + : "This makes the annual price 0.00. Use fewer months free."; + } + } + } + + function optionalPercent(key: "monthlyChurnPercent" | "annualRenewalPercent"): bigint | null { + if (typeof input[key] === "string" && !input[key].trim()) return null; + return percent(key, 10000n, "Enter a percent from 0 to 100, with up to two decimals, or leave it blank."); + } + const monthlyChurnHundredths = optionalPercent("monthlyChurnPercent"); + const annualRenewalHundredths = optionalPercent("annualRenewalPercent"); + let fee: AnnualPlanFee | null = null; + if (feePreset !== "none" && ANNUAL_PLAN_FEE_PRESET_IDS.includes(feePreset)) { + const preset = ANNUAL_PLAN_FEE_PRESETS.find(value => value.id === feePreset); + if (preset && currency !== "USD") { + errors.feePreset = "These presets are US rates in USD. Choose Custom fee for other currencies."; + } + const percentHundredths = percent("feePercent", 5000n, "Enter a fee percent from 0 to 50, with up to two decimals."); + const fixedCents = money("feeFixed", "Enter a fixed fee per charge. Use 0 if there is none."); + if (percentHundredths !== null && fixedCents !== null) { + fee = { preset: feePreset, percentHundredths, fixedCents, + edited: preset !== undefined && (percentHundredths !== preset.percentHundredths || fixedCents !== preset.fixedCents) }; + } + } + if (Object.keys(errors).length || !isSaasEconomicsCurrency(currency) || monthlyPriceCents === null || annualPriceCents === null) { + return { status: "invalid", values: null, errors }; + } + + const listAnnualCents = 12n * monthlyPriceCents; + const customerSavingsCents = listAnnualCents - annualPriceCents; + const annualMrr = moneyValue(exactFraction(annualPriceCents, 12n)); + const offer: AnnualPlanOffer = { + monthlyPriceCents, annualPriceCents, annualPriceRounded, listAnnualCents, customerSavingsCents, + discount: percentValue(exactFraction(customerSavingsCents, listAnnualCents)), + monthsFree: monthsValue(exactFraction(customerSavingsCents, monthlyPriceCents)), + mrr: { monthlyPlanCents: monthlyPriceCents, annualPlan: annualMrr, differenceCents: annualMrr.cents - monthlyPriceCents }, + invoiceEquivalent: { + months: monthsValue(exactFraction(annualPriceCents, monthlyPriceCents)), + wholePayments: divideCeil(annualPriceCents, monthlyPriceCents), + }, + }; + + function feeAmounts(gross: ExactFraction, charges: ExactFraction): AnnualPlanFeeAmounts | null { + if (fee === null) return null; + const feesCents = roundExact(addExact( + multiplyExact(gross, exactFraction(fee.percentHundredths, 10000n)), + multiplyExact(charges, exactFraction(fee.fixedCents)), + )); + return { feesCents, netCents: roundExact(gross) - feesCents }; + } + const upfront: AnnualPlanUpfront = { + monthly: { grossCents: monthlyPriceCents, fee: feeAmounts(exactFraction(monthlyPriceCents), exactFraction(1n)) }, + annual: { grossCents: annualPriceCents, fee: feeAmounts(exactFraction(annualPriceCents), exactFraction(1n)) }, + differenceCents: annualPriceCents - monthlyPriceCents, + }; + + let comparison: AnnualPlanComparison | null = null; + if (monthlyChurnHundredths !== null) { + const monthlyTotals = Array.from({ length: 36 }, (_, index) => cumulativeGrossProfit({ + arpaCents: monthlyPriceCents, grossMarginHundredths: 10000n, churnHundredths: monthlyChurnHundredths, + }, index + 1)); + const q = annualRenewalHundredths === null ? null : exactFraction(annualRenewalHundredths, 10000n); + const annualCharges = [exactFraction(1n), q === null ? null : addExact(exactFraction(1n), q), + q === null ? null : addExact(exactFraction(1n), q, multiplyExact(q, q))]; + const annualTotals = annualCharges.map(charges => charges === null ? null : multiplyExact(exactFraction(annualPriceCents), charges)); + const horizons: AnnualPlanHorizonResult[] = ANNUAL_PLAN_HORIZONS.map((months, index) => { + const monthlyGross = monthlyTotals[months - 1]; + const monthlyCharges = divideExact(monthlyGross, exactFraction(monthlyPriceCents)); + const monthly = { + grossCents: roundExact(monthlyGross), expectedChargesHundredths: roundExact(monthlyCharges, 100n), + fee: feeAmounts(monthlyGross, monthlyCharges), + }; + const annualGross = annualTotals[index]; + const charges = annualCharges[index]; + const annual = annualGross === null || charges === null ? null : { + grossCents: roundExact(annualGross), expectedChargesHundredths: roundExact(charges, 100n), + fee: feeAmounts(annualGross, charges), + }; + const direction = annualGross === null ? null : compareExact(annualGross, monthlyGross); + const breakEvenDiscount = charges === null ? null : percentValue(addExact(exactFraction(1n), + multiplyExact(exactFraction(-1n), divideExact(monthlyGross, multiplyExact(exactFraction(listAnnualCents), charges))), + )); + return { + months, monthly, annual, + differenceCents: annual === null ? null : annual.grossCents - monthly.grossCents, + netDifferenceCents: annual?.fee && monthly.fee ? annual.fee.netCents - monthly.fee.netCents : null, + annualVsMonthly: direction === null ? null : direction < 0 ? "less" : direction > 0 ? "more" : "same", + breakEvenDiscount, + }; + }); + comparison = { + monthlyChurnHundredths, annualRenewalHundredths, horizons, + runningTotals: monthlyTotals.map((gross, index) => { + const annual = annualTotals[Math.floor(index / 12)]; + return { month: index + 1, monthlyCents: roundExact(gross), annualCents: annual === null ? null : roundExact(annual) }; + }), + }; + } + return { status: "valid", errors: {}, values: { currency, offerMode, offer, upfront, fee, comparison } }; +} diff --git a/lib/card-art.ts b/lib/card-art.ts index 782d103a..9b91d869 100644 --- a/lib/card-art.ts +++ b/lib/card-art.ts @@ -67,13 +67,18 @@ import { transferReadinessArt } from "@/app/ui/art/transfer-readiness"; import { unraidArt } from "@/app/ui/art/unraid"; import { zfsCapacityArt } from "@/app/ui/art/zfs-capacity"; import { zfsPaddingArt } from "@/app/ui/art/zfs-padding"; +import { annualPlanDiscountCalculatorArt } from "@/app/(site)/(tools)/(saas)/annual-plan-discount-calculator/art/annual-plan-discount-calculator"; import { breakEvenMrrCalculatorArt } from "@/app/(site)/(tools)/(saas)/break-even-mrr-calculator/art/break-even-mrr-calculator"; import { cacPaybackCalculatorArt } from "@/app/(site)/(tools)/(saas)/cac-payback-calculator/art/cac-payback-calculator"; +import { churnRateConverterArt } from "@/app/(site)/(tools)/(saas)/churn-rate-converter/art/churn-rate-converter"; import { customerAcquisitionCostCalculatorArt } from "@/app/(site)/(tools)/(saas)/customer-acquisition-cost-calculator/art/customer-acquisition-cost-calculator"; import { customerLifetimeValueCalculatorArt } from "@/app/(site)/(tools)/(saas)/customer-lifetime-value-calculator/art/customer-lifetime-value-calculator"; +import { freeTrialConversionCalculatorArt } from "@/app/(site)/(tools)/(saas)/free-trial-conversion-calculator/art/free-trial-conversion-calculator"; import { ltvCacCalculatorArt } from "@/app/(site)/(tools)/(saas)/ltv-cac-calculator/art/ltv-cac-calculator"; +import { mrrGoalCalculatorArt } from "@/app/(site)/(tools)/(saas)/mrr-goal-calculator/art/mrr-goal-calculator"; import { saasGrossMarginCalculatorArt } from "@/app/(site)/(tools)/(saas)/saas-gross-margin-calculator/art/saas-gross-margin-calculator"; import { saasMetricsCalculatorArt } from "@/app/(site)/(tools)/(saas)/saas-metrics-calculator/art/saas-metrics-calculator"; +import { startupRunwayCalculatorArt } from "@/app/(site)/(tools)/(saas)/startup-runway-calculator/art/startup-runway-calculator"; import { siteSpotDnsToolsArt } from "@/app/(site)/art/site--spot-dns-tools"; import { siteSpotDockerignoreArt } from "@/app/(site)/art/site--spot-dockerignore"; import { siteSpotDomainToolsArt } from "@/app/(site)/art/site--spot-domain-tools"; @@ -97,6 +102,7 @@ const art: Record = { "self-hosting-for-beginners": siteSpotCourseArt, "software-licenses-for-self-hosting": siteSpotLicensesArt, + "annual-plan-discount-calculator": annualPlanDiscountCalculatorArt, "arpu-arpa-calculator": arpuArpaCalculatorArt, "arr-calculator": arrCalculatorArt, "backup-321": backup321Art, @@ -106,6 +112,7 @@ const art: Record = { "caa": caaArt, "ceph": cephArt, "chmod-calculator": chmodCalculatorArt, + "churn-rate-converter": churnRateConverterArt, "customer-acquisition-cost-calculator": customerAcquisitionCostCalculatorArt, "customer-churn-calculator": customerChurnCalculatorArt, "customer-lifetime-value-calculator": customerLifetimeValueCalculatorArt, @@ -127,6 +134,7 @@ const art: Record = { "domain-tools": siteSpotDomainToolsArt, "drive-reliability": driveReliabilityArt, "epp-status": eppStatusArt, + "free-trial-conversion-calculator": freeTrialConversionCalculatorArt, "hosting-cloud-tools": siteSpotSelfHostAppsArt, "ip-asn-lookup": ipAsnLookupArt, "kdp-backend-keywords-builder": kdpBackendKeywordsBuilderArt, @@ -147,6 +155,7 @@ const art: Record = { "lxc-idmap-checker": lxcIdmapCheckerArt, "ltv-cac-calculator": ltvCacCalculatorArt, "mrr-calculator": mrrCalculatorArt, + "mrr-goal-calculator": mrrGoalCalculatorArt, "mrr-waterfall-calculator": mrrWaterfallCalculatorArt, "multi-tld": multiTldArt, "nameserver": nameserverArt, @@ -172,6 +181,7 @@ const art: Record = { "soa": soaArt, "storage-growth": storageGrowthArt, "storage-layout": storageLayoutArt, + "startup-runway-calculator": startupRunwayCalculatorArt, "storage-tools": siteSpotStorageToolsArt, "tld-directory": tldDirectoryArt, "transfer-readiness": transferReadinessArt, diff --git a/lib/churn-rate-converter/engine.ts b/lib/churn-rate-converter/engine.ts new file mode 100644 index 00000000..4838f59a --- /dev/null +++ b/lib/churn-rate-converter/engine.ts @@ -0,0 +1,174 @@ +import { parsePercentHundredths } from "../saas-economics/shared.ts"; +import { compareExact, exactFraction, roundExact } from "../saas-economics/unit-economics.ts"; +import type { ExactFraction } from "../saas-economics/unit-economics.ts"; + +export const CHURN_PERIODS = ["month", "quarter", "year"] as const; +export type ChurnPeriod = (typeof CHURN_PERIODS)[number]; +export const CHURN_PERIOD_MONTHS: Readonly> = { month: 1, quarter: 3, year: 12 }; + +export type ChurnRateInput = { rate: string; period: string }; +export type ChurnRateErrorCode = "missing" | "format" | "tooPrecise" | "belowZero" | "aboveHundred"; +export type ChurnRateErrors = { + rate?: { code: ChurnRateErrorCode; message: string }; + period?: { code: "unknownPeriod"; message: string }; +}; + +/** A rate in hundredths of a percent: 4596 is 45.96%. */ +export type ChurnPercent = { + hundredths: number; + roundedToZero: boolean; + nearlyAll: boolean; +}; + +export type ChurnLinearEstimate = { + hundredths: number; + overHundred: boolean; + roundedToZero: boolean; +}; + +export type ChurnPeriodRow = { + period: ChurnPeriod; + isSource: boolean; + churn: ChurnPercent; + retention: ChurnPercent; + linearEstimate: ChurnLinearEstimate | null; +}; + +export type ChurnCohortOf100 = { + lost: number; + remaining: number; + lostUnderOne: boolean; + remainingUnderOne: boolean; +}; + +export type ChurnLifetime = + | { kind: "finite"; tenths: number; period: ChurnPeriod } + | { kind: "notEstimable"; reason: "zeroChurn" }; + +export type ChurnConversion = { + source: { hundredths: number; period: ChurnPeriod }; + rows: [ChurnPeriodRow, ChurnPeriodRow, ChurnPeriodRow]; + leadPeriod: ChurnPeriod; + yearOf100: ChurnCohortOf100; + lifetime: ChurnLifetime; +}; + +export type ChurnRateCalculation = + | { status: "valid"; values: ChurnConversion; errors: Record } + | { status: "invalid"; values: null; errors: ChurnRateErrors }; + +const RATE_MESSAGES: Readonly> = { + missing: "Enter a churn rate from 0 to 100%.", + format: "Use digits only, like 5 or 4.25.", + tooPrecise: "Use up to two decimals.", + belowZero: "Customer churn can't be below 0%. A negative rate is net revenue churn, which this tool doesn't convert.", + aboveHundred: "Churn can't be above 100%. You can't lose more customers than you started with.", +}; + +function rateError(code: ChurnRateErrorCode): NonNullable { + return { code, message: RATE_MESSAGES[code] }; +} + +function parseRate(raw: unknown): { value: bigint } | { error: NonNullable } { + if (typeof raw !== "string") return { error: rateError("missing") }; + let text = raw.trim(); + if (text.endsWith("%")) text = text.slice(0, -1).trim(); + if (!text) return { error: rateError("missing") }; + if (text.startsWith("-")) return { error: rateError("belowZero") }; + if (/^\d+\.\d{3,}$/.test(text)) return { error: rateError("tooPrecise") }; + const parsed = parsePercentHundredths(text, { min: 0n, max: 10000n }); + return parsed.ok + ? { value: parsed.value } + : { error: rateError(parsed.reason === "range" ? "aboveHundred" : parsed.reason) }; +} + +function inputFields(raw: unknown): { rate?: unknown; period?: unknown } { + try { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {}; + const prototype = Object.getPrototypeOf(raw); + if (prototype !== Object.prototype && prototype !== null) return {}; + const candidate = raw as { rate?: unknown; period?: unknown }; + return { rate: candidate.rate, period: candidate.period }; + } catch { + // Unreadable objects are invalid input, including throwing getters and revoked proxies. + return {}; + } +} + +function wholePowerChurn(hundredths: bigint, exponent: bigint): ExactFraction { + const denominator = 10000n ** exponent; + return exactFraction(denominator - (10000n - hundredths) ** exponent, denominator); +} + +function roundedRootChurn(hundredths: bigint, degree: bigint): bigint { + const left = (10000n - hundredths) * 20000n ** degree; + let low = 0n; + let high = 10000n; + while (low < high) { + const middle = (low + high + 1n) / 2n; + // Compare with the half-step by raising both non-negative retentions to the root degree. + const right = (20001n - 2n * middle) ** degree * 10000n; + if (left <= right) low = middle; + else high = middle - 1n; + } + return low; +} + +function periodRow(hundredths: bigint, source: ChurnPeriod, target: ChurnPeriod): ChurnPeriodRow { + const sourceMonths = BigInt(CHURN_PERIOD_MONTHS[source]); + const targetMonths = BigInt(CHURN_PERIOD_MONTHS[target]); + const isSource = source === target; + const rounded = isSource ? hundredths + : targetMonths > sourceMonths + ? roundExact(wholePowerChurn(hundredths, targetMonths / sourceMonths), 10000n) + : roundedRootChurn(hundredths, sourceMonths / targetMonths); + const roundedToZero = hundredths > 0n && rounded === 0n; + const nearlyAll = hundredths < 10000n && rounded === 10000n; + const linear = isSource ? null : roundExact(exactFraction(hundredths * targetMonths, sourceMonths)); + return { + period: target, + isSource, + churn: { hundredths: Number(rounded), roundedToZero, nearlyAll }, + retention: { hundredths: Number(10000n - rounded), roundedToZero: nearlyAll, nearlyAll: roundedToZero }, + linearEstimate: linear === null ? null : { + hundredths: Number(linear), + overHundred: linear > 10000n, + roundedToZero: hundredths > 0n && linear === 0n, + }, + }; +} + +/** Never throws for invalid input, including null, arrays, and non-string fields. */ +export function convertChurnRate(raw: unknown): ChurnRateCalculation { + const input = inputFields(raw); + const rate = parseRate(input.rate); + const period = typeof input.period === "string" ? input.period.trim() : ""; + const knownPeriod = CHURN_PERIODS.includes(period as ChurnPeriod); + const errors: ChurnRateErrors = {}; + if ("error" in rate) errors.rate = rate.error; + if (!knownPeriod) errors.period = { code: "unknownPeriod", message: "Choose monthly, quarterly, or annual." }; + if ("error" in rate || !knownPeriod) return { status: "invalid", values: null, errors }; + + const h = rate.value; + const source = period as ChurnPeriod; + const annual = wholePowerChurn(h, 12n / BigInt(CHURN_PERIOD_MONTHS[source])); + const lost = roundExact(annual, 100n); + return { + status: "valid", + errors: {}, + values: { + source: { hundredths: Number(h), period: source }, + rows: [periodRow(h, source, "month"), periodRow(h, source, "quarter"), periodRow(h, source, "year")], + leadPeriod: source === "year" ? "month" : "year", + yearOf100: { + lost: Number(lost), + remaining: Number(100n - lost), + lostUnderOne: compareExact(annual, exactFraction(0n)) > 0 && lost === 0n, + remainingUnderOne: compareExact(annual, exactFraction(1n)) < 0 && lost === 100n, + }, + lifetime: h === 0n + ? { kind: "notEstimable", reason: "zeroChurn" } + : { kind: "finite", tenths: Number(roundExact(exactFraction(10000n, h), 10n)), period: source }, + }, + }; +} diff --git a/lib/free-trial-conversion-calculator/engine.ts b/lib/free-trial-conversion-calculator/engine.ts new file mode 100644 index 00000000..1bfa1fab --- /dev/null +++ b/lib/free-trial-conversion-calculator/engine.ts @@ -0,0 +1,238 @@ +import { + isSaasEconomicsCurrency, parseMoneyCents, parseOptionalLabel, parseWholeNumber, + SAAS_ECONOMICS_MAX_MONEY_CENTS, type SaasEconomicsCurrency, +} from "../saas-economics/shared.ts"; +import { + addExact, divideExact, exactFraction, isWholeAtScale, multiplyExact, roundExact, + type ExactFraction, +} from "../saas-economics/unit-economics.ts"; + +export const TRIAL_MODELS = ["trialNoCard", "trialCardRequired", "freemium"] as const; +export type TrialModel = (typeof TRIAL_MODELS)[number]; +export const TRIAL_MRR_METHODS = ["average", "split"] as const; +export type TrialMrrMethod = (typeof TRIAL_MRR_METHODS)[number]; +export const TRIAL_MAX_COHORTS = 12; + +export type TrialCohortInput = { label: string; starts: string; paid: string; ageDays: string }; +export type TrialCohortField = keyof TrialCohortInput; +export type FreeTrialConversionInput = { + model: string; + windowDays: string; + cohorts: TrialCohortInput[]; + includeMrr: boolean; + currency: string; // ignored when includeMrr is false + mrrMethod: string; // ignored when includeMrr is false + averageMonthly: string; // used by "average" + monthlyCustomers: string; // used by "split" + monthlyPrice: string; + annualCustomers: string; + annualPrice: string; +}; + +export type TrialFieldKey = + | "form" | "model" | "windowDays" | "cohorts" + | `cohorts.${number}.${TrialCohortField}` + | "currency" | "mrrMethod" | "averageMonthly" + | "monthlyCustomers" | "monthlyPrice" | "annualCustomers" | "annualPrice"; + +export type TrialConversionRate = + | { kind: "finite"; hundredths: bigint; exact: ExactFraction; isRounded: boolean; roundedToZero: boolean } + | { kind: "notAvailable"; reason: "noStarts" }; +// hundredths = percent × 100. 1322n means 13.22%. exact is the percent as a fraction. + +export type TrialMaturity = + | { status: "complete" } + | { status: "maturing"; daysLeft: bigint }; + +export type TrialCohortResult = { + index: number; // position in the input + label: string; // trimmed; "" when blank. The UI shows "Cohort 1" etc. + starts: bigint; + paid: bigint; + notPaid: bigint; // starts - paid + ageDays: bigint; + rate: TrialConversionRate; + maturity: TrialMaturity; +}; + +export type TrialCombined = { + basis: "complete" | "all"; + status: "complete" | "provisional"; + includedIndexes: number[]; // cohorts in the basis, in input order + excludedMaturing: number; // maturing cohorts left out (0 when basis is "all") + starts: bigint; // over the basis + paid: bigint; + notPaid: bigint; + rate: TrialConversionRate; +}; + +export type TrialNewMrr = { + currency: SaasEconomicsCurrency; + method: TrialMrrMethod; + paidCustomers: bigint; // across all cohorts + averageMonthlyCents: bigint | null; // "average" only + monthlyPlan: { customers: bigint; priceCents: bigint; mrrCents: bigint } | null; // "split" only, exact + annualPlan: { customers: bigint; annualPriceCents: bigint; mrrCents: bigint; isRounded: boolean } | null; // "split" only, ÷12 rounded + mrrCents: bigint; // rounded from the exact total + mrrIsRounded: boolean; + runRateArrCents: bigint; // exact MRR × 12, rounded (always whole in practice) +}; + +export type FreeTrialConversionValues = { + model: TrialModel; + windowDays: bigint; + cohorts: TrialCohortResult[]; + combined: TrialCombined; + newMrr: TrialNewMrr | null; +}; + +export type FreeTrialConversionErrors = Partial>; + +export type FreeTrialConversionCalculation = + | { status: "valid"; values: FreeTrialConversionValues; errors: Record } + | { status: "invalid"; values: null; errors: FreeTrialConversionErrors }; + +const formError = "Something in the form is out of date. Reset and try again."; +const optionError = "Choose one of the listed options."; +const maximumCount = 10_000_000n; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : null; +} + +function conversionRate(starts: bigint, paid: bigint): TrialConversionRate { + if (starts === 0n) return { kind: "notAvailable", reason: "noStarts" }; + const exact = multiplyExact(divideExact(exactFraction(paid), exactFraction(starts)), exactFraction(100n)); + const hundredths = roundExact(exact, 100n); + return { + kind: "finite", hundredths, exact, isRounded: !isWholeAtScale(exact, 100n), + roundedToZero: paid > 0n && hundredths === 0n, + }; +} + +export function calculateFreeTrialConversion(inputValue: unknown): FreeTrialConversionCalculation { + const input = asRecord(inputValue); + const errors: FreeTrialConversionErrors = {}; + if (!input) return { status: "invalid", values: null, errors: { form: formError } }; + if (typeof input.includeMrr !== "boolean") errors.form = formError; + const model = input.model; + if (typeof model !== "string" || !TRIAL_MODELS.includes(model as TrialModel)) errors.model = optionError; + + const parsedWindow = parseWholeNumber(input.windowDays, { min: 1n, max: 730n }); + if (!parsedWindow.ok) errors.windowDays = parsedWindow.reason === "missing" + ? "Enter the follow-up window in days." : "Enter whole days from 1 to 730."; + + function count(raw: unknown, key: TrialFieldKey): bigint | null { + const parsed = parseWholeNumber(raw, { min: 0n, max: maximumCount }); + if (parsed.ok) return parsed.value; + errors[key] = typeof raw === "string" && raw.trim().startsWith("-") + ? "Counts can't be negative." + : parsed.reason === "missing" ? "Enter a count. Use 0 if there were none." + : parsed.reason === "range" ? "Enter a number up to 10,000,000." + : "Use a whole number with no commas or decimals."; + return null; + } + + function money(raw: unknown, key: TrialFieldKey): bigint | null { + const parsed = parseMoneyCents(raw, { min: 0n, max: SAAS_ECONOMICS_MAX_MONEY_CENTS }); + if (parsed.ok) return parsed.value; + errors[key] = parsed.reason === "missing" ? "Enter an amount." + : parsed.reason === "range" ? "Enter an amount up to 1,000,000,000.00." + : "Use digits with up to two decimals. No signs, commas, or currency symbols."; + return null; + } + + const cohorts: TrialCohortResult[] = []; + let paidCustomers = 0n; + let allCohortCountsParsed = true; + if (!Array.isArray(input.cohorts)) { + errors.form = formError; + allCohortCountsParsed = false; + } else { + if (input.cohorts.length === 0) errors.cohorts = "Add at least one cohort."; + if (input.cohorts.length > TRIAL_MAX_COHORTS) errors.cohorts = "Use up to 12 cohorts."; + for (let index = 0; index < input.cohorts.length; index += 1) { + const row = asRecord(input.cohorts[index]); + if (!row) { + errors.form = formError; + allCohortCountsParsed = false; + continue; + } + const label = parseOptionalLabel(row.label, 40); + if (!label.ok) errors[`cohorts.${index}.label`] = "Use a name up to 40 characters on one line."; + const starts = count(row.starts, `cohorts.${index}.starts`); + const paid = count(row.paid, `cohorts.${index}.paid`); + if (starts === null || paid === null) allCohortCountsParsed = false; + if (paid !== null) paidCustomers += paid; + if (starts !== null && paid !== null && paid > starts) { + errors[`cohorts.${index}.paid`] = "Paid can't be more than starts. Count only accounts from this cohort."; + } + const age = parseWholeNumber(row.ageDays, { min: 0n, max: 3650n }); + if (!age.ok) errors[`cohorts.${index}.ageDays`] = age.reason === "missing" + ? "Enter the days since this cohort's last start." : "Enter whole days from 0 to 3,650."; + if (label.ok && starts !== null && paid !== null && paid <= starts && age.ok && parsedWindow.ok) { + cohorts.push({ + index, label: label.value, starts, paid, notPaid: starts - paid, ageDays: age.value, + rate: conversionRate(starts, paid), + maturity: age.value >= parsedWindow.value ? { status: "complete" } + : { status: "maturing", daysLeft: parsedWindow.value - age.value }, + }); + } + } + } + + let newMrr: TrialNewMrr | null = null; + if (input.includeMrr === true) { + const currency = input.currency; + const method = input.mrrMethod; + if (!isSaasEconomicsCurrency(currency)) errors.currency = optionError; + if (typeof method !== "string" || !TRIAL_MRR_METHODS.includes(method as TrialMrrMethod)) errors.mrrMethod = optionError; + let averageMonthlyCents: bigint | null = null; + let monthlyPlan: TrialNewMrr["monthlyPlan"] = null; + let annualPlan: TrialNewMrr["annualPlan"] = null; + let exactMrr: ExactFraction | null = null; + if (method === "average") { + averageMonthlyCents = money(input.averageMonthly, "averageMonthly"); + if (averageMonthlyCents !== null) exactMrr = multiplyExact(exactFraction(paidCustomers), exactFraction(averageMonthlyCents)); + } else if (method === "split") { + const monthlyCustomers = count(input.monthlyCustomers, "monthlyCustomers"); + const monthlyPrice = money(input.monthlyPrice, "monthlyPrice"); + const annualCustomers = count(input.annualCustomers, "annualCustomers"); + const annualPrice = money(input.annualPrice, "annualPrice"); + if (allCohortCountsParsed && monthlyCustomers !== null && annualCustomers !== null + && monthlyCustomers + annualCustomers !== paidCustomers) { + const formattedPaid = paidCustomers.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + errors.monthlyCustomers = `Monthly and annual customers must add up to ${formattedPaid}, the paid customers above.`; + } + if (monthlyCustomers !== null && monthlyPrice !== null && annualCustomers !== null && annualPrice !== null) { + monthlyPlan = { customers: monthlyCustomers, priceCents: monthlyPrice, mrrCents: monthlyCustomers * monthlyPrice }; + const annualMrr = divideExact(multiplyExact(exactFraction(annualCustomers), exactFraction(annualPrice)), exactFraction(12n)); + annualPlan = { customers: annualCustomers, annualPriceCents: annualPrice, mrrCents: roundExact(annualMrr), isRounded: !isWholeAtScale(annualMrr, 1n) }; + exactMrr = addExact(exactFraction(monthlyPlan.mrrCents), annualMrr); + } + } + if (isSaasEconomicsCurrency(currency) && (method === "average" || method === "split") && exactMrr !== null) { + newMrr = { + currency, method, paidCustomers, averageMonthlyCents, monthlyPlan, annualPlan, + mrrCents: roundExact(exactMrr), mrrIsRounded: !isWholeAtScale(exactMrr, 1n), + runRateArrCents: roundExact(multiplyExact(exactMrr, exactFraction(12n))), + }; + } + } + if (Object.keys(errors).length > 0 || !parsedWindow.ok) return { status: "invalid", values: null, errors }; + + const complete = cohorts.filter(row => row.maturity.status === "complete"); + const useComplete = complete.reduce((sum, row) => sum + row.starts, 0n) > 0n; + const included = useComplete ? complete : cohorts; + const starts = included.reduce((sum, row) => sum + row.starts, 0n); + const paid = included.reduce((sum, row) => sum + row.paid, 0n); + const combined: TrialCombined = { + basis: useComplete ? "complete" : "all", + status: useComplete || cohorts.every(row => row.maturity.status === "complete") ? "complete" : "provisional", + includedIndexes: included.map(row => row.index), + excludedMaturing: useComplete ? cohorts.length - complete.length : 0, + starts, paid, notPaid: starts - paid, rate: conversionRate(starts, paid), + }; + return { status: "valid", errors: {}, values: { model: model as TrialModel, windowDays: parsedWindow.value, cohorts, combined, newMrr } }; +} diff --git a/lib/mrr-goal-calculator/engine.ts b/lib/mrr-goal-calculator/engine.ts new file mode 100644 index 00000000..a3395b4e --- /dev/null +++ b/lib/mrr-goal-calculator/engine.ts @@ -0,0 +1,425 @@ +import { type SaasEconomicsCurrency } from "../saas-economics/shared.ts"; +import { + SAAS_ECONOMICS_MAX_MONEY_CENTS, + divideCeil, + divideHalfAwayFromZero, + isSaasEconomicsCurrency, + parseMoneyCents, + parsePercentHundredths, + parseWholeNumber, +} from "../saas-economics/shared.ts"; + +export const MRR_GOAL_MAX_MONTHS = 600; +export const MRR_GOAL_MAX_COUNT = 1_000_000_000n; +export const MRR_GOAL_METHODS = ["customers", "growth"] as const; +export type MrrGoalMethod = (typeof MRR_GOAL_METHODS)[number]; +export const MRR_GOAL_SOLVE_FOR = ["date", "pace"] as const; +export type MrrGoalSolveFor = (typeof MRR_GOAL_SOLVE_FOR)[number]; +export const MRR_GOAL_PERIODS = ["month", "year"] as const; +export type MrrGoalPeriod = (typeof MRR_GOAL_PERIODS)[number]; + +export type MrrGoalInput = { + currency: string; + goalAmount: string; + goalPeriod: string; + monthlyArpa: string; + method: string; + solveFor: string; + currentCustomers: string; + newCustomersPerMonth: string; + churnPercent: string; + currentMrr: string; + growthPercent: string; + deadlineMonths: string; + visitorToSignupPercent: string; + signupToPaidPercent: string; +}; + +export type MrrGoalFieldKey = keyof MrrGoalInput | "traffic" | "form"; +export type MrrGoalErrorCode = + | "missing" | "format" | "range" | "zero" | "zeroBase" | "option" | "trafficNeedsTimeline" | "form"; +export type MrrGoalErrors = Partial>; + +export type MrrGoalGoal = { + period: MrrGoalPeriod; + enteredCents: bigint; + monthlyGoalCents: bigint; + monthlyGoalIsRounded: boolean; + annualGoalCents: bigint; + customersNeeded: bigint; + mrrAtCustomersNeededCents: bigint; +}; + +export type MrrGoalCustomerPoint = { month: number; customers: bigint; mrrCents: bigint }; +export type MrrGoalGrowthPoint = { month: number; mrrCents: bigint }; + +export type MrrGoalCustomerTimeline = { + method: "customers"; + solveFor: MrrGoalSolveFor; + currentCustomers: bigint; + churnHundredths: bigint; + remainingCustomers: bigint; + lostPerMonthAtGoal: bigint; + ceiling: { floor: bigint; isWhole: boolean } | null; + pace: bigint; + outcome: + | { kind: "reached"; month: number } + | { kind: "alreadyThere"; fallsBelowInMonth: number | null } + | { kind: "unreachable"; reason: "noNewCustomers" } + | { kind: "unreachable"; reason: "ceiling"; paceToReach: bigint } + | { kind: "beyondHorizon" } + | { kind: "paceForDeadline"; deadlineMonths: number; alreadyThere: boolean }; + path: MrrGoalCustomerPoint[]; +}; + +export type MrrGoalGrowthTimeline = { + method: "growth"; + solveFor: MrrGoalSolveFor; + currentMrrCents: bigint; + remainingMrrCents: bigint; + growthHundredths: bigint; // Returns the tested maximum 100000n for aboveMaxRate. + outcome: + | { kind: "reached"; month: number } + | { kind: "alreadyThere" } + | { kind: "unreachable"; reason: "noGrowth" | "shrinking" } + | { kind: "beyondHorizon" } + | { kind: "rateForDeadline"; deadlineMonths: number; alreadyThere: boolean } + | { kind: "aboveMaxRate"; deadlineMonths: number }; + path: MrrGoalGrowthPoint[]; +}; + +export type MrrGoalTraffic = + | { + kind: "finite"; + visitorToSignupHundredths: bigint; + signupToPaidHundredths: bigint; + pace: bigint; + signupsPerMonth: bigint; + visitorsPerMonth: bigint; + visitorsToGoal: bigint | null; + months: number | null; + } + | { kind: "noFiniteTraffic"; stage: "visitorToSignup" | "signupToPaid" }; + +export type MrrGoalValues = { + currency: SaasEconomicsCurrency; + monthlyArpaCents: bigint; + goal: MrrGoalGoal; + timeline: MrrGoalCustomerTimeline | MrrGoalGrowthTimeline | null; + traffic: MrrGoalTraffic | null; +}; + +export type MrrGoalCalculation = + | { status: "valid"; values: MrrGoalValues; errors: Record } + | { status: "invalid"; values: null; errors: MrrGoalErrors }; + +const rateDenominator = 10000n; +const maxGrowthHundredths = 100000n; +type ExactPoint = { month: number; numerator: bigint; denominator: bigint }; + +function nonnegative(value: bigint): bigint { + return value > 0n ? value : 0n; +} + +function* customerPoints(current: bigint, pace: bigint, churn: bigint): Generator { + let dPower = 1n; + let qPower = 1n; + const q = rateDenominator - churn; + for (let month = 0; month <= MRR_GOAL_MAX_MONTHS; month += 1) { + yield churn === 0n + ? { month, numerator: current + pace * BigInt(month), denominator: 1n } + : { + month, + numerator: pace * rateDenominator * (dPower - qPower) + current * churn * qPower, + denominator: churn * dPower, + }; + if (churn !== 0n) { + dPower *= rateDenominator; + qPower *= q; + } + } +} + +function* growthPoints(current: bigint, growth: bigint): Generator { + let numerator = current; + let denominator = 1n; + for (let month = 0; month <= MRR_GOAL_MAX_MONTHS; month += 1) { + yield { month, numerator, denominator }; + numerator *= rateDenominator + growth; + denominator *= rateDenominator; + } +} + +function customerTimeline( + required: bigint, arpa: bigint, current: bigint, churn: bigint, + enteredPace: bigint, solveFor: MrrGoalSolveFor, deadline: number, +): MrrGoalCustomerTimeline { + let pace = enteredPace; + let keyMonth = 0; + let outcome: MrrGoalCustomerTimeline["outcome"]; + if (solveFor === "pace") { + if (churn === 0n) pace = nonnegative(divideCeil(required - current, BigInt(deadline))); + else { + let dPower = 1n; + let qPower = 1n; + for (let month = 0; month < deadline; month += 1) { + dPower *= rateDenominator; + qPower *= rateDenominator - churn; + } + pace = nonnegative(divideCeil( + (required * dPower - current * qPower) * churn, + rateDenominator * (dPower - qPower), + )); + } + outcome = { kind: "paceForDeadline", deadlineMonths: deadline, alreadyThere: current >= required }; + keyMonth = deadline; + } else if (current >= required) { + let fallsBelowInMonth: number | null = null; + if (churn > 0n && current * churn > pace * rateDenominator) { + for (const point of customerPoints(current, pace, churn)) { + if (point.numerator < required * point.denominator) { + fallsBelowInMonth = point.month; + break; + } + } + } + outcome = { kind: "alreadyThere", fallsBelowInMonth }; + keyMonth = fallsBelowInMonth ?? 0; + } else if (churn === 0n && pace === 0n) { + outcome = { kind: "unreachable", reason: "noNewCustomers" }; + keyMonth = 120; + } else if (churn > 0n && (required * churn > pace * rateDenominator + || (churn < rateDenominator && required * churn === pace * rateDenominator))) { + outcome = { + kind: "unreachable", reason: "ceiling", + paceToReach: churn === rateDenominator ? required : required * churn / rateDenominator + 1n, + }; + keyMonth = 120; + } else { + outcome = { kind: "beyondHorizon" }; + keyMonth = MRR_GOAL_MAX_MONTHS; + for (const point of customerPoints(current, pace, churn)) { + if (point.numerator >= required * point.denominator) { + outcome = { kind: "reached", month: point.month }; + keyMonth = point.month; + break; + } + } + } + + const end = keyMonth > 12 ? keyMonth : 12; + const path: MrrGoalCustomerPoint[] = []; + for (const point of customerPoints(current, pace, churn)) { + const customers = point.numerator / point.denominator; + path.push({ month: point.month, customers, mrrCents: customers * arpa }); + if (point.month === end) break; + } + return { + method: "customers", solveFor, currentCustomers: current, churnHundredths: churn, + remainingCustomers: nonnegative(required - current), + lostPerMonthAtGoal: divideCeil(required * churn, rateDenominator), + ceiling: churn === 0n ? null + : { floor: pace * rateDenominator / churn, isWhole: pace * rateDenominator % churn === 0n }, + pace, outcome, path, + }; +} + +function growthTimeline( + goalTwelfthCents: bigint, monthlyGoalCents: bigint, current: bigint, + enteredGrowth: bigint, solveFor: MrrGoalSolveFor, deadline: number, +): MrrGoalGrowthTimeline { + let growth = enteredGrowth; + let keyMonth = 0; + let outcome: MrrGoalGrowthTimeline["outcome"]; + const alreadyThere = 12n * current >= goalTwelfthCents; + if (solveFor === "pace") { + keyMonth = deadline; + if (alreadyThere) { + growth = 0n; + outcome = { kind: "rateForDeadline", deadlineMonths: deadline, alreadyThere: true }; + } else { + const exponent = BigInt(deadline); + const target = goalTwelfthCents * rateDenominator ** exponent; + const meetsGoal = (rate: bigint) => 12n * current * (rateDenominator + rate) ** exponent >= target; + if (!meetsGoal(maxGrowthHundredths)) { + // No required rate exists in the supported range. Return the tested upper bound. + growth = maxGrowthHundredths; + outcome = { kind: "aboveMaxRate", deadlineMonths: deadline }; + } else { + let low = 0n; + let high = maxGrowthHundredths; + while (low < high) { + const middle = (low + high) / 2n; + if (meetsGoal(middle)) high = middle; + else low = middle + 1n; + } + growth = low; + outcome = { kind: "rateForDeadline", deadlineMonths: deadline, alreadyThere: false }; + } + } + } else if (alreadyThere) outcome = { kind: "alreadyThere" }; + else if (growth <= 0n) { + outcome = { kind: "unreachable", reason: growth === 0n ? "noGrowth" : "shrinking" }; + keyMonth = 120; + } else { + outcome = { kind: "beyondHorizon" }; + keyMonth = MRR_GOAL_MAX_MONTHS; + for (const point of growthPoints(current, growth)) { + if (12n * point.numerator >= goalTwelfthCents * point.denominator) { + outcome = { kind: "reached", month: point.month }; + keyMonth = point.month; + break; + } + } + } + + const path: MrrGoalGrowthPoint[] = []; + if (outcome.kind !== "aboveMaxRate") { + const end = keyMonth > 12 ? keyMonth : 12; + for (const point of growthPoints(current, growth)) { + path.push({ month: point.month, mrrCents: divideHalfAwayFromZero(point.numerator, point.denominator) }); + if (point.month === end) break; + } + } + return { + method: "growth", solveFor, currentMrrCents: current, + remainingMrrCents: nonnegative(monthlyGoalCents - current), + growthHundredths: growth, outcome, path, + }; +} + +function trafficForTimeline(timeline: MrrGoalCustomerTimeline, visitorRate: bigint, paidRate: bigint): MrrGoalTraffic { + const pace = timeline.pace; + if (pace > 0n && paidRate === 0n) return { kind: "noFiniteTraffic", stage: "signupToPaid" }; + if (pace > 0n && visitorRate === 0n) return { kind: "noFiniteTraffic", stage: "visitorToSignup" }; + const signupsPerMonth = pace === 0n ? 0n : divideCeil(pace * rateDenominator, paidRate); + const visitorsPerMonth = pace === 0n ? 0n : divideCeil(pace * rateDenominator * rateDenominator, visitorRate * paidRate); + const months = timeline.outcome.kind === "reached" ? timeline.outcome.month + : timeline.outcome.kind === "paceForDeadline" ? timeline.outcome.deadlineMonths : null; + return { + kind: "finite", visitorToSignupHundredths: visitorRate, signupToPaidHundredths: paidRate, + pace, signupsPerMonth, visitorsPerMonth, + visitorsToGoal: months === null ? null : visitorsPerMonth * BigInt(months), months, + }; +} + +export function calculateMrrGoal(inputValue: unknown): MrrGoalCalculation { + if (inputValue === null || typeof inputValue !== "object" || Array.isArray(inputValue)) { + return { status: "invalid", values: null, errors: { + form: { code: "form", message: "Something in the form is out of date. Reset and try again." }, + } }; + } + const input = inputValue as Record; + const errors: MrrGoalErrors = {}; + const option = (raw: unknown) => typeof raw === "string" ? raw.trim() : raw; + const currency = option(input.currency); + const period = option(input.goalPeriod); + const method = option(input.method); + const solveFor = option(input.solveFor); + const optionError = { code: "option", message: "Choose one of the listed options." } as const; + if (!isSaasEconomicsCurrency(currency)) errors.currency = optionError; + if (period !== "month" && period !== "year") errors.goalPeriod = optionError; + if (method !== "customers" && method !== "growth") errors.method = optionError; + if (solveFor !== "date" && solveFor !== "pace") errors.solveFor = optionError; + + function money(key: "goalAmount" | "monthlyArpa" | "currentMrr", missing: string, zero: string): bigint | null { + const parsed = parseMoneyCents(input[key], { min: 0n, max: SAAS_ECONOMICS_MAX_MONEY_CENTS }); + if (!parsed.ok) { + errors[key] = { code: parsed.reason, message: parsed.reason === "missing" ? missing + : parsed.reason === "range" ? "Enter an amount up to 1,000,000,000.00." + : "Use digits with up to two decimals. No signs, commas, or currency symbols." }; + return null; + } + if (parsed.value === 0n) { + errors[key] = { code: key === "currentMrr" ? "zeroBase" : "zero", message: zero }; + return null; + } + return parsed.value; + } + + function count(key: "currentCustomers" | "newCustomersPerMonth" | "deadlineMonths"): bigint | null { + const deadline = key === "deadlineMonths"; + const parsed = parseWholeNumber(input[key], { min: deadline ? 1n : 0n, max: deadline ? BigInt(MRR_GOAL_MAX_MONTHS) : MRR_GOAL_MAX_COUNT }); + if (!parsed.ok) { + errors[key] = { code: parsed.reason, message: parsed.reason === "missing" + ? deadline ? "Enter the number of months." : "Enter a whole number. Use 0 if none." + : deadline ? "Enter whole months from 1 to 600." + : parsed.reason === "range" ? "Enter a number from 0 to 1,000,000,000." + : "Use digits only, without commas, signs, or decimals." }; + return null; + } + return parsed.value; + } + + function percent(key: "churnPercent" | "growthPercent" | "visitorToSignupPercent" | "signupToPaidPercent"): bigint | null { + const growth = key === "growthPercent"; + const parsed = parsePercentHundredths(input[key], { min: growth ? -10000n : 0n, max: growth ? maxGrowthHundredths : rateDenominator }); + if (!parsed.ok) { + errors[key] = { code: parsed.reason, message: parsed.reason === "missing" + ? growth ? "Enter your net monthly MRR growth." + : key === "churnPercent" ? "Enter monthly churn. Use 0 if no one cancels." + : "Enter this rate, or clear both rates." + : growth ? "Enter a percent from -100 to 1000, with up to two decimals." + : "Enter a percent from 0 to 100, with up to two decimals." }; + return null; + } + return parsed.value; + } + + const goalCents = money("goalAmount", "Enter your revenue goal.", "Enter a goal above 0."); + const arpaCents = money("monthlyArpa", "Enter your monthly price or ARPA.", "Enter an ARPA above 0."); + const activeFields: (keyof MrrGoalInput)[] = method === "customers" + ? solveFor === "date" ? ["currentCustomers", "newCustomersPerMonth", "churnPercent"] + : solveFor === "pace" ? ["currentCustomers", "churnPercent", "deadlineMonths"] : [] + : method === "growth" + ? solveFor === "date" ? ["currentMrr", "growthPercent"] + : solveFor === "pace" ? ["currentMrr", "deadlineMonths"] : [] : []; + const blank = (key: keyof MrrGoalInput) => typeof input[key] !== "string" || !(input[key] as string).trim(); + const timelineActive = activeFields.some(key => !blank(key)); + let currentCustomers: bigint | null = null; + let newCustomers: bigint | null = null; + let churn: bigint | null = null; + let currentMrr: bigint | null = null; + let growth: bigint | null = null; + let deadline: bigint | null = null; + if (timelineActive) { + if (method === "customers") { + currentCustomers = count("currentCustomers"); + churn = percent("churnPercent"); + if (solveFor === "date") newCustomers = count("newCustomersPerMonth"); + } else if (method === "growth") { + currentMrr = money("currentMrr", "Enter your current MRR.", "A growth rate can't start from 0. Use new customers and churn instead."); + if (solveFor === "date") growth = percent("growthPercent"); + } + if (solveFor === "pace") deadline = count("deadlineMonths"); + } + const trafficActive = method === "customers" && (!blank("visitorToSignupPercent") || !blank("signupToPaidPercent")); + let visitorRate: bigint | null = null; + let paidRate: bigint | null = null; + if (trafficActive) { + visitorRate = percent("visitorToSignupPercent"); + paidRate = percent("signupToPaidPercent"); + if (!timelineActive) errors.traffic = { + code: "trafficNeedsTimeline", message: "Fill in the customer timeline first. Traffic is worked out from its monthly pace.", + }; + } + + if (Object.keys(errors).length || !isSaasEconomicsCurrency(currency) + || (period !== "month" && period !== "year") || (method !== "customers" && method !== "growth") + || (solveFor !== "date" && solveFor !== "pace")) return { status: "invalid", values: null, errors }; + + const goalTwelfthCents = period === "month" ? 12n * goalCents! : goalCents!; + const monthlyGoalCents = divideHalfAwayFromZero(goalTwelfthCents, 12n); + const required = divideCeil(goalTwelfthCents, 12n * arpaCents!); + const goal: MrrGoalGoal = { + period, enteredCents: goalCents!, monthlyGoalCents, + monthlyGoalIsRounded: goalTwelfthCents % 12n !== 0n, + annualGoalCents: goalTwelfthCents, customersNeeded: required, + mrrAtCustomersNeededCents: required * arpaCents!, + }; + const timeline = !timelineActive ? null : method === "customers" + ? customerTimeline(required, arpaCents!, currentCustomers!, churn!, newCustomers ?? 0n, solveFor, Number(deadline ?? 0n)) + : growthTimeline(goalTwelfthCents, monthlyGoalCents, currentMrr!, growth ?? 0n, solveFor, Number(deadline ?? 0n)); + const traffic = trafficActive && timeline?.method === "customers" ? trafficForTimeline(timeline, visitorRate!, paidRate!) : null; + return { status: "valid", errors: {}, values: { currency, monthlyArpaCents: arpaCents!, goal, timeline, traffic } }; +} diff --git a/lib/registered-site-pages.json b/lib/registered-site-pages.json index d397c2b9..4d3501df 100644 --- a/lib/registered-site-pages.json +++ b/lib/registered-site-pages.json @@ -4,6 +4,7 @@ "/about", "/ad-networks", "/ad-networks/[slug]", + "/annual-plan-discount-calculator", "/arpu-arpa-calculator", "/arr-calculator", "/backup-321-calculator", @@ -13,6 +14,7 @@ "/cac-payback-calculator", "/ceph-capacity-calculator", "/chmod-calculator", + "/churn-rate-converter", "/cli-apps", "/components-ui", "/components-ui/[section]", @@ -35,6 +37,7 @@ "/domain-report", "/drive-reliability", "/epp-status-codes", + "/free-trial-conversion-calculator", "/ip-asn-lookup", "/kdp-backend-keywords-builder", "/kdp-book-description-formatter", @@ -276,6 +279,7 @@ "/lxc-idmap-checker", "/matter-devices", "/mrr-calculator", + "/mrr-goal-calculator", "/mrr-waterfall-calculator", "/multi-tld-availability", "/nameserver-checker", @@ -319,6 +323,7 @@ "/snapraid-mergerfs-planner", "/soa-checker", "/software-licenses-for-self-hosting", + "/startup-runway-calculator", "/storage-growth-planner", "/storage-layout-advisor", "/terms", diff --git a/lib/startup-runway-calculator/engine.ts b/lib/startup-runway-calculator/engine.ts new file mode 100644 index 00000000..9e5cd501 --- /dev/null +++ b/lib/startup-runway-calculator/engine.ts @@ -0,0 +1,327 @@ +import { type SaasEconomicsCurrency } from "../saas-economics/shared.ts"; +import { + SAAS_ECONOMICS_MAX_MONEY_CENTS, + divideCeil, + divideHalfAwayFromZero, + isSaasEconomicsCurrency, + parseMoneyCents, + parseOptionalLabel, + parsePercentHundredths, + parseWholeNumber, +} from "../saas-economics/shared.ts"; + +export const STARTUP_RUNWAY_HORIZON_MONTHS = 60; +export const STARTUP_RUNWAY_MAX_PLANNED_CHANGES = 10; +export const STARTUP_RUNWAY_NAME_MAX_LENGTH = 60; +export const STARTUP_RUNWAY_DIRECTIONS = ["in", "out"] as const; +export type StartupRunwayDirection = (typeof STARTUP_RUNWAY_DIRECTIONS)[number]; +export const STARTUP_RUNWAY_FREQUENCIES = ["once", "monthly", "yearly"] as const; +export type StartupRunwayFrequency = (typeof STARTUP_RUNWAY_FREQUENCIES)[number]; + +export type StartupRunwayPlannedChangeInput = { + id: string; + name: string; + direction: string; + amount: string; + startMonth: string; + frequency: string; +}; + +export type StartupRunwayInput = { + currency: string; + cashNow: string; + collectedPerMonth: string; + paidOutPerMonth: string; + revenueGrowthPercent: string; + expenseGrowthPercent: string; + plannedChanges: StartupRunwayPlannedChangeInput[]; +}; + +export type StartupRunwayFieldKey = + | "form" | "currency" | "cashNow" | "collectedPerMonth" | "paidOutPerMonth" + | "revenueGrowthPercent" | "expenseGrowthPercent" | "plannedChanges" + | `plannedChanges.${string}.name` | `plannedChanges.${string}.direction` + | `plannedChanges.${string}.amount` | `plannedChanges.${string}.startMonth` + | `plannedChanges.${string}.frequency`; + +export type StartupRunwayErrorCode = + | "missing" | "format" | "negative" | "range" | "zero" | "option" | "limit" | "duplicateId" | "form"; +export type StartupRunwayErrors = Partial>; + +export type StartupRunwayToday = + | { + kind: "burning"; + netBurnCents: bigint; + runwayTenths: bigint; + runwayIsExact: boolean; + cashOutMonth: bigint; + } + | { kind: "breakEven" } + | { kind: "cashFlowPositive"; surplusCents: bigint }; + +export type StartupRunwayPlannedChange = { + id: string; + name: string; + nameWasBlank: boolean; + direction: StartupRunwayDirection; + amountCents: bigint; + startMonth: number; + frequency: StartupRunwayFrequency; +}; + +export type StartupRunwayMonth = { + month: number; + collectedCents: bigint; + paidOutCents: bigint; + plannedCents: bigint; + changeCents: bigint; + balanceCents: bigint; + atTodaysRateCents: bigint; + coversCosts: boolean | null; +}; + +export type StartupRunwayCashOut = { + month: number; + runwayTenths: bigint; + runwayIsExact: boolean; +}; + +export type StartupRunwayProjection = { + revenueGrowthHundredths: bigint; + revenueGrowthWasBlank: boolean; + expenseGrowthHundredths: bigint; + expenseGrowthWasBlank: boolean; + plannedChanges: StartupRunwayPlannedChange[]; + matchesToday: boolean; + cashOut: StartupRunwayCashOut | null; + firstCoversCostsMonth: number | null; + coversCostsFromMonth: number | null; + lowestBalance: { month: number; cents: bigint }; + cashShortfallCents: bigint | null; + outcome: + | { kind: "runsOut" } + | { kind: "coversCostsThroughout" } + | { kind: "coversCostsBeforeCashOut"; month: number } + | { kind: "lastsBeyondHorizon" }; + path: StartupRunwayMonth[]; +}; + +export type StartupRunwayValues = { + currency: SaasEconomicsCurrency; + cashNowCents: bigint; + collectedPerMonthCents: bigint; + grossBurnCents: bigint; + netBurnCents: bigint; + today: StartupRunwayToday; + projection: StartupRunwayProjection | null; +}; + +export type StartupRunwayCalculation = + | { status: "valid"; values: StartupRunwayValues; errors: Record } + | { status: "invalid"; values: null; errors: StartupRunwayErrors }; + +const rateDenominator = 10000n; +const formMessage = "Something in the form is out of date. Reset and try again."; +const optionMessage = "Choose one of the listed options."; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : null; +} + +function rawString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function projectRunway( + cash: bigint, collected: bigint, paidOut: bigint, + revenueGrowth: bigint, revenueGrowthWasBlank: boolean, + expenseGrowth: bigint, expenseGrowthWasBlank: boolean, + plannedChanges: StartupRunwayPlannedChange[], +): StartupRunwayProjection { + const netBurn = paidOut - collected; + const path: StartupRunwayMonth[] = [{ + month: 0, collectedCents: 0n, paidOutCents: 0n, plannedCents: 0n, + changeCents: 0n, balanceCents: cash, atTodaysRateCents: cash, coversCosts: null, + }]; + let balance = cash; + let lowestBalance = { month: 0, cents: cash }; + let cashOut: StartupRunwayCashOut | null = null; + let firstCoversCostsMonth: number | null = null; + let coversCostsFromMonth: number | null = null; + let revenuePower = 1n; + let expensePower = 1n; + let denominatorPower = 1n; + + for (let month = 1; month <= STARTUP_RUNWAY_HORIZON_MONTHS; month += 1) { + // Round the exact powers, never the preceding month's rounded cents. + const collectedCents = divideHalfAwayFromZero(collected * revenuePower, denominatorPower); + const paidOutCents = divideHalfAwayFromZero(paidOut * expensePower, denominatorPower); + let plannedCents = 0n; + let recurringIn = collectedCents; + let recurringOut = paidOutCents; + for (const change of plannedChanges) { + if (month < change.startMonth) continue; + if (change.frequency === "once" && month !== change.startMonth) continue; + if (change.frequency === "yearly" && (month - change.startMonth) % 12 !== 0) continue; + plannedCents += change.direction === "in" ? change.amountCents : -change.amountCents; + if (change.frequency === "monthly") { + if (change.direction === "in") recurringIn += change.amountCents; + else recurringOut += change.amountCents; + } + } + const coversCosts = recurringIn >= recurringOut; + if (coversCosts) { + if (firstCoversCostsMonth === null) firstCoversCostsMonth = month; + if (coversCostsFromMonth === null) coversCostsFromMonth = month; + } else coversCostsFromMonth = null; + + const changeCents = collectedCents - paidOutCents + plannedCents; + const previousBalance = balance; + balance += changeCents; + if (cashOut === null && balance <= 0n && changeCents < 0n) { + const gap = -changeCents; + cashOut = { + month: month === 1 && previousBalance === 0n ? 0 : month, + runwayTenths: 10n * BigInt(month - 1) + 10n * previousBalance / gap, + runwayIsExact: (10n * previousBalance) % gap === 0n, + }; + } + if (balance < lowestBalance.cents) lowestBalance = { month, cents: balance }; + path.push({ + month, collectedCents, paidOutCents, plannedCents, changeCents, + balanceCents: balance, atTodaysRateCents: cash - BigInt(month) * netBurn, coversCosts, + }); + // The largest exact product has 265 decimal digits at month 60. + if (month < STARTUP_RUNWAY_HORIZON_MONTHS) { + revenuePower *= rateDenominator + revenueGrowth; + expensePower *= rateDenominator + expenseGrowth; + denominatorPower *= rateDenominator; + } + } + + const outcome: StartupRunwayProjection["outcome"] = cashOut !== null + ? { kind: "runsOut" } + : coversCostsFromMonth === 1 ? { kind: "coversCostsThroughout" } + : coversCostsFromMonth !== null ? { kind: "coversCostsBeforeCashOut", month: coversCostsFromMonth } + : { kind: "lastsBeyondHorizon" }; + return { + revenueGrowthHundredths: revenueGrowth, revenueGrowthWasBlank, + expenseGrowthHundredths: expenseGrowth, expenseGrowthWasBlank, plannedChanges, + matchesToday: revenueGrowth === 0n && expenseGrowth === 0n && plannedChanges.length === 0, + cashOut, firstCoversCostsMonth, coversCostsFromMonth, lowestBalance, + cashShortfallCents: lowestBalance.cents < 0n && coversCostsFromMonth !== null ? -lowestBalance.cents : null, + outcome, path, + }; +} + +export function calculateStartupRunway(inputValue: unknown): StartupRunwayCalculation { + const input = asRecord(inputValue); + if (!input) return { status: "invalid", values: null, errors: { form: { code: "form", message: formMessage } } }; + const errors: StartupRunwayErrors = {}; + const currency = rawString(input.currency).trim(); + if (!isSaasEconomicsCurrency(currency)) errors.currency = { code: "option", message: optionMessage }; + + function money(raw: unknown, key: StartupRunwayFieldKey, missing: string, positive = false): bigint | null { + if (rawString(raw).trim().startsWith("-")) { + errors[key] = { code: "negative", message: positive + ? "Enter an amount above 0. Use Type to choose money in or out." + : "Enter 0 or more. This tool doesn't model overdrafts or debt." }; + return null; + } + const parsed = parseMoneyCents(raw, { min: 0n, max: SAAS_ECONOMICS_MAX_MONEY_CENTS }); + if (!parsed.ok) { + errors[key] = { code: parsed.reason, message: parsed.reason === "missing" ? missing + : parsed.reason === "range" ? "Enter an amount up to 1,000,000,000.00." + : "Use digits with up to two decimals. No commas or currency symbols." }; + return null; + } + if (positive && parsed.value === 0n) { + errors[key] = { code: "zero", message: "Enter an amount above 0." }; + return null; + } + return parsed.value; + } + + function growth(key: "revenueGrowthPercent" | "expenseGrowthPercent", value: unknown): { value: bigint | null; wasBlank: boolean } { + const raw = rawString(value); + if (!raw.trim()) return { value: 0n, wasBlank: true }; + const parsed = parsePercentHundredths(raw, { min: -rateDenominator, max: rateDenominator }); + if (!parsed.ok) { + errors[key] = { code: parsed.reason, message: "Enter a monthly rate from -100 to 100, with up to two decimals. Leave out the % sign." }; + return { value: null, wasBlank: false }; + } + return { value: parsed.value, wasBlank: false }; + } + + const cash = money(input.cashNow, "cashNow", "Enter the cash you have now. Use 0 if none."); + const collected = money(input.collectedPerMonth, "collectedPerMonth", "Enter the cash customers pay you in a typical month. Use 0 if none."); + const paidOut = money(input.paidOutPerMonth, "paidOutPerMonth", "Enter what you pay out in a typical month. Use 0 if none."); + const revenueGrowth = growth("revenueGrowthPercent", input.revenueGrowthPercent); + const expenseGrowth = growth("expenseGrowthPercent", input.expenseGrowthPercent); + const rows = Array.isArray(input.plannedChanges) ? input.plannedChanges : []; + if (rows.length > STARTUP_RUNWAY_MAX_PLANNED_CHANGES) { + errors.plannedChanges = { code: "limit", message: "You can add up to 10 planned changes." }; + } + const seenIds = new Set(); + const plannedChanges: StartupRunwayPlannedChange[] = []; + let countedRows = 0; + for (const value of rows) { + const row = asRecord(value); + if (!row || typeof row.id !== "string" || !row.id) { + errors.form = { code: "form", message: formMessage }; + continue; + } + if (seenIds.has(row.id) && !errors.plannedChanges) { + errors.plannedChanges = { code: "duplicateId", message: formMessage }; + } + seenIds.add(row.id); + const name = rawString(row.name); + if (!name.trim() && !rawString(row.amount).trim() && !rawString(row.startMonth).trim()) continue; + countedRows += 1; + const prefix = `plannedChanges.${row.id}` as const; + const label = parseOptionalLabel(name, STARTUP_RUNWAY_NAME_MAX_LENGTH); + if (!label.ok) errors[`${prefix}.name`] = { + code: label.reason, message: label.reason === "range" ? "Use 60 characters or fewer." + : "Remove line breaks and special characters from the name.", + }; + const direction = rawString(row.direction).trim(); + if (direction !== "in" && direction !== "out") errors[`${prefix}.direction`] = { code: "option", message: optionMessage }; + const amount = money(row.amount, `${prefix}.amount`, "Enter an amount, or remove this change.", true); + const startMonth = parseWholeNumber(row.startMonth, { min: 1n, max: BigInt(STARTUP_RUNWAY_HORIZON_MONTHS) }); + if (!startMonth.ok) errors[`${prefix}.startMonth`] = { + code: startMonth.reason, message: startMonth.reason === "missing" + ? "Enter the month it starts, from 1 to 60." : "Enter a whole month from 1 to 60.", + }; + const frequency = rawString(row.frequency).trim(); + if (frequency !== "once" && frequency !== "monthly" && frequency !== "yearly") { + errors[`${prefix}.frequency`] = { code: "option", message: optionMessage }; + } + if (label.ok && amount !== null && startMonth.ok && (direction === "in" || direction === "out") + && (frequency === "once" || frequency === "monthly" || frequency === "yearly")) { + plannedChanges.push({ + id: row.id, name: label.value || `Planned change ${countedRows}`, nameWasBlank: !label.value, + direction, amountCents: amount, startMonth: Number(startMonth.value), frequency, + }); + } + } + + if (Object.keys(errors).length || !isSaasEconomicsCurrency(currency)) return { status: "invalid", values: null, errors }; + const netBurn = paidOut! - collected!; + const today: StartupRunwayToday = netBurn > 0n + ? { + kind: "burning", netBurnCents: netBurn, runwayTenths: 10n * cash! / netBurn, + runwayIsExact: (10n * cash!) % netBurn === 0n, cashOutMonth: divideCeil(cash!, netBurn), + } + : netBurn === 0n ? { kind: "breakEven" } : { kind: "cashFlowPositive", surplusCents: -netBurn }; + const forecastOn = !revenueGrowth.wasBlank || !expenseGrowth.wasBlank || plannedChanges.length > 0; + return { + status: "valid", errors: {}, values: { + currency, cashNowCents: cash!, collectedPerMonthCents: collected!, grossBurnCents: paidOut!, netBurnCents: netBurn, today, + projection: forecastOn ? projectRunway( + cash!, collected!, paidOut!, revenueGrowth.value!, revenueGrowth.wasBlank, + expenseGrowth.value!, expenseGrowth.wasBlank, plannedChanges, + ) : null, + }, + }; +} diff --git a/scripts/stack127/marker-art/figures.mjs b/scripts/stack127/marker-art/figures.mjs index e3b19f03..f5812d59 100644 --- a/scripts/stack127/marker-art/figures.mjs +++ b/scripts/stack127/marker-art/figures.mjs @@ -1423,6 +1423,93 @@ function toolSpot(spec, A) { } } return; + } else if (d.motif === "trialConversion") { + // A group that started together waits on a time track. Two filled accounts sit past the dashed day-N gate. + const track = 88; + const size = 14; + const r = size / 2; + const ring = { stroke: "@ink", strokeWidth: 1.6, roughness: 0.7 }; + A.line(12, track, 128, track, { stroke: "@sub", strokeWidth: 1.5, roughness: 0.55 }); + A.line(14, track - 5, 14, track + 5, { stroke: "@sub", strokeWidth: 1.5, roughness: 0.5 }); + const starters = [ + [28, track - r - 1.5], [47, track - r - 1.5], [66, track - r - 1.5], + [37.5, track - r - 18], [56.5, track - r - 18], + [47, track - r - 34.5], + ]; + for (const [x, y] of starters) A.circle(x, y, size, ring); + markerDash(A, 86, 26, 86, track + 6, "@ink", 7, 5); + // Rough's solid circle fill loops twice and cancels under evenodd, so fill with a plain dot. + for (const x of [104, 122]) { + A.dot(x, track - r - 1.5, r - 0.4, "@primary"); + A.circle(x, track - r - 1.5, size, ring); + } + return; + } else if (d.motif === "annualDiscount") { + // A price tag holds a row of paid months. The last slot is empty, its month lifted out above the tag. + A.path("M21 63 C 13 52, 17 38, 7 27", { stroke: "@sub", strokeWidth: 1.5, roughness: 0.7 }); + A.polygon([[9, 63], [26, 41], [110, 41], [110, 85], [26, 85]], { stroke: "@ink", strokeWidth: 1.8, roughness: 0.9 }); + A.circle(21, 63, 6, { stroke: "@ink", strokeWidth: 1.5, roughness: 0.5 }); + const tile = 8.4; + const gap = 4.4; + const top = 56; + const height = 14; + for (let index = 0; index < 5; index += 1) { + A.rect(33 + index * (tile + gap), top, tile, height, { stroke: "@sub", fill: "@sub", fillStyle: "solid", roughness: 0.8, strokeWidth: 1.4 }); + } + dashedRect(A, 33 + 5 * (tile + gap), top - 1, tile + 1, height + 2, "@sub"); + A.polygon([[99, 20], [109, 23], [105, 37], [95, 34]], { stroke: "@primary", fill: "@primary", fillStyle: "solid", roughness: 0.9, strokeWidth: 1.8 }); + return; + } else if (d.motif === "churnCompounding") { + // Customers lost add up along flattening steps, because each period starts with fewer people. + // A dashed cap on the last step shows how much higher a straight multiply puts the total. + const baseline = 94; + const width = 16; + const scale = 70; + const lost = [1, 2, 3, 4, 5].map((periods) => 1 - 0.77 ** periods); + for (let index = 0; index < lost.length; index += 1) { + const height = scale * lost[index]; + A.rect(14 + index * 23, baseline - height, width, height, { + stroke: "@ink", + fill: "@primary", + fillStyle: "solid", + roughness: 1.05, + strokeWidth: 1.55, + }); + } + const lastX = 14 + 4 * 23; + const lastTop = baseline - scale * lost[4]; + const linearTop = baseline - scale * 0.23 * 5; + dashedRect(A, lastX, linearTop, width, lastTop - linearTop - 3, "@sub"); + A.line(6, baseline, 134, baseline, { stroke: "@sub", strokeWidth: 1.4 }); + return; + } else if (d.motif === "goalFlag") { + // Monthly dots climb to a dashed target line and meet it at the foot of a planted flag. + const target = 30; + const poleX = 108; + markerDash(A, 12, target, 130, target, "@sub", 7, 5); + const dots = Array.from({ length: 7 }, (_, index) => { + const share = index / 6; + return [18 + share * (poleX - 18), 96 - (96 - target) * share ** 0.9]; + }); + for (let index = 1; index < dots.length; index += 1) { + A.line(dots[index - 1][0], dots[index - 1][1], dots[index][0], dots[index][1], { stroke: "@sub", strokeWidth: 1.3, roughness: 0.6 }); + } + for (const [x, y] of dots) A.dot(x, y, 3.2, "@ink"); + A.line(poleX, target, poleX, 7, { stroke: "@ink", strokeWidth: 1.9, roughness: 0.6 }); + A.polygon([[poleX, 8], [128, 13.5], [poleX, 19]], { stroke: "@primary", fill: "@primary", fillStyle: "solid", roughness: 0.8, strokeWidth: 1.6 }); + return; + } else if (d.motif === "runwayLiftoff") { + // A strip recedes to a marked end. A paper plane has already lifted off well before the end bars. + A.line(6, 76, 100, 83, { stroke: "@ink", strokeWidth: 1.8, roughness: 0.6 }); + A.line(6, 104, 100, 95, { stroke: "@ink", strokeWidth: 1.8, roughness: 0.6 }); + markerDash(A, 12, 90, 94, 89, "@sub", 6, 5); + A.line(104, 78, 104, 100, { stroke: "@ink", strokeWidth: 3.4, roughness: 0.5 }); + markerDash(A, 36, 88, 54, 74, "@primary", 5, 4); + markerDash(A, 54, 74, 72, 58, "@primary", 5, 4); + markerDash(A, 72, 58, 88, 45, "@primary", 5, 4); + A.polygon([[118, 20], [82, 33], [95, 37]], { stroke: "@primary", fill: "@primary", fillStyle: "solid", roughness: 0.8, strokeWidth: 1.6 }); + A.polygon([[118, 20], [95, 37], [93, 49]], { stroke: "@primary", strokeWidth: 1.6, roughness: 0.8 }); + return; } else { drawIcon(A, d.icon, cx, cy, Math.min(A.w, A.h) * 0.48, "@ink"); } diff --git a/tests/pages/annual-plan-discount-calculator/annual-plan-discount-calculator.test.mjs b/tests/pages/annual-plan-discount-calculator/annual-plan-discount-calculator.test.mjs new file mode 100644 index 00000000..c6ca4704 --- /dev/null +++ b/tests/pages/annual-plan-discount-calculator/annual-plan-discount-calculator.test.mjs @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ANNUAL_PLAN_FEE_PRESETS, + calculateAnnualPlanDiscount, +} from "../../../lib/annual-plan-discount-calculator/engine.ts"; + +const input = (overrides = {}) => ({ + currency: "USD", monthlyPrice: "20", offerMode: "discount", annualPrice: "", + discountPercent: "20", monthsFree: "", monthlyChurnPercent: "", annualRenewalPercent: "", + feePreset: "none", feePercent: "", feeFixed: "", ...overrides, +}); +const valid = (overrides = {}) => { + const result = calculateAnnualPlanDiscount(input(overrides)); + assert.equal(result.status, "valid", JSON.stringify(result.errors)); + assert.deepEqual(result.errors, {}); + return result.values; +}; +const invalid = (overrides, key, message) => { + const result = calculateAnnualPlanDiscount(input(overrides)); + assert.equal(result.status, "invalid"); + assert.equal(result.values, null); + assert.equal(result.errors[key], message); + return result.errors; +}; +const fees = (feePreset) => { + const preset = ANNUAL_PLAN_FEE_PRESETS.find(value => value.id === feePreset); + return { feePreset, feePercent: String(Number(preset.percentHundredths) / 100), feeFixed: String(Number(preset.fixedCents) / 100) }; +}; +const example1 = { + offerMode: "monthsFree", monthsFree: "2", monthlyChurnPercent: "3", annualRenewalPercent: "70", ...fees("stripeCard"), +}; +const table = (v) => v.comparison.horizons.map(h => [ + h.months, h.monthly.grossCents, h.annual?.grossCents ?? null, + h.differenceCents, h.annualVsMonthly, h.breakEvenDiscount?.tenths ?? null, +]); + +test("example 1: two months free, churn, annual renewals, and Stripe fees", () => { + const v = valid(example1); + assert.equal(v.offer.annualPriceCents, 20000n); + assert.equal(v.offer.annualPriceRounded, false); + assert.equal(v.offer.discount.tenths, 167n); + assert.deepEqual(v.offer.discount.exact, { numerator: 1n, denominator: 6n }); + assert.deepEqual(v.offer.monthsFree, { tenths: 20n, exact: true }); + assert.equal(v.offer.customerSavingsCents, 4000n); + assert.equal(v.offer.mrr.monthlyPlanCents, 2000n); + assert.deepEqual(v.offer.mrr.annualPlan, { cents: 1667n, exact: { numerator: 5000n, denominator: 3n } }); + assert.equal(v.offer.mrr.differenceCents, -333n); + assert.deepEqual(v.offer.invoiceEquivalent, { months: { tenths: 100n, exact: true }, wholePayments: 10n }); + assert.deepEqual(v.upfront, { + monthly: { grossCents: 2000n, fee: { feesCents: 88n, netCents: 1912n } }, + annual: { grossCents: 20000n, fee: { feesCents: 610n, netCents: 19390n } }, differenceCents: 18000n, + }); + assert.deepEqual(table(v), [ + [12, 20411n, 20000n, -411n, "less", 150n], + [24, 34572n, 34000n, -572n, "less", 153n], + [36, 44398n, 43800n, -598n, "less", 155n], + ]); + assert.equal(v.comparison.horizons[0].monthly.expectedChargesHundredths, 1021n); + assert.equal(v.comparison.horizons[0].annual.expectedChargesHundredths, 100n); + assert.deepEqual(v.comparison.horizons.map(h => [h.monthly.fee, h.annual.fee, h.netDifferenceCents]), [ + [{ feesCents: 898n, netCents: 19513n }, { feesCents: 610n, netCents: 19390n }, -123n], + [{ feesCents: 1521n, netCents: 33051n }, { feesCents: 1037n, netCents: 32963n }, -88n], + [{ feesCents: 1954n, netCents: 42444n }, { feesCents: 1336n, netCents: 42464n }, 20n], + ]); + const totals = v.comparison.runningTotals; + assert.equal(totals.length, 36); + assert.deepEqual(totals[0], { month: 1, monthlyCents: 2000n, annualCents: 20000n }); + for (const [month, expected] of [[12, 20000n], [13, 34000n], [24, 34000n], [25, 43800n], [36, 43800n]]) { + assert.equal(totals[month - 1].annualCents, expected); + } + for (const h of v.comparison.horizons) assert.equal(totals[h.months - 1].monthlyCents, h.monthly.grossCents); +}); + +test("example 2: annual price, fractional invoice threshold, and unknown renewals", () => { + const v = valid({ offerMode: "annualPrice", annualPrice: "192", monthlyChurnPercent: "3" }); + assert.equal(v.offer.discount.tenths, 200n); + assert.deepEqual(v.offer.monthsFree, { tenths: 24n, exact: false }); + assert.equal(v.offer.customerSavingsCents, 4800n); + assert.equal(v.offer.mrr.annualPlan.cents, 1600n); + assert.equal(v.offer.mrr.differenceCents, -400n); + assert.deepEqual(v.offer.invoiceEquivalent, { months: { tenths: 96n, exact: false }, wholePayments: 10n }); + assert.deepEqual(table(v), [ + [12, 20411n, 19200n, -1211n, "less", 150n], + [24, 34572n, null, null, null, null], [36, 44398n, null, null, null, null], + ]); + assert.equal(v.comparison.annualRenewalHundredths, null); + assert.ok(v.comparison.horizons.every(h => h.netDifferenceCents === null && h.monthly.fee === null)); + assert.ok(v.comparison.runningTotals.slice(0, 12).every(point => point.annualCents === 19200n)); + assert.ok(v.comparison.runningTotals.slice(12).every(point => point.annualCents === null)); +}); + +test("example 3: annual billings exceed monthly at five percent churn", () => { + const v = valid({ monthlyPrice: "50", monthlyChurnPercent: "5", annualRenewalPercent: "80" }); + assert.equal(v.offer.annualPriceCents, 48000n); + assert.deepEqual(table(v), [ + [12, 45964n, 48000n, 2036n, "more", 234n], + [24, 70801n, 86400n, 15599n, "more", 344n], + [36, 84222n, 117120n, 32898n, "more", 425n], + ]); + assert.equal(v.comparison.horizons[0].monthly.expectedChargesHundredths, 919n); +}); + +test("example 4: round the annual offer first and use it for every later figure", () => { + const v = valid({ monthlyPrice: "9.99", discountPercent: "15" }); + assert.equal(v.offer.annualPriceCents, 10190n); + assert.equal(v.offer.annualPriceRounded, true); + assert.equal(v.offer.discount.tenths, 150n); + assert.deepEqual(v.offer.discount.exact, { numerator: 899n, denominator: 5994n }); + assert.deepEqual(v.offer.monthsFree, { tenths: 18n, exact: false }); + assert.equal(v.offer.customerSavingsCents, 1798n); + assert.equal(v.offer.mrr.annualPlan.cents, 849n); + assert.deepEqual(v.offer.invoiceEquivalent, { months: { tenths: 102n, exact: false }, wholePayments: 11n }); + assert.equal(v.comparison, null); +}); + +test("example 5: fractional churn and Paddle fees", () => { + const v = valid({ monthlyPrice: "29", monthlyChurnPercent: "2.5", annualRenewalPercent: "75", ...fees("paddle") }); + assert.equal(v.offer.annualPriceCents, 27840n); + assert.deepEqual(table(v), [ + [12, 30392n, 27840n, -2552n, "less", 127n], + [24, 52822n, 48720n, -4102n, "less", 133n], + [36, 69374n, 64380n, -4994n, "less", 138n], + ]); + assert.equal(v.comparison.horizons[0].monthly.expectedChargesHundredths, 1048n); + assert.equal(v.comparison.horizons[0].monthly.fee.feesCents, 2044n); + assert.equal(v.comparison.horizons[0].annual.fee.feesCents, 1442n); +}); + +test("example 6: zero churn and complete annual renewal", () => { + const v = valid({ offerMode: "monthsFree", monthsFree: "2", monthlyChurnPercent: "0", annualRenewalPercent: "100" }); + assert.deepEqual(table(v), [ + [12, 24000n, 20000n, -4000n, "less", 0n], + [24, 48000n, 40000n, -8000n, "less", 0n], + [36, 72000n, 60000n, -12000n, "less", 0n], + ]); + assert.ok(v.comparison.horizons.every(h => !h.breakEvenDiscount.nearZero)); +}); + +test("example 7: complete monthly churn and no annual renewal", () => { + const v = valid({ offerMode: "monthsFree", monthsFree: "2", monthlyChurnPercent: "100", annualRenewalPercent: "0" }); + for (const h of v.comparison.horizons) { + assert.equal(h.monthly.grossCents, 2000n); + assert.equal(h.monthly.expectedChargesHundredths, 100n); + assert.equal(h.annual.grossCents, 20000n); + assert.equal(h.annual.expectedChargesHundredths, 100n); + assert.equal(h.breakEvenDiscount.tenths, 917n); + } +}); + +test("example 8: annual surcharges retain signed savings and months free", () => { + const v = valid({ offerMode: "annualPrice", annualPrice: "300" }); + assert.equal(v.offer.discount.tenths, -250n); + assert.deepEqual(v.offer.monthsFree, { tenths: -30n, exact: true }); + assert.equal(v.offer.customerSavingsCents, -6000n); + assert.deepEqual(v.offer.invoiceEquivalent, { months: { tenths: 150n, exact: true }, wholePayments: 15n }); +}); + +test("example 9: each preset charges the fixed fee once per expected payment", () => { + for (const [preset, monthly, annual] of [ + ["paddle", 1800n, 1050n], ["lemonSqueezy", 1920n, 1150n], + ["stripeCardBilling", 1224n, 750n], ["stripeCard", 1056n, 610n], + ]) { + const v = valid({ offerMode: "monthsFree", monthsFree: "2", monthlyChurnPercent: "0", ...fees(preset) }); + const h = v.comparison.horizons[0]; + assert.equal(h.monthly.fee.feesCents, monthly, preset); + assert.equal(h.annual.fee.feesCents, annual, preset); + assert.equal(v.fee.edited, false); + assert.equal(v.offer.mrr.annualPlan.cents, 1667n); + assert.equal(h.breakEvenDiscount.tenths, 0n); + } +}); + +test("example 10: custom and edited fees use the entered values", () => { + const original = valid(example1); + const custom = valid({ ...example1, feePreset: "custom", feePercent: "2.9", feeFixed: "0.30" }); + assert.deepEqual(custom.fee, { preset: "custom", percentHundredths: 290n, fixedCents: 30n, edited: false }); + assert.deepEqual(custom.upfront, original.upfront); + assert.deepEqual(custom.comparison, original.comparison); + const edited = valid({ ...example1, feeFixed: "0.25" }); + assert.equal(edited.fee.edited, true); + assert.equal(edited.upfront.annual.fee.feesCents, 605n); +}); + +test("example 11: no discount and no churn give exactly equal year-one billings", () => { + const v = valid({ discountPercent: "0", monthlyChurnPercent: "0" }); + assert.equal(v.offer.discount.tenths, 0n); + assert.equal(v.offer.discount.nearZero, false); + assert.equal(v.offer.customerSavingsCents, 0n); + assert.deepEqual(v.offer.monthsFree, { tenths: 0n, exact: true }); + assert.equal(v.comparison.horizons[0].differenceCents, 0n); + assert.equal(v.comparison.horizons[0].annualVsMonthly, "same"); +}); + +test("example 12: a derived annual price rounding to zero is invalid", () => { + invalid({ monthlyPrice: "0.01", offerMode: "monthsFree", monthsFree: "11.99" }, "monthsFree", + "This makes the annual price 0.00. Use fewer months free."); + invalid({ monthlyPrice: "0.01", discountPercent: "99.99" }, "discountPercent", + "This makes the annual price 0.00. Use a smaller discount."); +}); + +test("example 13: a derived annual price may exceed the money entry maximum", () => { + const v = valid({ monthlyPrice: "1000000000", discountPercent: "0" }); + assert.equal(v.offer.annualPriceCents, 1200000000000n); + assert.equal(v.offer.annualPriceRounded, false); + assert.equal(v.offer.mrr.annualPlan.cents, 100000000000n); +}); + +test("example 14: invalid inputs return field errors and never return values", () => { + const moneyFormat = "Use digits with up to two decimals. No signs, commas, or currency symbols."; + invalid({ monthlyPrice: "" }, "monthlyPrice", "Enter the monthly price."); + invalid({ monthlyPrice: "0" }, "monthlyPrice", "Enter a monthly price above 0."); + for (const monthlyPrice of ["-20", "$20", "1,000", "20.001", "1e3", "+20", ".5", "5."]) { + invalid({ monthlyPrice }, "monthlyPrice", moneyFormat); + } + for (const discountPercent of ["100", "-5", "20%", "110", "20.001"]) { + invalid({ discountPercent }, "discountPercent", + "Enter a number from 0 to 99.99, without the % sign. To charge more than 12 monthly payments, choose Annual price."); + } + invalid({ discountPercent: "" }, "discountPercent", "Enter the annual discount."); + for (const monthsFree of ["12", "13", "-1"]) { + invalid({ offerMode: "monthsFree", monthsFree }, "monthsFree", "Enter months free from 0 to 11.99."); + } + invalid({ offerMode: "monthsFree", monthsFree: "" }, "monthsFree", "Enter the number of months free."); + invalid({ offerMode: "annualPrice", annualPrice: "0" }, "annualPrice", "Enter an annual price above 0."); + invalid({ offerMode: "annualPrice", annualPrice: "" }, "annualPrice", "Enter the annual price."); + for (const key of ["monthlyChurnPercent", "annualRenewalPercent"]) { + for (const raw of ["100.01", "-1", "101", "3%", "0.001"]) { + invalid({ [key]: raw }, key, "Enter a percent from 0 to 100, with up to two decimals, or leave it blank."); + } + } + invalid({ ...fees("paddle"), feePercent: "" }, "feePercent", "Enter a fee percent from 0 to 50, with up to two decimals."); + invalid({ ...fees("paddle"), currency: "EUR" }, "feePreset", + "These presets are US rates in USD. Choose Custom fee for other currencies."); + for (const [key, raw] of [["currency", "JPY"], ["offerMode", "weekly"], ["feePreset", "unknown"]]) { + invalid({ [key]: raw }, key, "Choose one of the listed options."); + } + const formError = "Something in the form is out of date. Reset and try again."; + for (const raw of [null, [], 20, "bad", input({ monthlyPrice: 20 }), input({ annualRenewalPercent: null })]) { + const result = calculateAnnualPlanDiscount(raw); + assert.equal(result.status, "invalid"); + assert.equal(result.values, null); + assert.equal(result.errors.form, formError); + } + assert.equal(valid({ annualPrice: "junk", monthsFree: "junk", feePercent: "junk", feeFixed: "junk" }).comparison, null); + assert.equal(valid({ annualRenewalPercent: "70" }).comparison, null); + assert.equal(valid({ monthlyPrice: " 20.00 ", discountPercent: " 20.00 " }).offer.annualPriceCents, 19200n); +}); + +test("rounding preserves tiny signed discounts, exact comparisons, and negative net receipts", () => { + for (const [annualPrice, sign] of [["239.99", 1n], ["240.01", -1n]]) { + const v = valid({ offerMode: "annualPrice", annualPrice }); + assert.equal(v.offer.discount.tenths, 0n); + assert.equal(v.offer.discount.nearZero, true); + assert.equal(v.offer.discount.exact.numerator, sign); + } + const negative = valid({ monthlyPrice: "0.01", discountPercent: "0", monthlyChurnPercent: "0", feePreset: "custom", feePercent: "0", feeFixed: "1" }); + assert.equal(negative.upfront.monthly.fee.netCents, -99n); + assert.equal(negative.comparison.horizons[0].monthly.fee.netCents, -1188n); + assert.equal(negative.comparison.horizons[0].annual.fee.netCents, -88n); + assert.equal(negative.comparison.horizons[0].netDifferenceCents, 1100n); + const surcharge = valid({ discountPercent: "0", monthlyChurnPercent: "0", annualRenewalPercent: "0" }); + assert.equal(surcharge.comparison.horizons[1].breakEvenDiscount.tenths, -1000n); + assert.equal(surcharge.comparison.horizons[2].breakEvenDiscount.tenths, -2000n); + const tiny = valid({ monthlyPrice: "0.01", discountPercent: "0", monthlyChurnPercent: "0.01" }).comparison.horizons[0]; + assert.equal(tiny.differenceCents, 0n); + assert.equal(tiny.annualVsMonthly, "more"); + const halfCent = valid({ monthlyPrice: "0.05", offerMode: "monthsFree", monthsFree: "1.9" }); + assert.equal(halfCent.offer.annualPriceCents, 51n); + assert.equal(halfCent.offer.annualPriceRounded, true); + const halfFee = valid({ monthlyPrice: "0.01", feePreset: "custom", feePercent: "50", feeFixed: "0" }); + assert.equal(halfFee.upfront.monthly.fee.feesCents, 1n); +}); + +test("validation collects all active errors and enforces price and fee bounds", () => { + const errors = invalid({ monthlyPrice: "0", discountPercent: "100", monthlyChurnPercent: "101", + annualRenewalPercent: "101", feePreset: "custom", feePercent: "51", feeFixed: "101" }, + "monthlyPrice", "Enter a monthly price above 0."); + assert.deepEqual(Object.keys(errors).sort(), ["monthlyPrice", "discountPercent", "monthlyChurnPercent", "annualRenewalPercent", "feePercent", "feeFixed"].sort()); + invalid({ monthlyPrice: "1000000000.01" }, "monthlyPrice", "Enter an amount up to 1,000,000,000.00."); + invalid({ offerMode: "annualPrice", annualPrice: "1000000000.01" }, "annualPrice", "Enter an amount up to 1,000,000,000.00."); + for (const feePercent of ["-1", "50.01", "2.901"]) { + invalid({ feePreset: "custom", feePercent, feeFixed: "0" }, "feePercent", "Enter a fee percent from 0 to 50, with up to two decimals."); + } + invalid({ feePreset: "custom", feePercent: "0", feeFixed: "" }, "feeFixed", "Enter a fixed fee per charge. Use 0 if there is none."); + invalid({ feePreset: "custom", feePercent: "0", feeFixed: "100.01" }, "feeFixed", "Enter a fixed fee up to 100.00."); + invalid({ feePreset: "custom", feePercent: "0", feeFixed: "-1" }, "feeFixed", "Use digits with up to two decimals. No signs, commas, or currency symbols."); + assert.equal(valid({ currency: "EUR", feePreset: "custom", feePercent: "50", feeFixed: "100" }).fee.fixedCents, 10000n); +}); diff --git a/tests/pages/churn-rate-converter/engine.test.mjs b/tests/pages/churn-rate-converter/engine.test.mjs new file mode 100644 index 00000000..7d650822 --- /dev/null +++ b/tests/pages/churn-rate-converter/engine.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CHURN_PERIODS, CHURN_PERIOD_MONTHS, convertChurnRate } from "../../../lib/churn-rate-converter/engine.ts"; + +function values(rate, period) { + const result = convertChurnRate({ rate, period }); + assert.equal(result.status, "valid", JSON.stringify(result.errors)); + assert.deepEqual(result.errors, {}); + return result.values; +} + +function percent(hundredths, roundedToZero = false, nearlyAll = false) { + return { hundredths, roundedToZero, nearlyAll }; +} + +function linear(hundredths, overHundred = false, roundedToZero = false) { + return { hundredths, overHundred, roundedToZero }; +} + +function row(period, isSource, churn, retention, linearEstimate) { + return { period, isSource, churn, retention, linearEstimate }; +} + +function cohort(lost, remaining, lostUnderOne = false, remainingUnderOne = false) { + return { lost, remaining, lostUnderOne, remainingUnderOne }; +} + +function expectConversion(rate, period, h, rows, yearOf100, tenths) { + assert.deepEqual(values(rate, period), { + source: { hundredths: h, period }, + rows, + leadPeriod: period === "year" ? "month" : "year", + yearOf100, + lifetime: tenths === null + ? { kind: "notEstimable", reason: "zeroChurn" } + : { kind: "finite", tenths, period }, + }); +} + +// These fixtures use the spec's independently calculated examples, not engine-generated values. +test("5% monthly compounds to 45.96% annual churn", () => { + expectConversion("5", "month", 500, [ + row("month", true, percent(500), percent(9500), null), + row("quarter", false, percent(1426), percent(8574), linear(1500)), + row("year", false, percent(4596), percent(5404), linear(6000)), + ], cohort(46, 54), 200); +}); + +test("5% quarterly converts in both directions", () => { + expectConversion("5", "quarter", 500, [ + row("month", false, percent(170), percent(9830), linear(167)), + row("quarter", true, percent(500), percent(9500), null), + row("year", false, percent(1855), percent(8145), linear(2000)), + ], cohort(19, 81), 200); +}); + +test("20% annual uses the monthly lead result", () => { + expectConversion("20", "year", 2000, [ + row("month", false, percent(184), percent(9816), linear(167)), + row("quarter", false, percent(543), percent(9457), linear(500)), + row("year", true, percent(2000), percent(8000), null), + ], cohort(20, 80), 50); +}); + +test("3.5% monthly rounds churn and lifetime once", () => { + expectConversion("3.5", "month", 350, [ + row("month", true, percent(350), percent(9650), null), + row("quarter", false, percent(1014), percent(8986), linear(1050)), + row("year", false, percent(3479), percent(6521), linear(4200)), + ], cohort(35, 65), 286); +}); + +test("10% monthly preserves a linear estimate above 100%", () => { + expectConversion("10", "month", 1000, [ + row("month", true, percent(1000), percent(9000), null), + row("quarter", false, percent(2710), percent(7290), linear(3000)), + row("year", false, percent(7176), percent(2824), linear(12000, true)), + ], cohort(72, 28), 100); +}); + +test("45.96% annual rounds back to 5% monthly", () => { + expectConversion("45.96", "year", 4596, [ + row("month", false, percent(500), percent(9500), linear(383)), + row("quarter", false, percent(1426), percent(8574), linear(1149)), + row("year", true, percent(4596), percent(5404), null), + ], cohort(46, 54), 22); +}); + +test("zero monthly churn has no boundary flags and no estimable lifetime", () => { + expectConversion("0", "month", 0, [ + row("month", true, percent(0), percent(10000), null), + row("quarter", false, percent(0), percent(10000), linear(0)), + row("year", false, percent(0), percent(10000), linear(0)), + ], cohort(0, 100), null); +}); + +test("complete quarterly churn keeps lifetime in quarters", () => { + expectConversion("100", "quarter", 10000, [ + row("month", false, percent(10000), percent(0), linear(3333)), + row("quarter", true, percent(10000), percent(0), null), + row("year", false, percent(10000), percent(0), linear(40000, true)), + ], cohort(100, 0), 10); +}); + +test("0.01% annual flags positive rates and linear estimates rounded to zero", () => { + expectConversion("0.01", "year", 1, [ + row("month", false, percent(0, true), percent(10000, false, true), linear(0, false, true)), + row("quarter", false, percent(0, true), percent(10000, false, true), linear(0, false, true)), + row("year", true, percent(1), percent(9999), null), + ], cohort(0, 100, true), 100000); +}); + +test("0.01% monthly compounds a tiny rate without losing precision", () => { + expectConversion("0.01", "month", 1, [ + row("month", true, percent(1), percent(9999), null), + row("quarter", false, percent(3), percent(9997), linear(3)), + row("year", false, percent(12), percent(9988), linear(12)), + ], cohort(0, 100, true), 100000); +}); + +test("99.99% monthly distinguishes near-total churn from complete loss", () => { + expectConversion("99.99", "month", 9999, [ + row("month", true, percent(9999), percent(1), null), + row("quarter", false, percent(10000, false, true), percent(0, true), linear(29997, true)), + row("year", false, percent(10000, false, true), percent(0, true), linear(119988, true)), + ], cohort(100, 0, false, true), 10); +}); + +test("0.5% annual rounds the exact half-customer tie up", () => { + expectConversion("0.5", "year", 50, [ + row("month", false, percent(4), percent(9996), linear(4)), + row("quarter", false, percent(13), percent(9987), linear(13)), + row("year", true, percent(50), percent(9950), null), + ], cohort(1, 99), 2000); +}); + +test("0.02% annual rounds the quarterly linear half-step up", () => { + expectConversion("0.02", "year", 2, [ + row("month", false, percent(0, true), percent(10000, false, true), linear(0, false, true)), + row("quarter", false, percent(1), percent(9999), linear(1)), + row("year", true, percent(2), percent(9998), null), + ], cohort(0, 100, true), 50000); +}); + +test("0.06% annual rounds the monthly linear half-step up", () => { + expectConversion("0.06", "year", 6, [ + row("month", false, percent(1), percent(9999), linear(1)), + row("quarter", false, percent(2), percent(9998), linear(2)), + row("year", true, percent(6), percent(9994), null), + ], cohort(0, 100, true), 16667); +}); + +test("66.35% quarterly stays below the root rounding tie", () => { + expectConversion("66.35", "quarter", 6635, [ + row("month", false, percent(3044), percent(6956), linear(2212)), + row("quarter", true, percent(6635), percent(3365), null), + row("year", false, percent(9872), percent(128), linear(26540, true)), + ], cohort(99, 1), 15); +}); + +test("0.64% monthly rounds the exact lifetime tie up to 156.3 months", () => { + expectConversion("0.64", "month", 64, [ + row("month", true, percent(64), percent(9936), null), + row("quarter", false, percent(191), percent(9809), linear(192)), + row("year", false, percent(742), percent(9258), linear(768)), + ], cohort(7, 93), 1563); +}); + +test("cohort counts use exact annual churn before rounding the displayed rate", () => { + // Exact loss is below 99.5 customers, though the annual display rounds to 99.50%. + const result = values("73.4", "quarter"); + assert.equal(result.rows[2].churn.hundredths, 9950); + assert.deepEqual(result.yearOf100, cohort(99, 1)); +}); + +test("accepts trimmed percent suffixes, leading zeroes, and endpoint spellings", () => { + for (const rate of [" 5% ", "5 %", "05", "005.00", "\t5\n"]) { + assert.equal(values(rate, "month").source.hundredths, 500); + } + for (const rate of ["0", "0.00", "000.00%", "100", "100.00", "0100%"]) { + assert.equal(convertChurnRate({ rate, period: "year" }).status, "valid"); + } + assert.deepEqual(convertChurnRate({ rate: "5", period: " quarter " }).values, values("5", "quarter")); + assert.equal(convertChurnRate(Object.assign(Object.create(null), { rate: "5", period: "month" })).status, "valid"); +}); + +const messages = { + missing: "Enter a churn rate from 0 to 100%.", + format: "Use digits only, like 5 or 4.25.", + tooPrecise: "Use up to two decimals.", + belowZero: "Customer churn can't be below 0%. A negative rate is net revenue churn, which this tool doesn't convert.", + aboveHundred: "Churn can't be above 100%. You can't lose more customers than you started with.", +}; +const periodError = { code: "unknownPeriod", message: "Choose monthly, quarterly, or annual." }; + +test("rejects invalid rates with the contracted validation precedence", () => { + const inputs = { + missing: ["", " ", "%", " % ", undefined, null, 5, NaN, Infinity, {}, [], true, Symbol("rate"), 5n], + format: ["5,5", "1e2", ".5", "5.", "+5", "five", "5%%", "1 0", "1\t0", "1\n0", "١", "100.001%%", "5% %"], + tooPrecise: ["4.567", "100.001", "0.000", "4.567%", "9999999999999.000"], + belowZero: ["-1", "-0", "-0.00", "-4.567", "-101", "-1%", "-five"], + aboveHundred: ["100.01", "101", "9999999999999", "9".repeat(1000), "101%"], + }; + for (const [code, rates] of Object.entries(inputs)) { + for (const rate of rates) { + assert.deepEqual(convertChurnRate({ rate, period: "month" }), { + status: "invalid", values: null, errors: { rate: { code, message: messages[code] } }, + }, `${code} for ${String(rate)}`); + } + } +}); + +test("validates both fields and never silently defaults an invalid period", () => { + for (const period of ["Monthly", "MONTH", "12", "", " ", undefined, null, 12, {}, [], Symbol("period")]) { + assert.deepEqual(convertChurnRate({ rate: "5", period }), { + status: "invalid", values: null, errors: { period: periodError }, + }); + assert.deepEqual(convertChurnRate({ rate: "-1", period }), { + status: "invalid", values: null, errors: { rate: { code: "belowZero", message: messages.belowZero }, period: periodError }, + }); + } +}); + +test("non-plain and unreadable objects return both errors without throwing", () => { + const array = Object.assign([], { rate: "5", period: "month" }); + class Input { rate = "5"; period = "month"; } + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + const throwing = { get rate() { throw new Error("Unreadable input"); }, period: "month" }; + for (const raw of [null, undefined, [], array, new Date(), new Input(), Object.create({ rate: "5", period: "month" }), + "5", 5, true, 5n, Symbol("raw"), () => {}, throwing, revoked.proxy]) { + assert.deepEqual(convertChurnRate(raw), { + status: "invalid", values: null, errors: { rate: { code: "missing", message: messages.missing }, period: periodError }, + }); + } +}); + +test("all representable rates preserve complements, ordering, flags, endpoints, and source identity", () => { + assert.deepEqual(CHURN_PERIODS, ["month", "quarter", "year"]); + assert.deepEqual(CHURN_PERIOD_MONTHS, { month: 1, quarter: 3, year: 12 }); + for (const period of CHURN_PERIODS) { + for (let h = 0; h <= 10000; h += 1) { + const rate = `${Math.floor(h / 100)}.${String(h % 100).padStart(2, "0")}`; + const result = values(rate, period); + assert.deepEqual(result.rows.map(row => row.period), ["month", "quarter", "year"]); + assert.equal(result.rows.filter(row => row.isSource).length, 1); + assert.equal(result.rows.find(row => row.isSource).churn.hundredths, h); + assert.equal(result.rows.find(row => row.isSource).linearEstimate, null); + for (const row of result.rows) { + assert.equal(row.churn.hundredths + row.retention.hundredths, 10000); + assert.ok(Number.isInteger(row.churn.hundredths) && row.churn.hundredths >= 0 && row.churn.hundredths <= 10000); + assert.equal(row.retention.roundedToZero, row.churn.nearlyAll); + assert.equal(row.retention.nearlyAll, row.churn.roundedToZero); + if (h === 0 || h === 10000) { + assert.deepEqual(row.churn, percent(h)); + assert.deepEqual(row.retention, percent(10000 - h)); + } + } + assert.ok(result.rows[0].churn.hundredths <= result.rows[1].churn.hundredths); + assert.ok(result.rows[1].churn.hundredths <= result.rows[2].churn.hundredths); + assert.equal(result.yearOf100.lost + result.yearOf100.remaining, 100); + if (h === 0) assert.deepEqual(result.lifetime, { kind: "notEstimable", reason: "zeroChurn" }); + else assert.ok(Number.isInteger(result.lifetime.tenths) && result.lifetime.tenths >= 10 && result.lifetime.tenths <= 100000); + } + } +}); diff --git a/tests/pages/free-trial-conversion-calculator/free-trial-conversion-calculator.test.mjs b/tests/pages/free-trial-conversion-calculator/free-trial-conversion-calculator.test.mjs new file mode 100644 index 00000000..fc3747ea --- /dev/null +++ b/tests/pages/free-trial-conversion-calculator/free-trial-conversion-calculator.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { calculateFreeTrialConversion } from "../../../lib/free-trial-conversion-calculator/engine.ts"; + +const cohort = (overrides = {}) => ({ label: "", starts: "1180", paid: "156", ageDays: "45", ...overrides }); +const input = (overrides = {}) => ({ + model: "trialNoCard", windowDays: "30", cohorts: [cohort()], includeMrr: false, + currency: "USD", mrrMethod: "average", averageMonthly: "49", + monthlyCustomers: "", monthlyPrice: "", annualCustomers: "", annualPrice: "", ...overrides, +}); +function valid(overrides = {}) { + const result = calculateFreeTrialConversion(input(overrides)); + assert.equal(result.status, "valid", JSON.stringify(result.errors)); + assert.deepEqual(result.errors, {}); + return result.values; +} +function invalid(overrides, field, message) { + const result = calculateFreeTrialConversion(input(overrides)); + assert.equal(result.status, "invalid"); + assert.equal(result.values, null); + assert.equal(result.errors[field], message); +} + +test("one completed cohort returns its exact rate and optional new MRR", () => { + const values = valid(); + assert.equal(values.windowDays, 30n); + assert.deepEqual(values.cohorts[0], { + index: 0, label: "", starts: 1180n, paid: 156n, notPaid: 1024n, ageDays: 45n, + rate: { kind: "finite", hundredths: 1322n, exact: { numerator: 780n, denominator: 59n }, isRounded: true, roundedToZero: false }, + maturity: { status: "complete" }, + }); + assert.deepEqual(values.combined, { + basis: "complete", status: "complete", includedIndexes: [0], excludedMaturing: 0, + starts: 1180n, paid: 156n, notPaid: 1024n, rate: values.cohorts[0].rate, + }); + assert.equal(values.newMrr, null); + assert.deepEqual(valid({ includeMrr: true }).newMrr, { + currency: "USD", method: "average", paidCustomers: 156n, averageMonthlyCents: 4900n, + monthlyPlan: null, annualPlan: null, mrrCents: 764400n, mrrIsRounded: false, runRateArrCents: 9172800n, + }); +}); + +test("cohort age determines maturity, including the exact window boundary", () => { + const complete = valid({ cohorts: [cohort({ starts: "100", paid: "20", ageDays: "30" })] }); + assert.equal(complete.combined.rate.hundredths, 2000n); + assert.deepEqual(complete.cohorts[0].maturity, { status: "complete" }); + const maturing = valid({ windowDays: "14", cohorts: [cohort({ starts: "200", paid: "30", ageDays: "10" })] }); + assert.equal(maturing.cohorts[0].rate.hundredths, 1500n); + assert.deepEqual(maturing.cohorts[0].maturity, { status: "maturing", daysLeft: 4n }); + assert.equal(maturing.combined.basis, "all"); + assert.equal(maturing.combined.status, "provisional"); +}); + +test("combined rate divides complete-cohort totals, while MRR includes all paid customers", () => { + const values = valid({ includeMrr: true, averageMonthly: "20", cohorts: [ + cohort({ label: " Jul ", starts: "400", paid: "60", ageDays: "75" }), + cohort({ label: "Aug", starts: "500", paid: "70", ageDays: "45" }), + cohort({ label: "Sep", starts: "450", paid: "30", ageDays: "10" }), + ] }); + assert.deepEqual(values.cohorts.map(row => row.rate.hundredths), [1500n, 1400n, 667n]); + assert.equal(values.cohorts[0].label, "Jul"); + assert.deepEqual(values.cohorts[2].maturity, { status: "maturing", daysLeft: 20n }); + assert.deepEqual(values.combined, { + basis: "complete", status: "complete", includedIndexes: [0, 1], excludedMaturing: 1, + starts: 900n, paid: 130n, notPaid: 770n, + rate: { kind: "finite", hundredths: 1444n, exact: { numerator: 130n, denominator: 9n }, isRounded: true, roundedToZero: false }, + }); + assert.equal(values.newMrr.paidCustomers, 160n); + assert.equal(values.newMrr.mrrCents, 320000n); +}); + +test("all cohorts are used provisionally when complete cohorts have no starts", () => { + const maturing = valid({ cohorts: [ + cohort({ starts: "100", paid: "5", ageDays: "5" }), + cohort({ starts: "200", paid: "10", ageDays: "12" }), + ] }); + assert.deepEqual(maturing.cohorts.map(row => row.rate.hundredths), [500n, 500n]); + // The spec's 1500n disagrees with its formula: 15 / 300 * 100 = 5%, or 500n. + assert.equal(maturing.combined.rate.hundredths, 500n); + assert.equal(maturing.combined.basis, "all"); + assert.equal(maturing.combined.status, "provisional"); + assert.deepEqual(maturing.combined.includedIndexes, [0, 1]); + assert.equal(maturing.combined.excludedMaturing, 0); + const emptyComplete = valid({ cohorts: [ + cohort({ starts: "0", paid: "0", ageDays: "40" }), + cohort({ starts: "100", paid: "10", ageDays: "5" }), + ] }); + assert.deepEqual(emptyComplete.cohorts[0].rate, { kind: "notAvailable", reason: "noStarts" }); + assert.equal(emptyComplete.combined.basis, "all"); + assert.equal(emptyComplete.combined.status, "provisional"); + assert.equal(emptyComplete.combined.rate.hundredths, 1000n); +}); + +test("zero starts, exact percentages, and tiny rounded rates remain distinct", () => { + const zero = valid({ cohorts: [cohort({ starts: "0", paid: "0", ageDays: "40" })] }); + assert.deepEqual(zero.combined.rate, { kind: "notAvailable", reason: "noStarts" }); + assert.equal(zero.combined.basis, "all"); + assert.equal(zero.combined.status, "complete"); + const maturingZero = valid({ cohorts: [cohort({ starts: "0", paid: "0", ageDays: "0" })] }); + assert.equal(maturingZero.combined.status, "provisional"); + for (const [starts, paid, hundredths, isRounded, roundedToZero] of [ + ["3", "1", 3333n, true, false], ["3", "2", 6667n, true, false], + ["1", "1", 10000n, false, false], ["2000000", "1", 0n, true, true], + ["100", "0", 0n, false, false], ["20000", "1", 1n, true, false], + ]) { + const rate = valid({ cohorts: [cohort({ starts, paid })] }).combined.rate; + assert.equal(rate.hundredths, hundredths); + assert.equal(rate.isRounded, isRounded); + assert.equal(rate.roundedToZero, roundedToZero); + } +}); + +test("annual MRR rounds once and ARR uses the exact monthly amount", () => { + const mrr = valid({ includeMrr: true, mrrMethod: "split", averageMonthly: "garbage", + cohorts: [cohort({ starts: "4", paid: "4" })], + monthlyCustomers: "3", monthlyPrice: "10", annualCustomers: "1", annualPrice: "100", + }).newMrr; + assert.deepEqual(mrr, { + currency: "USD", method: "split", paidCustomers: 4n, averageMonthlyCents: null, + monthlyPlan: { customers: 3n, priceCents: 1000n, mrrCents: 3000n }, + annualPlan: { customers: 1n, annualPriceCents: 10000n, mrrCents: 833n, isRounded: true }, + mrrCents: 3833n, mrrIsRounded: true, runRateArrCents: 46000n, + }); + assert.equal(valid({ includeMrr: true, cohorts: [cohort({ paid: "0" })] }).newMrr.mrrCents, 0n); + const zeroSplit = valid({ includeMrr: true, mrrMethod: "split", cohorts: [cohort({ starts: "0", paid: "0" })], + monthlyCustomers: "0", monthlyPrice: "0", annualCustomers: "0", annualPrice: "0", + }).newMrr; + assert.equal(zeroSplit.mrrCents, 0n); + assert.equal(zeroSplit.mrrIsRounded, false); +}); + +test("all models share the math and maximum amounts use bigint without overflow", () => { + for (const model of ["trialNoCard", "trialCardRequired", "freemium"]) { + const values = valid({ model, windowDays: "180", cohorts: [cohort({ starts: "5000", paid: "150", ageDays: "200" })] }); + assert.equal(values.model, model); + assert.equal(values.combined.rate.hundredths, 300n); + assert.equal(values.combined.status, "complete"); + } + const maximum = valid({ windowDays: "730", includeMrr: true, averageMonthly: "1000000000", + cohorts: [cohort({ starts: "10000000", paid: "10000000", ageDays: "3650" })], + }); + assert.equal(maximum.combined.rate.hundredths, 10000n); + assert.equal(maximum.newMrr.mrrCents, 1000000000000000000n); + assert.equal(maximum.newMrr.runRateArrCents, 12000000000000000000n); + const twelve = valid({ cohorts: Array.from({ length: 12 }, () => cohort()) }); + assert.equal(twelve.cohorts.length, 12); + assert.equal(twelve.combined.starts, 14160n); +}); + +test("counts reject negatives, fractions, decorated numbers, blanks, and impossible paid totals", () => { + for (const [raw, message] of [ + ["-5", "Counts can't be negative."], ["", "Enter a count. Use 0 if there were none."], + ["2.5", "Use a whole number with no commas or decimals."], ["1,180", "Use a whole number with no commas or decimals."], + ["1e3", "Use a whole number with no commas or decimals."], ["+1", "Use a whole number with no commas or decimals."], + ["1 0", "Use a whole number with no commas or decimals."], ["10000001", "Enter a number up to 10,000,000."], + ]) { + for (const field of ["starts", "paid"]) invalid({ cohorts: [cohort({ [field]: raw })] }, `cohorts.0.${field}`, message); + } + for (const [starts, paid] of [["100", "101"], ["0", "1"]]) { + invalid({ cohorts: [cohort({ starts, paid })] }, "cohorts.0.paid", "Paid can't be more than starts. Count only accounts from this cohort."); + } +}); + +test("window, age, label, cohort limits, and model return their field errors", () => { + for (const raw of ["0", "731", "2.5", "-1", "1e2"]) invalid({ windowDays: raw }, "windowDays", "Enter whole days from 1 to 730."); + invalid({ windowDays: "" }, "windowDays", "Enter the follow-up window in days."); + for (const raw of ["-1", "3651", "0.5"]) invalid({ cohorts: [cohort({ ageDays: raw })] }, "cohorts.0.ageDays", "Enter whole days from 0 to 3,650."); + invalid({ cohorts: [cohort({ ageDays: "" })] }, "cohorts.0.ageDays", "Enter the days since this cohort's last start."); + for (const label of ["x".repeat(41), "name\n", "\tname", "a\u007fb", "a\u0085b"]) { + invalid({ cohorts: [cohort({ label })] }, "cohorts.0.label", "Use a name up to 40 characters on one line."); + } + assert.equal(valid({ cohorts: [cohort({ label: "x".repeat(40) })] }).cohorts[0].label.length, 40); + invalid({ cohorts: [] }, "cohorts", "Add at least one cohort."); + invalid({ cohorts: Array.from({ length: 13 }, () => cohort()) }, "cohorts", "Use up to 12 cohorts."); + invalid({ model: "optIn" }, "model", "Choose one of the listed options."); +}); + +test("MRR checks only active fields and validates the split against every parsed count", () => { + assert.equal(valid({ currency: "bad", mrrMethod: "bad", averageMonthly: "$49", monthlyCustomers: "bad", + monthlyPrice: "bad", annualCustomers: "bad", annualPrice: "bad" }).newMrr, null); + assert.equal(valid({ includeMrr: true, monthlyCustomers: "bad", monthlyPrice: "bad", annualCustomers: "bad", annualPrice: "bad" }).newMrr.mrrCents, 764400n); + invalid({ includeMrr: true, currency: "JPY" }, "currency", "Choose one of the listed options."); + invalid({ includeMrr: true, mrrMethod: "blend" }, "mrrMethod", "Choose one of the listed options."); + invalid({ includeMrr: true, averageMonthly: "" }, "averageMonthly", "Enter an amount."); + for (const raw of ["$49", "-1", "+1", "1e3", "1,000", "1 0", "1.001"]) { + invalid({ includeMrr: true, averageMonthly: raw }, "averageMonthly", "Use digits with up to two decimals. No signs, commas, or currency symbols."); + } + invalid({ includeMrr: true, averageMonthly: "1000000000.01" }, "averageMonthly", "Enter an amount up to 1,000,000,000.00."); + const split = { includeMrr: true, mrrMethod: "split", monthlyCustomers: "100", monthlyPrice: "10", annualCustomers: "50", annualPrice: "100" }; + invalid(split, "monthlyCustomers", "Monthly and annual customers must add up to 156, the paid customers above."); + invalid({ ...split, annualPrice: "-1" }, "annualPrice", "Use digits with up to two decimals. No signs, commas, or currency symbols."); + invalid({ ...split, monthlyCustomers: "-1" }, "monthlyCustomers", "Counts can't be negative."); + const badCohortCount = calculateFreeTrialConversion(input({ ...split, cohorts: [cohort({ starts: "bad" })] })); + assert.equal(badCohortCount.status, "invalid"); + assert.equal(badCohortCount.errors.monthlyCustomers, undefined); + const badSplitCount = calculateFreeTrialConversion(input({ ...split, annualCustomers: "bad" })); + assert.equal(badSplitCount.errors.monthlyCustomers, undefined); + invalid({ ...split, cohorts: [cohort({ starts: "2000", paid: "1180" })] }, "monthlyCustomers", "Monthly and annual customers must add up to 1,180, the paid customers above."); +}); + +test("malformed payloads never throw and collect independent errors in one pass", () => { + for (const raw of [null, undefined, [], 3, "form", input({ cohorts: {} }), input({ cohorts: [null] }), + input({ cohorts: [[]] }), input({ cohorts: Array(1) }), input({ includeMrr: "yes" })]) { + const result = calculateFreeTrialConversion(raw); + assert.equal(result.status, "invalid"); + assert.equal(result.values, null); + assert.equal(result.errors.form, "Something in the form is out of date. Reset and try again."); + } + const result = calculateFreeTrialConversion(input({ model: "optIn", windowDays: "", includeMrr: true, + currency: "JPY", averageMonthly: "$49", cohorts: [ + cohort({ label: "x".repeat(41), starts: "-5", paid: "1e3", ageDays: "-1" }), + cohort({ starts: "100", paid: "101", ageDays: "" }), + ], + })); + assert.equal(result.status, "invalid"); + assert.deepEqual(Object.keys(result.errors).sort(), ["model", "windowDays", "currency", "averageMonthly", + "cohorts.0.label", "cohorts.0.starts", "cohorts.0.paid", "cohorts.0.ageDays", "cohorts.1.paid", "cohorts.1.ageDays"].sort()); +}); diff --git a/tests/pages/mrr-goal-calculator/mrr-goal-calculator.test.mjs b/tests/pages/mrr-goal-calculator/mrr-goal-calculator.test.mjs new file mode 100644 index 00000000..6621f848 --- /dev/null +++ b/tests/pages/mrr-goal-calculator/mrr-goal-calculator.test.mjs @@ -0,0 +1,415 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { calculateMrrGoal, MRR_GOAL_MAX_MONTHS, MRR_GOAL_MAX_COUNT } from "../../../lib/mrr-goal-calculator/engine.ts"; + +const input = (overrides = {}) => ({ + currency: "USD", goalAmount: "10000", goalPeriod: "month", monthlyArpa: "29", + method: "customers", solveFor: "date", currentCustomers: "", newCustomersPerMonth: "", + churnPercent: "", currentMrr: "", growthPercent: "", deadlineMonths: "", + visitorToSignupPercent: "", signupToPaidPercent: "", ...overrides, +}); +const valid = (overrides = {}) => { + const result = calculateMrrGoal(input(overrides)); + assert.equal(result.status, "valid", JSON.stringify(result.errors)); + assert.deepEqual(result.errors, {}); + return result.values; +}; +const customers = (overrides = {}) => valid({ currentCustomers: "0", newCustomersPerMonth: "15", churnPercent: "0", ...overrides }); +const growth = (overrides = {}) => valid({ method: "growth", currentMrr: "1000", growthPercent: "10", ...overrides }); +const deadline = (overrides = {}) => customers({ solveFor: "pace", deadlineMonths: "12", ...overrides }); +const trafficRates = { visitorToSignupPercent: "3", signupToPaidPercent: "15" }; + +test("example 1: the customer goal rounds up and the blank timeline is skipped", () => { + const v = valid(); + assert.equal(v.goal.customersNeeded, 345n); + assert.equal(v.goal.mrrAtCustomersNeededCents, 1000500n); + assert.equal(v.goal.monthlyGoalCents, 1000000n); + assert.equal(v.goal.annualGoalCents, 12000000n); + assert.equal(v.timeline, null); + assert.equal(v.traffic, null); +}); + +test("example 2: a $12 ARPA needs 834 customers", () => { + assert.equal(valid({ monthlyArpa: "12" }).goal.customersNeeded, 834n); +}); + +test("example 3: ARR keeps the exact yearly goal while rounding the monthly display", () => { + assert.deepEqual(valid({ goalPeriod: "year" }).goal, { + period: "year", enteredCents: 1000000n, monthlyGoalCents: 83333n, + monthlyGoalIsRounded: true, annualGoalCents: 1000000n, + customersNeeded: 29n, mrrAtCustomersNeededCents: 84100n, + }); +}); + +test("example 4: 15 additions without churn reach the goal in month 23", () => { + const t = customers().timeline; + assert.deepEqual(t.outcome, { kind: "reached", month: 23 }); + assert.equal(t.ceiling, null); + assert.equal(t.lostPerMonthAtGoal, 0n); + assert.equal(t.remainingCustomers, 345n); + assert.equal(t.pace, 15n); + assert.equal(t.path.length, 24); + assert.deepEqual(t.path[23], { month: 23, customers: 345n, mrrCents: 1000500n }); +}); + +test("example 5: 3% churn delays the goal to month 39 without rounding intermediate counts", () => { + const t = customers({ churnPercent: "3" }).timeline; + assert.deepEqual(t.outcome, { kind: "reached", month: 39 }); + assert.deepEqual(t.ceiling, { floor: 500n, isWhole: true }); + assert.equal(t.lostPerMonthAtGoal, 11n); + assert.equal(t.path[38].customers, 342n); + for (const [month, expected] of [[1, 15n], [2, 29n], [3, 43n], [12, 153n], [24, 259n], [39, 347n]]) { + assert.equal(t.path[month].customers, expected, `month ${month}`); + assert.equal(t.path[month].mrrCents, expected * 2900n); + } +}); + +test("example 6: an account goal above the ceiling returns the smallest reachable pace", () => { + const v = customers({ goalAmount: "15000", churnPercent: "3" }); + assert.equal(v.goal.customersNeeded, 518n); + assert.deepEqual(v.timeline.outcome, { kind: "unreachable", reason: "ceiling", paceToReach: 16n }); + assert.deepEqual(v.timeline.ceiling, { floor: 500n, isWhole: true }); + assert.equal(v.timeline.path.at(-1).month, 120); +}); + +test("example 7: a current base of 120 reaches the goal in month 16", () => { + const t = customers({ currentCustomers: "120", newCustomersPerMonth: "20", churnPercent: "2.5" }).timeline; + assert.equal(t.remainingCustomers, 225n); + assert.deepEqual(t.ceiling, { floor: 800n, isWhole: true }); + assert.deepEqual(t.outcome, { kind: "reached", month: 16 }); + assert.equal(t.path[15].customers, 334n); + assert.equal(t.path[16].customers, 346n); +}); + +test("example 8: an already-met goal can fall below the target in month 9", () => { + const t = customers({ currentCustomers: "400", newCustomersPerMonth: "5", churnPercent: "3" }).timeline; + assert.deepEqual(t.outcome, { kind: "alreadyThere", fallsBelowInMonth: 9 }); + assert.deepEqual(t.ceiling, { floor: 166n, isWhole: false }); + assert.equal(t.remainingCustomers, 0n); + assert.ok(t.path[8].customers >= 345n); + assert.ok(t.path[9].customers < 345n); + assert.equal(t.path.at(-1).month, 12); +}); + +test("example 9: no additions and no churn cannot reach a higher goal", () => { + const t = customers({ currentCustomers: "10", newCustomersPerMonth: "0" }).timeline; + assert.deepEqual(t.outcome, { kind: "unreachable", reason: "noNewCustomers" }); + assert.equal(t.path.at(-1).month, 120); + assert.ok(t.path.every(point => point.customers === 10n)); +}); + +test("example 10: a goal reached after 1000 months stops at the 600-month cap", () => { + const v = customers({ goalAmount: "29000", newCustomersPerMonth: "1" }); + assert.equal(v.goal.customersNeeded, 1000n); + assert.deepEqual(v.timeline.outcome, { kind: "beyondHorizon" }); + assert.equal(v.timeline.path.length, 601); + assert.equal(v.timeline.path.at(-1).customers, 600n); +}); + +test("example 11: reaching 200 customers in 12 months needs 17 additions each month", () => { + const v = deadline({ monthlyArpa: "50" }); + assert.equal(v.goal.customersNeeded, 200n); + assert.equal(v.timeline.pace, 17n); + assert.deepEqual(v.timeline.outcome, { kind: "paceForDeadline", deadlineMonths: 12, alreadyThere: false }); + assert.equal(v.timeline.path[12].customers, 204n); +}); + +test("example 12: the deadline pace is whole and traffic uses that pace", () => { + const v = deadline({ churnPercent: "4", ...trafficRates }); + assert.equal(v.timeline.pace, 36n); + assert.equal(v.timeline.lostPerMonthAtGoal, 14n); + assert.deepEqual(v.timeline.outcome, { kind: "paceForDeadline", deadlineMonths: 12, alreadyThere: false }); + assert.deepEqual(v.traffic, { + kind: "finite", visitorToSignupHundredths: 300n, signupToPaidHundredths: 1500n, + pace: 36n, signupsPerMonth: 240n, visitorsPerMonth: 8000n, visitorsToGoal: 96000n, months: 12, + }); + assert.equal(customers({ newCustomersPerMonth: "35", churnPercent: "4" }).timeline.outcome.month, 13); + assert.equal(customers({ newCustomersPerMonth: "36", churnPercent: "4" }).timeline.outcome.month, 12); +}); + +test("example 13: zero signup-to-paid conversion has no finite traffic", () => { + assert.deepEqual(deadline({ churnPercent: "4", ...trafficRates, signupToPaidPercent: "0" }).traffic, + { kind: "noFiniteTraffic", stage: "signupToPaid" }); +}); + +test("example 14: 10% monthly MRR growth first reaches the goal in month 25", () => { + const t = growth().timeline; + assert.deepEqual(t.outcome, { kind: "reached", month: 25 }); + assert.equal(t.remainingMrrCents, 900000n); + assert.equal(t.path[1].mrrCents, 110000n); + assert.equal(t.path[3].mrrCents, 133100n); + assert.equal(t.path[24].mrrCents, 984973n); +}); + +test("example 15: binary search returns the smallest hundredth-percent growth rate", () => { + const t = growth({ solveFor: "pace", deadlineMonths: "24" }).timeline; + assert.equal(t.growthHundredths, 1007n); + assert.deepEqual(t.outcome, { kind: "rateForDeadline", deadlineMonths: 24, alreadyThere: false }); + assert.equal(t.path[24].mrrCents, 1000127n); + assert.equal(growth({ growthPercent: "10.06" }).timeline.path[24].mrrCents, 997949n); +}); + +test("example 16: a one-month tenfold increase needs exactly 900% growth", () => { + const t = growth({ solveFor: "pace", deadlineMonths: "1" }).timeline; + assert.equal(t.growthHundredths, 90000n); + assert.deepEqual(t.outcome, { kind: "rateForDeadline", deadlineMonths: 1, alreadyThere: false }); + assert.equal(t.path[1].mrrCents, 1000000n); + assert.equal(t.path.at(-1).month, 12); +}); + +test("example 17: a deadline above the supported rate returns no chart path", () => { + const t = growth({ currentMrr: "0.01", goalAmount: "1000000000", solveFor: "pace", deadlineMonths: "1" }).timeline; + assert.deepEqual(t.outcome, { kind: "aboveMaxRate", deadlineMonths: 1 }); + assert.equal(t.growthHundredths, 100000n); + assert.deepEqual(t.path, []); +}); + +test("example 18: 0.3% growth cannot reach a tenfold goal within 600 months", () => { + const t = growth({ growthPercent: "0.3" }).timeline; + assert.deepEqual(t.outcome, { kind: "beyondHorizon" }); + assert.equal(t.path.length, 601); + assert.ok(100000n * 10030n ** 768n < 1000000n * 10000n ** 768n); + assert.ok(100000n * 10030n ** 769n >= 1000000n * 10000n ** 769n); +}); + +test("example 19: zero and negative growth explain why the goal is unreachable", () => { + for (const [growthPercent, reason] of [["0", "noGrowth"], ["-2", "shrinking"]]) { + const t = growth({ growthPercent }).timeline; + assert.deepEqual(t.outcome, { kind: "unreachable", reason }); + assert.equal(t.path.at(-1).month, 120); + } +}); + +test("example 20: current MRR above the goal is already there with no remaining MRR", () => { + const t = growth({ currentMrr: "12000", growthPercent: "5" }).timeline; + assert.deepEqual(t.outcome, { kind: "alreadyThere" }); + assert.equal(t.remainingMrrCents, 0n); + assert.equal(t.path[0].mrrCents, 1200000n); + assert.equal(t.path.at(-1).month, 12); +}); + +test("example 21: invalid inputs return the specified field codes and messages", () => { + const cases = [ + [{ goalAmount: "" }, "goalAmount", "missing", "Enter your revenue goal."], + [{ goalAmount: "0" }, "goalAmount", "zero", "Enter a goal above 0."], + [{ monthlyArpa: "0" }, "monthlyArpa", "zero", "Enter an ARPA above 0."], + ...["$10,000", "1e4", "10000.001"].map(goalAmount => [{ goalAmount }, "goalAmount", "format", "Use digits with up to two decimals. No signs, commas, or currency symbols."]), + ...[["101", "range"], ["-1", "format"]].map(([churnPercent, code]) => [{ currentCustomers: "0", newCustomersPerMonth: "15", churnPercent }, "churnPercent", code, "Enter a percent from 0 to 100, with up to two decimals."]), + ...["12.5", "1,200"].map(currentCustomers => [{ currentCustomers, newCustomersPerMonth: "15", churnPercent: "0" }, "currentCustomers", "format", "Use digits only, without commas, signs, or decimals."]), + [{ method: "growth", currentMrr: "0", growthPercent: "10" }, "currentMrr", "zeroBase", "A growth rate can't start from 0. Use new customers and churn instead."], + [{ method: "growth", currentMrr: "1000", growthPercent: "1000.01" }, "growthPercent", "range", "Enter a percent from -100 to 1000, with up to two decimals."], + ...["0", "601"].map(deadlineMonths => [{ solveFor: "pace", currentCustomers: "0", churnPercent: "0", deadlineMonths }, "deadlineMonths", "range", "Enter whole months from 1 to 600."]), + [{ currentCustomers: "0", newCustomersPerMonth: "15", churnPercent: "0", visitorToSignupPercent: "3" }, "signupToPaidPercent", "missing", "Enter this rate, or clear both rates."], + [{ ...trafficRates }, "traffic", "trafficNeedsTimeline", "Fill in the customer timeline first. Traffic is worked out from its monthly pace."], + [{ method: "weekly" }, "method", "option", "Choose one of the listed options."], + ]; + for (const [overrides, field, code, message] of cases) { + const result = calculateMrrGoal(input(overrides)); + assert.equal(result.status, "invalid", `${field}: ${JSON.stringify(overrides)}`); + assert.equal(result.values, null); + assert.deepEqual(result.errors, { [field]: { code, message } }); + } + const partial = calculateMrrGoal(input({ newCustomersPerMonth: "15" })); + assert.equal(partial.status, "invalid"); + assert.deepEqual(partial.errors, { + currentCustomers: { code: "missing", message: "Enter a whole number. Use 0 if none." }, + churnPercent: { code: "missing", message: "Enter monthly churn. Use 0 if no one cancels." }, + }); + assert.equal(valid().timeline, null); +}); + +test("inactive drafts are ignored for every method and solve mode", () => { + assert.equal(customers({ currentMrr: "bad", growthPercent: "bad", deadlineMonths: "bad" }).timeline.outcome.kind, "reached"); + assert.equal(deadline({ newCustomersPerMonth: "bad", currentMrr: "bad", growthPercent: "bad" }).timeline.pace, 29n); + assert.equal(growth({ currentCustomers: "bad", newCustomersPerMonth: "bad", churnPercent: "bad", deadlineMonths: "bad", visitorToSignupPercent: "bad", signupToPaidPercent: "bad" }).traffic, null); + assert.equal(growth({ solveFor: "pace", deadlineMonths: "24", growthPercent: "bad", currentCustomers: "bad", churnPercent: "bad" }).timeline.growthHundredths, 1007n); + for (const method of ["customers", "growth"]) { + for (const solveFor of ["date", "pace"]) { + const v = valid({ method, solveFor }); + assert.equal(v.timeline, null); + assert.equal(v.traffic, null); + } + } + assert.equal(valid({ newCustomersPerMonth: "bad", solveFor: "pace" }).timeline, null); + assert.equal(valid({ method: "growth", solveFor: "pace", growthPercent: "bad" }).timeline, null); +}); + +test("non-objects, missing properties, and every active error are handled without throwing", () => { + for (const raw of [null, undefined, [], 5, "bad", true, 1n]) { + assert.deepEqual(calculateMrrGoal(raw), { status: "invalid", values: null, errors: { + form: { code: "form", message: "Something in the form is out of date. Reset and try again." }, + } }); + } + const result = calculateMrrGoal(input({ currency: "JPY", goalAmount: "", goalPeriod: "week", monthlyArpa: "0", currentCustomers: "12.5", newCustomersPerMonth: "1000000001", churnPercent: "101", visitorToSignupPercent: "", signupToPaidPercent: "101" })); + assert.equal(result.status, "invalid"); + assert.deepEqual(Object.keys(result.errors).sort(), ["currency", "goalAmount", "goalPeriod", "monthlyArpa", "currentCustomers", "newCustomersPerMonth", "churnPercent", "visitorToSignupPercent", "signupToPaidPercent"].sort()); + const missing = input(); + delete missing.goalAmount; + missing.monthlyArpa = 29; + assert.equal(calculateMrrGoal(missing).errors.goalAmount.code, "missing"); + assert.equal(calculateMrrGoal(missing).errors.monthlyArpa.code, "missing"); +}); + +test("parsing accepts outer whitespace and rejects malformed pastes and bounds", () => { + assert.equal(valid({ currency: " USD ", goalPeriod: " month ", method: " customers ", solveFor: " date ", goalAmount: " 00010000.00 ", monthlyArpa: " 29 " }).goal.customersNeeded, 345n); + for (const goalAmount of ["+1", "-1", "1 0", ".5", "5.", "NaN", "Infinity", "1%", "1\n0"]) { + assert.equal(calculateMrrGoal(input({ goalAmount })).errors.goalAmount.code, "format", goalAmount); + } + assert.equal(calculateMrrGoal(input({ goalAmount: "1000000000.01" })).errors.goalAmount.code, "range"); + assert.equal(valid({ goalAmount: "1000000000.00", monthlyArpa: "0.01" }).goal.customersNeeded, 100000000000n); + assert.equal(customers({ currentCustomers: String(MRR_GOAL_MAX_COUNT), newCustomersPerMonth: String(MRR_GOAL_MAX_COUNT), churnPercent: "100" }).timeline.pace, MRR_GOAL_MAX_COUNT); + for (const deadlineMonths of ["1.5", "+1", "1,000"]) { + assert.equal(calculateMrrGoal(input({ solveFor: "pace", currentCustomers: "0", churnPercent: "0", deadlineMonths })).errors.deadlineMonths.code, "format"); + } + for (const growthPercent of ["-100.01", "1000.01"]) { + assert.equal(calculateMrrGoal(input({ method: "growth", currentMrr: "1000", growthPercent })).errors.growthPercent.code, "range"); + } + for (const growthPercent of ["+1", "1%", "1e2", "1.001"]) { + assert.equal(calculateMrrGoal(input({ method: "growth", currentMrr: "1000", growthPercent })).errors.growthPercent.code, "format"); + } +}); + +test("fractional-cent yearly goals use exact comparisons rather than rounded displays", () => { + const tiny = valid({ goalAmount: "0.01", goalPeriod: "year", monthlyArpa: "0.01" }); + assert.equal(tiny.goal.monthlyGoalCents, 0n); + assert.equal(tiny.goal.customersNeeded, 1n); + assert.equal(valid({ goalAmount: "0.06", goalPeriod: "year" }).goal.monthlyGoalCents, 1n); + const below = growth({ goalAmount: "0.13", goalPeriod: "year", currentMrr: "0.01", growthPercent: "0" }); + assert.equal(below.goal.monthlyGoalCents, 1n); + assert.equal(below.timeline.remainingMrrCents, 0n); + assert.deepEqual(below.timeline.outcome, { kind: "unreachable", reason: "noGrowth" }); + assert.equal(growth({ goalAmount: "0.13", goalPeriod: "year", currentMrr: "0.01", growthPercent: "10" }).timeline.outcome.month, 1); + const rate = growth({ goalAmount: "0.13", goalPeriod: "year", currentMrr: "0.01", solveFor: "pace", deadlineMonths: "1" }).timeline; + assert.equal(rate.growthHundredths, 834n); +}); + +test("customer dates include month 600 and classify a goal exactly at the ceiling", () => { + assert.equal(MRR_GOAL_MAX_MONTHS, 600); + const atCap = customers({ goalAmount: "600", monthlyArpa: "1", newCustomersPerMonth: "1" }).timeline; + assert.deepEqual(atCap.outcome, { kind: "reached", month: 600 }); + assert.equal(atCap.path.length, 601); + assert.equal(customers({ goalAmount: "601", monthlyArpa: "1", newCustomersPerMonth: "1" }).timeline.outcome.kind, "beyondHorizon"); + const ceiling = customers({ goalAmount: "14500", churnPercent: "3" }).timeline; + assert.deepEqual(ceiling.outcome, { kind: "unreachable", reason: "ceiling", paceToReach: 16n }); + assert.ok(ceiling.path.every(point => point.customers < 500n)); + assert.deepEqual(customers({ churnPercent: "100", newCustomersPerMonth: "346" }).timeline.outcome, { kind: "reached", month: 1 }); + assert.equal(deadline({ churnPercent: "100", deadlineMonths: "600" }).timeline.pace, 345n); + const large = deadline({ goalAmount: "1000000000", monthlyArpa: "0.01", churnPercent: "100", deadlineMonths: "600" }).timeline; + assert.equal(large.pace, 100000000000n); + assert.equal(large.path[600].customers, 100000000000n); +}); + +test("100% churn reaches its ceiling in month 1 and cannot reach a higher target", () => { + const overrides = { currentCustomers: "0", newCustomersPerMonth: "15", churnPercent: "100", monthlyArpa: "1" }; + const reached = customers({ ...overrides, goalAmount: "15" }).timeline; + assert.deepEqual(reached.outcome, { kind: "reached", month: 1 }); + assert.deepEqual(reached.ceiling, { floor: 15n, isWhole: true }); + assert.ok(reached.path.slice(1).every(point => point.customers === 15n)); + const unreachable = customers({ ...overrides, goalAmount: "16" }).timeline; + assert.deepEqual(unreachable.outcome, { kind: "unreachable", reason: "ceiling", paceToReach: 16n }); + assert.deepEqual(unreachable.ceiling, { floor: 15n, isWhole: true }); + assert.equal(unreachable.path.at(-1).month, 120); + assert.ok(unreachable.path.slice(1).every(point => point.customers === 15n)); + assert.deepEqual(customers({ ...overrides, goalAmount: "16", newCustomersPerMonth: "16" }).timeline.outcome, + { kind: "reached", month: 1 }); +}); + +test("already-met customer goals can hold, grow, or shrink without crossing within the cap", () => { + for (const overrides of [{ currentCustomers: "345", newCustomersPerMonth: "0" }, { currentCustomers: "400", newCustomersPerMonth: "12", churnPercent: "3" }, { currentCustomers: "400", newCustomersPerMonth: "13", churnPercent: "3" }, { currentCustomers: "1000", newCustomersPerMonth: "11", churnPercent: "3" }]) { + const t = customers(overrides).timeline; + assert.deepEqual(t.outcome, { kind: "alreadyThere", fallsBelowInMonth: null }); + assert.equal(t.path.at(-1).month, 12); + } + const eventual = customers({ currentCustomers: "1000000000", goalAmount: "1", monthlyArpa: "1", newCustomersPerMonth: "0", churnPercent: "0.01" }).timeline; + assert.deepEqual(eventual.outcome, { kind: "alreadyThere", fallsBelowInMonth: null }); + const positive = deadline({ currentCustomers: "400", churnPercent: "3" }).timeline; + assert.equal(positive.pace, 7n); + assert.equal(positive.outcome.alreadyThere, true); + assert.ok(positive.path[12].customers >= 345n); + assert.equal(deadline({ currentCustomers: "400", churnPercent: "3", deadlineMonths: "1" }).timeline.pace, 0n); +}); + +test("traffic handles zero pace, both zero stages, and independent rounding", () => { + assert.deepEqual(customers({ ...trafficRates, visitorToSignupPercent: "0" }).traffic, { kind: "noFiniteTraffic", stage: "visitorToSignup" }); + assert.deepEqual(customers({ visitorToSignupPercent: "0", signupToPaidPercent: "0" }).traffic, { kind: "noFiniteTraffic", stage: "signupToPaid" }); + const zero = customers({ newCustomersPerMonth: "0", visitorToSignupPercent: "0", signupToPaidPercent: "0" }).traffic; + assert.equal(zero.kind, "finite"); + assert.equal(zero.signupsPerMonth, 0n); + assert.equal(zero.visitorsPerMonth, 0n); + assert.equal(zero.visitorsToGoal, null); + const zeroDeadline = deadline({ currentCustomers: "400", visitorToSignupPercent: "0", signupToPaidPercent: "0" }).traffic; + assert.equal(zeroDeadline.visitorsToGoal, 0n); + assert.equal(zeroDeadline.months, 12); + const rounded = customers({ goalAmount: "1", monthlyArpa: "1", newCustomersPerMonth: "1", visitorToSignupPercent: "60", signupToPaidPercent: "60" }).traffic; + assert.equal(rounded.signupsPerMonth, 2n); + assert.equal(rounded.visitorsPerMonth, 3n); + assert.equal(rounded.visitorsToGoal, 3n); + for (const overrides of [{ currentCustomers: "400" }, { goalAmount: "15000", churnPercent: "3" }, { goalAmount: "29000", newCustomersPerMonth: "1" }]) { + assert.equal(customers({ ...overrides, ...trafficRates }).traffic.visitorsToGoal, null); + } +}); + +test("growth handles a complete loss and the maximum supported deadline rate", () => { + const loss = growth({ growthPercent: "-100" }).timeline; + assert.deepEqual(loss.outcome, { kind: "unreachable", reason: "shrinking" }); + assert.equal(loss.path[0].mrrCents, 100000n); + assert.ok(loss.path.slice(1).every(point => point.mrrCents === 0n)); + const maximum = growth({ currentMrr: "1", goalAmount: "11", solveFor: "pace", deadlineMonths: "1" }).timeline; + assert.equal(maximum.growthHundredths, 100000n); + assert.equal(maximum.outcome.kind, "rateForDeadline"); + assert.equal(growth({ currentMrr: "1", goalAmount: "11.01", solveFor: "pace", deadlineMonths: "1" }).timeline.outcome.kind, "aboveMaxRate"); + const already = growth({ currentMrr: "12000", solveFor: "pace", deadlineMonths: "600" }).timeline; + assert.equal(already.growthHundredths, 0n); + assert.deepEqual(already.outcome, { kind: "rateForDeadline", deadlineMonths: 600, alreadyThere: true }); + assert.equal(already.path.length, 601); + const atCap = growth({ currentMrr: "0.01", goalAmount: "3.91", growthPercent: "1" }).timeline; + assert.deepEqual(atCap.outcome, { kind: "reached", month: 600 }); + assert.equal(atCap.path.length, 601); + const displayedEarly = growth({ currentMrr: "1", goalAmount: "1.01", growthPercent: "0.50" }).timeline; + assert.equal(displayedEarly.path[1].mrrCents, 101n); + assert.deepEqual(displayedEarly.outcome, { kind: "reached", month: 2 }); +}); + +// This oracle follows the recurrence directly instead of the engine's closed form. +function customerFraction(current, pace, churn, months) { + let numerator = current; + let denominator = 1n; + for (let month = 0; month < months; month += 1) { + numerator = numerator * (10000n - churn) + pace * 10000n * denominator; + denominator *= 10000n; + } + return { numerator, denominator }; +} + +test("deadline customer paces meet the exact recurrence and one fewer does not", () => { + for (const churnPercent of ["0", "0.01", "3", "99.99", "100"]) { + const [whole, fraction = ""] = churnPercent.split("."); + const churn = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0")); + for (const deadlineMonths of ["1", "12", "600"]) { + for (const currentCustomers of ["0", "400"]) { + const v = deadline({ churnPercent, deadlineMonths, currentCustomers }); + const pace = v.timeline.pace; + const state = customerFraction(BigInt(currentCustomers), pace, churn, Number(deadlineMonths)); + assert.ok(state.numerator >= v.goal.customersNeeded * state.denominator); + assert.equal(v.timeline.path[Number(deadlineMonths)].customers, state.numerator / state.denominator); + if (pace > 0n) { + const below = customerFraction(BigInt(currentCustomers), pace - 1n, churn, Number(deadlineMonths)); + assert.ok(below.numerator < v.goal.customersNeeded * below.denominator); + } + } + } + } +}); + +test("600-month growth solves stay exact at the goal and at the preceding rate", () => { + for (const currentMrr of ["0.01", "1000", "9999.99"]) { + const v = growth({ currentMrr, solveFor: "pace", deadlineMonths: "600" }); + const t = v.timeline; + const target = v.goal.annualGoalCents * 10000n ** 600n; + assert.equal(t.outcome.kind, "rateForDeadline"); + assert.equal(t.path.length, 601); + assert.ok(12n * t.currentMrrCents * (10000n + t.growthHundredths) ** 600n >= target); + assert.ok(12n * t.currentMrrCents * (9999n + t.growthHundredths) ** 600n < target); + assert.ok(t.path.every(point => point.month <= 600 && point.mrrCents >= 0n)); + } +}); diff --git a/tests/pages/startup-runway-calculator/startup-runway-calculator.test.mjs b/tests/pages/startup-runway-calculator/startup-runway-calculator.test.mjs new file mode 100644 index 00000000..742804c3 --- /dev/null +++ b/tests/pages/startup-runway-calculator/startup-runway-calculator.test.mjs @@ -0,0 +1,407 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { calculateStartupRunway } from "../../../lib/startup-runway-calculator/engine.ts"; + +const input = (overrides = {}) => ({ + currency: "USD", cashNow: "500000", collectedPerMonth: "10000", paidOutPerMonth: "50000", + revenueGrowthPercent: "", expenseGrowthPercent: "", plannedChanges: [], ...overrides, +}); +const change = (overrides = {}) => ({ + id: "change1", name: "", direction: "out", amount: "500", startMonth: "4", frequency: "once", ...overrides, +}); +const valid = (overrides) => { + const result = calculateStartupRunway(input(overrides)); + assert.equal(result.status, "valid", JSON.stringify(result.errors)); + assert.deepEqual(result.errors, {}); + return result.values; +}; +const invalid = (overrides) => { + const result = calculateStartupRunway(input(overrides)); + assert.equal(result.status, "invalid"); + assert.equal(result.values, null); + return result.errors; +}; + +test("example 1: today's runway is exact and the forecast is off", () => { + const v = valid(); + assert.equal(v.cashNowCents, 50000000n); + assert.equal(v.collectedPerMonthCents, 1000000n); + assert.equal(v.grossBurnCents, 5000000n); + assert.equal(v.netBurnCents, 4000000n); + assert.deepEqual(v.today, { + kind: "burning", netBurnCents: 4000000n, runwayTenths: 125n, runwayIsExact: true, cashOutMonth: 13n, + }); + assert.equal(v.projection, null); +}); + +test("example 2: 10% growth runs out before lasting break-even, with the complete month 0 to 20 path", () => { + const v = valid({ paidOutPerMonth: "60000", revenueGrowthPercent: "10", expenseGrowthPercent: "0" }); + assert.deepEqual(v.today, { + kind: "burning", netBurnCents: 5000000n, runwayTenths: 100n, runwayIsExact: true, cashOutMonth: 10n, + }); + const p = v.projection; + assert.deepEqual(p.outcome, { kind: "runsOut" }); + assert.deepEqual(p.cashOut, { month: 12, runwayTenths: 118n, runwayIsExact: false }); + assert.equal(p.firstCoversCostsMonth, 20); + assert.equal(p.coversCostsFromMonth, 20); + assert.deepEqual(p.lowestBalance, { month: 19, cents: -12840911n }); + assert.equal(p.cashShortfallCents, 12840911n); + assert.equal(p.matchesToday, false); + assert.equal(p.revenueGrowthHundredths, 1000n); + assert.equal(p.revenueGrowthWasBlank, false); + assert.equal(p.expenseGrowthHundredths, 0n); + assert.equal(p.expenseGrowthWasBlank, false); + assert.deepEqual(p.plannedChanges, []); + // Independently calculated with Python Fraction, rounding each exact month to cents. + const expected = [ + [0n, 50000000n], [1000000n, 45000000n], [1100000n, 40100000n], + [1210000n, 35310000n], [1331000n, 30641000n], [1464100n, 26105100n], + [1610510n, 21715610n], [1771561n, 17487171n], [1948717n, 13435888n], + [2143589n, 9579477n], [2357948n, 5937425n], [2593742n, 2531167n], + [2853117n, -615716n], [3138428n, -3477288n], [3452271n, -6025017n], + [3797498n, -8227519n], [4177248n, -10050271n], [4594973n, -11455298n], + [5054470n, -12400828n], [5559917n, -12840911n], [6115909n, -12725002n], + ]; + assert.deepEqual(p.path.slice(0, 21), expected.map(([collectedCents, balanceCents], month) => ({ + month, collectedCents, balanceCents, paidOutCents: month === 0 ? 0n : 6000000n, + plannedCents: 0n, changeCents: month === 0 ? 0n : collectedCents - 6000000n, + atTodaysRateCents: 50000000n - BigInt(month) * 5000000n, + coversCosts: month === 0 ? null : month >= 20, + }))); + assert.equal(p.path.length, 61); + assert.equal(p.path[60].month, 60); +}); + +test("example 3: 5% growth floors the partial month and retains the cash gap", () => { + const p = valid({ paidOutPerMonth: "60000", revenueGrowthPercent: "5", expenseGrowthPercent: "0" }).projection; + assert.deepEqual(p.outcome, { kind: "runsOut" }); + assert.deepEqual(p.cashOut, { month: 11, runwayTenths: 105n, runwayIsExact: false }); + assert.equal(p.firstCoversCostsMonth, 38); + assert.equal(p.coversCostsFromMonth, 38); + assert.deepEqual(p.lowestBalance, { month: 37, cents: -70371862n }); + assert.equal(p.cashShortfallCents, 70371862n); +}); + +test("example 4: positive cash flow keeps its own outcome", () => { + const v = valid({ cashNow: "1000", collectedPerMonth: "60000", paidOutPerMonth: "50000" }); + assert.deepEqual(v.today, { kind: "cashFlowPositive", surplusCents: 1000000n }); + assert.equal(v.netBurnCents, -1000000n); + assert.equal(v.projection, null); +}); + +test("example 5: equal flows are break-even", () => { + const v = valid({ cashNow: "1000", collectedPerMonth: "30000", paidOutPerMonth: "30000" }); + assert.deepEqual(v.today, { kind: "breakEven" }); + assert.equal(v.netBurnCents, 0n); + assert.equal(v.projection, null); +}); + +test("example 6: a hire interrupts coverage and a yearly renewal affects cash", () => { + const v = valid({ + cashNow: "20000", collectedPerMonth: "3000", paidOutPerMonth: "2500", revenueGrowthPercent: "5", + plannedChanges: [ + change({ id: "hire", name: "Hire", amount: "2000", frequency: "monthly" }), + change({ id: "renewal", name: "Annual renewal", amount: "1200", startMonth: "3", frequency: "yearly" }), + ], + }); + assert.deepEqual(v.today, { kind: "cashFlowPositive", surplusCents: 50000n }); + const p = v.projection; + assert.equal(p.expenseGrowthWasBlank, true); + assert.equal(p.expenseGrowthHundredths, 0n); + assert.equal(p.cashOut, null); + assert.equal(p.firstCoversCostsMonth, 1); + assert.equal(p.coversCostsFromMonth, 10); + assert.deepEqual(p.outcome, { kind: "coversCostsBeforeCashOut", month: 10 }); + assert.deepEqual(p.lowestBalance, { month: 9, cents: 1737970n }); + assert.equal(p.cashShortfallCents, null); + assert.equal(p.path[3].plannedCents, -120000n); + assert.equal(p.path[3].balanceCents, 2075750n); + assert.equal(p.path[9].collectedCents, 443237n); + assert.equal(p.path[9].coversCosts, false); + assert.equal(p.path[10].collectedCents, 465398n); + assert.equal(p.path[10].coversCosts, true); + assert.equal(p.path[15].plannedCents, -320000n); + assert.equal(p.path[15].balanceCents, 2083569n); +}); + +test("example 7: growth covers costs before cash runs out", () => { + const v = valid({ cashNow: "30000", collectedPerMonth: "5000", paidOutPerMonth: "10000", revenueGrowthPercent: "10", expenseGrowthPercent: "0" }); + assert.deepEqual(v.today, { kind: "burning", netBurnCents: 500000n, runwayTenths: 60n, runwayIsExact: true, cashOutMonth: 6n }); + const p = v.projection; + assert.equal(p.cashOut, null); + assert.equal(p.firstCoversCostsMonth, 9); + assert.equal(p.coversCostsFromMonth, 9); + assert.deepEqual(p.outcome, { kind: "coversCostsBeforeCashOut", month: 9 }); + assert.deepEqual(p.lowestBalance, { month: 8, cents: 717945n }); +}); + +test("example 8: faster-growing expenses never reach lasting break-even", () => { + const v = valid({ cashNow: "100000", collectedPerMonth: "6000", paidOutPerMonth: "8000", revenueGrowthPercent: "3", expenseGrowthPercent: "5" }); + assert.equal(v.today.runwayTenths, 500n); + assert.equal(v.today.cashOutMonth, 50n); + const p = v.projection; + assert.deepEqual(p.cashOut, { month: 20, runwayTenths: 196n, runwayIsExact: false }); + assert.deepEqual(p.outcome, { kind: "runsOut" }); + assert.equal(p.firstCoversCostsMonth, null); + assert.equal(p.coversCostsFromMonth, null); + assert.equal(p.cashShortfallCents, null); +}); + +test("example 9: no opening cash is valid and gives zero runway", () => { + const v = valid({ cashNow: "0", collectedPerMonth: "500", paidOutPerMonth: "1000" }); + assert.deepEqual(v.today, { kind: "burning", netBurnCents: 50000n, runwayTenths: 0n, runwayIsExact: true, cashOutMonth: 0n }); +}); + +test("example 10: growth begins in month 2 and cannot fund the first month's gap", () => { + const v = valid({ cashNow: "0", collectedPerMonth: "900", paidOutPerMonth: "1000", revenueGrowthPercent: "20", expenseGrowthPercent: "0" }); + assert.equal(v.today.runwayTenths, 0n); + assert.equal(v.today.cashOutMonth, 0n); + const p = v.projection; + assert.deepEqual(p.outcome, { kind: "runsOut" }); + assert.deepEqual(p.cashOut, { month: 0, runwayTenths: 0n, runwayIsExact: true }); + assert.equal(p.coversCostsFromMonth, 2); + assert.deepEqual(p.lowestBalance, { month: 1, cents: -10000n }); + assert.equal(p.cashShortfallCents, 10000n); + assert.equal(p.path[1].collectedCents, 90000n); + assert.equal(p.path[2].collectedCents, 108000n); +}); + +test("example 11: static runway stays uncapped while the forecast stops at month 60", () => { + const v = valid({ cashNow: "500000", collectedPerMonth: "9900", paidOutPerMonth: "10000", revenueGrowthPercent: "0", expenseGrowthPercent: "0" }); + assert.equal(v.today.runwayTenths, 50000n); + assert.equal(v.today.cashOutMonth, 5000n); + const p = v.projection; + assert.equal(p.matchesToday, true); + assert.equal(p.cashOut, null); + assert.deepEqual(p.outcome, { kind: "lastsBeyondHorizon" }); + assert.equal(p.path[60].balanceCents, 49400000n); + assert.deepEqual(p.lowestBalance, { month: 60, cents: 49400000n }); +}); + +test("example 12: a one-off payment takes cash below zero despite recurring coverage", () => { + const v = valid({ cashNow: "100000", collectedPerMonth: "5000", paidOutPerMonth: "4000", plannedChanges: [change({ amount: "150000", startMonth: "6" })] }); + assert.equal(v.today.kind, "cashFlowPositive"); + const p = v.projection; + assert.equal(p.revenueGrowthWasBlank, true); + assert.equal(p.expenseGrowthWasBlank, true); + assert.deepEqual(p.outcome, { kind: "runsOut" }); + assert.deepEqual(p.cashOut, { month: 6, runwayTenths: 57n, runwayIsExact: false }); + assert.equal(p.path[5].balanceCents, 10500000n); + assert.equal(p.path[6].changeCents, -14900000n); + assert.equal(p.path[6].balanceCents, -4400000n); + assert.equal(p.coversCostsFromMonth, 1); + assert.deepEqual(p.lowestBalance, { month: 6, cents: -4400000n }); + assert.equal(p.cashShortfallCents, 4400000n); +}); + +test("example 13: -100% growth keeps month 1 and then drops revenue to zero", () => { + const p = valid({ cashNow: "10000", collectedPerMonth: "1000", paidOutPerMonth: "500", revenueGrowthPercent: "-100" }).projection; + assert.equal(p.path[1].collectedCents, 100000n); + assert.ok(p.path.slice(2).every(point => point.collectedCents === 0n)); + assert.deepEqual(p.cashOut, { month: 22, runwayTenths: 220n, runwayIsExact: true }); + assert.equal(p.firstCoversCostsMonth, 1); + assert.equal(p.coversCostsFromMonth, null); + assert.equal(p.cashShortfallCents, null); +}); + +test("exact half cents round away from zero without compounding rounded values", () => { + const p = valid({ cashNow: "0", collectedPerMonth: "0.01", paidOutPerMonth: "0.01", revenueGrowthPercent: "50", expenseGrowthPercent: "50" }).projection; + assert.deepEqual(p.path.slice(1, 5).map(point => point.collectedCents), [1n, 2n, 2n, 3n]); + assert.deepEqual(p.path.slice(1, 5).map(point => point.paidOutCents), [1n, 2n, 2n, 3n]); + assert.ok(p.path.every(point => point.balanceCents === 0n)); + assert.equal(p.cashOut, null); + assert.deepEqual(p.outcome, { kind: "coversCostsThroughout" }); + assert.deepEqual(p.lowestBalance, { month: 0, cents: 0n }); +}); + +test("each cadence keeps planned amounts flat in either direction", () => { + for (const direction of ["in", "out"]) { + for (const frequency of ["once", "monthly", "yearly"]) { + const p = valid({ cashNow: "100000", collectedPerMonth: "0", paidOutPerMonth: "0", revenueGrowthPercent: "100", expenseGrowthPercent: "100", plannedChanges: [change({ direction, frequency, startMonth: "2", amount: "123.45" })] }).projection; + const activeMonths = frequency === "once" ? [2] : frequency === "yearly" ? [2, 14, 26, 38, 50] : Array.from({ length: 59 }, (_, i) => i + 2); + const signed = direction === "in" ? 12345n : -12345n; + assert.deepEqual(p.path.filter(point => point.plannedCents !== 0n).map(point => point.month), activeMonths); + assert.ok(p.path.filter(point => point.plannedCents !== 0n).every(point => point.plannedCents === signed)); + assert.equal(p.path[60].balanceCents, 10000000n + BigInt(activeMonths.length) * signed); + assert.equal(p.coversCostsFromMonth, direction === "out" && frequency === "monthly" ? null : 1); + } + } +}); + +test("only monthly income helps cover costs, and only from its start month", () => { + for (const frequency of ["once", "yearly", "monthly"]) { + const p = valid({ cashNow: "1000", collectedPerMonth: "0", paidOutPerMonth: "1", plannedChanges: [change({ direction: "in", frequency, amount: "1", startMonth: "4" })] }).projection; + assert.equal(p.firstCoversCostsMonth, frequency === "monthly" ? 4 : null); + assert.equal(p.coversCostsFromMonth, frequency === "monthly" ? 4 : null); + } +}); + +test("simultaneous planned payments cancel in cash but recurring flows still determine coverage", () => { + const p = valid({ cashNow: "1000", collectedPerMonth: "0", paidOutPerMonth: "0", plannedChanges: [ + change({ id: "monthly", amount: "1", startMonth: "1", frequency: "monthly" }), + change({ id: "once", direction: "in", amount: "1", startMonth: "1" }), + ] }).projection; + assert.equal(p.path[1].plannedCents, 0n); + assert.equal(p.path[1].coversCosts, false); + assert.equal(p.coversCostsFromMonth, null); +}); + +test("cash out at zero differs from a shortfall, and tied low balances keep the first month", () => { + const p = valid({ cashNow: "1", collectedPerMonth: "0", paidOutPerMonth: "1", plannedChanges: [change({ direction: "in", amount: "1", startMonth: "2", frequency: "monthly" })] }).projection; + assert.deepEqual(p.cashOut, { month: 1, runwayTenths: 10n, runwayIsExact: true }); + assert.deepEqual(p.lowestBalance, { month: 1, cents: 0n }); + assert.equal(p.coversCostsFromMonth, 2); + assert.equal(p.cashShortfallCents, null); + assert.deepEqual(p.outcome, { kind: "runsOut" }); +}); + +test("a hire at month 60 can end earlier cost coverage without cash out", () => { + const p = valid({ cashNow: "1000", collectedPerMonth: "1", paidOutPerMonth: "1", plannedChanges: [change({ amount: "1", startMonth: "60", frequency: "monthly" })] }).projection; + assert.equal(p.firstCoversCostsMonth, 1); + assert.equal(p.coversCostsFromMonth, null); + assert.equal(p.cashOut, null); + assert.deepEqual(p.outcome, { kind: "lastsBeyondHorizon" }); +}); + +test("a zero opening balance can hold until a later planned payment", () => { + const p = valid({ cashNow: "0", collectedPerMonth: "0", paidOutPerMonth: "0", plannedChanges: [change({ amount: "1", startMonth: "2" })] }).projection; + assert.equal(p.path[1].balanceCents, 0n); + // Follow the explicit first qualifying month; the month-zero exception applies only in month 1. + assert.deepEqual(p.cashOut, { month: 2, runwayTenths: 10n, runwayIsExact: true }); + assert.deepEqual(p.outcome, { kind: "runsOut" }); + assert.equal(p.coversCostsFromMonth, 1); + assert.deepEqual(p.lowestBalance, { month: 2, cents: -100n }); + assert.equal(p.cashShortfallCents, 100n); +}); + +test("zero growth matches static partial-month runway inside the horizon", () => { + for (const [cashNow, runwayTenths, runwayIsExact, month] of [ + ["0", 0n, true, 0], ["1", 10n, true, 1], ["2.01", 20n, false, 3], + ["12.59", 125n, false, 13], ["60", 600n, true, 60], + ]) { + const v = valid({ cashNow, collectedPerMonth: "0", paidOutPerMonth: "1", revenueGrowthPercent: "0", expenseGrowthPercent: "0" }); + assert.equal(v.today.runwayTenths, runwayTenths); + assert.equal(v.today.runwayIsExact, runwayIsExact); + assert.equal(v.today.cashOutMonth, BigInt(month)); + assert.deepEqual(v.projection.cashOut, { month, runwayTenths, runwayIsExact }); + assert.equal(v.projection.matchesToday, true); + assert.equal(v.projection.cashShortfallCents, null); + } + const afterHorizon = valid({ cashNow: "60.01", collectedPerMonth: "0", paidOutPerMonth: "1", revenueGrowthPercent: "0" }); + assert.equal(afterHorizon.today.cashOutMonth, 61n); + assert.equal(afterHorizon.projection.cashOut, null); +}); + +test("forecast group ignores blank rows and flags growth left blank", () => { + const ignored = change({ name: " ", amount: " ", startMonth: " ", direction: "bad", frequency: "bad" }); + assert.equal(valid({ plannedChanges: [ignored] }).projection, null); + for (const plannedChanges of [undefined, null, {}, "bad"]) assert.equal(valid({ plannedChanges }).projection, null); + const p = valid({ expenseGrowthPercent: "0", plannedChanges: [ignored, change({ id: "second", name: " Hire " }), change({ id: "third" })] }).projection; + assert.equal(p.revenueGrowthWasBlank, true); + assert.equal(p.expenseGrowthWasBlank, false); + assert.equal(p.matchesToday, false); + assert.deepEqual(p.plannedChanges, [ + { id: "second", name: "Hire", nameWasBlank: false, direction: "out", amountCents: 50000n, startMonth: 4, frequency: "once" }, + { id: "third", name: "Planned change 2", nameWasBlank: true, direction: "out", amountCents: 50000n, startMonth: 4, frequency: "once" }, + ]); + assert.equal(valid({ revenueGrowthPercent: 0, expenseGrowthPercent: null }).projection, null); +}); + +test("money validates each field without treating malformed pastes as zero", () => { + const cases = [ + ["", "missing"], [" ", "missing"], [null, "missing"], [1, "missing"], + ["-1000", "negative"], [" -0.00 ", "negative"], ["abc", "format"], + ["$500,000", "format"], ["500,000", "format"], ["1e5", "format"], ["100.001", "format"], + ["+1", "format"], ["1 2", "format"], [".5", "format"], ["5.", "format"], + ["NaN", "format"], ["Infinity", "format"], ["1%", "format"], + ["1000000000.01", "range"], ["9".repeat(1000), "range"], + ]; + for (const key of ["cashNow", "collectedPerMonth", "paidOutPerMonth"]) { + for (const [raw, code] of cases) assert.equal(invalid({ [key]: raw })[key].code, code, `${key}: ${String(raw).slice(0, 30)}`); + } + assert.equal(valid({ cashNow: " 00001.2 " }).cashNowCents, 120n); + assert.equal(valid({ cashNow: "1000000000.00" }).cashNowCents, 100000000000n); + const tinyBurn = valid({ cashNow: "1000000000", collectedPerMonth: "0", paidOutPerMonth: "0.01" }); + assert.equal(tinyBurn.today.runwayTenths, 1000000000000n); + assert.equal(tinyBurn.today.cashOutMonth, 100000000000n); +}); + +test("signed growth validates format and bounds on both fields", () => { + for (const key of ["revenueGrowthPercent", "expenseGrowthPercent"]) { + for (const raw of ["-100.01", "100.01", "1000", "9".repeat(1000)]) assert.equal(invalid({ [key]: raw })[key].code, "range"); + for (const raw of ["100.5%", "1e2", "+5", "0.001", "1 0", "abc"]) assert.equal(invalid({ [key]: raw })[key].code, "format"); + for (const raw of ["-100", "100", " -00.01 ", "-0"]) assert.ok(valid({ [key]: raw }).projection); + } + const p = valid({ expenseGrowthPercent: "-100" }).projection; + assert.equal(p.path[1].paidOutCents, 5000000n); + assert.ok(p.path.slice(2).every(point => point.paidOutCents === 0n)); +}); + +test("planned changes report amount and start-month errors separately", () => { + const fields = "plannedChanges.change1"; + for (const [amount, code] of [["", "missing"], ["0", "zero"], ["-200", "negative"], ["1.001", "format"], ["1000000000.01", "range"]]) { + assert.equal(invalid({ plannedChanges: [change({ amount })] })[`${fields}.amount`].code, code); + } + for (const [startMonth, code] of [["", "missing"], ["61", "range"], ["0", "range"], ["4.5", "format"], ["-1", "format"], ["1e1", "format"], ["+1", "format"], ["9".repeat(1000), "range"]]) { + assert.equal(invalid({ plannedChanges: [change({ startMonth })] })[`${fields}.startMonth`].code, code); + } + const both = invalid({ plannedChanges: [change({ name: "Hire", amount: "", startMonth: "" })] }); + assert.equal(both[`${fields}.amount`].code, "missing"); + assert.equal(both[`${fields}.startMonth`].code, "missing"); + for (const startMonth of [" 01 ", "60"]) assert.ok(valid({ plannedChanges: [change({ startMonth })] }).projection); +}); + +test("names and options validate counted rows", () => { + for (const name of ["Hire\n", "\tHire", "x\u0000", "x\u007f", "x\u0085"]) { + assert.equal(invalid({ plannedChanges: [change({ name })] })["plannedChanges.change1.name"].code, "format"); + } + assert.equal(invalid({ plannedChanges: [change({ name: "x".repeat(61) })] })["plannedChanges.change1.name"].code, "range"); + assert.equal(valid({ plannedChanges: [change({ name: "🙂".repeat(60) })] }).projection.plannedChanges[0].name, "🙂".repeat(60)); + assert.equal(invalid({ plannedChanges: [change({ direction: "both", frequency: "weekly" })] })["plannedChanges.change1.direction"].code, "option"); + assert.equal(invalid({ plannedChanges: [change({ frequency: "weekly" })] })["plannedChanges.change1.frequency"].code, "option"); + for (const currency of ["USD", "EUR", "GBP", "INR", "CAD", "AUD"]) assert.equal(valid({ currency }).currency, currency); + for (const currency of ["JPY", "usd", null, ""]) assert.equal(invalid({ currency }).currency.code, "option"); + assert.equal(valid({ currency: " EUR " }).currency, "EUR"); +}); + +test("structural errors include blank rows, while all active field errors accumulate", () => { + for (const raw of [null, [], "bad", 1, true, undefined]) { + assert.deepEqual(calculateStartupRunway(raw), { status: "invalid", values: null, errors: { form: { code: "form", message: "Something in the form is out of date. Reset and try again." } } }); + } + for (const row of [null, [], "bad", {}, change({ id: "" }), change({ id: 123 })]) assert.equal(invalid({ plannedChanges: [row] }).form.code, "form"); + assert.equal(invalid({ plannedChanges: [change(), change()] }).plannedChanges.code, "duplicateId"); + const blank = change({ name: "", amount: "", startMonth: "" }); + assert.equal(invalid({ plannedChanges: [blank, blank] }).plannedChanges.code, "duplicateId"); + assert.equal(invalid({ plannedChanges: Array.from({ length: 11 }, (_, i) => ({ ...blank, id: `id${i}` })) }).plannedChanges.code, "limit"); + const badInput = input({ currency: "JPY", cashNow: "-1", collectedPerMonth: "", paidOutPerMonth: "$1", revenueGrowthPercent: "101", expenseGrowthPercent: "5%", plannedChanges: [change({ name: "x\n", direction: "both", amount: "0", startMonth: "61", frequency: "weekly" }), null] }); + const original = structuredClone(badInput); + const r = calculateStartupRunway(badInput); + assert.equal(r.status, "invalid"); + assert.deepEqual(new Set(Object.keys(r.errors)), new Set([ + "currency", "cashNow", "collectedPerMonth", "paidOutPerMonth", "revenueGrowthPercent", "expenseGrowthPercent", "form", + "plannedChanges.change1.name", "plannedChanges.change1.direction", "plannedChanges.change1.amount", + "plannedChanges.change1.startMonth", "plannedChanges.change1.frequency", + ])); + assert.deepEqual(badInput, original); + const missing = calculateStartupRunway({ currency: "USD" }); + for (const key of ["cashNow", "collectedPerMonth", "paidOutPerMonth"]) assert.equal(missing.errors[key].code, "missing"); +}); + +test("maximum amounts and ten planned changes remain exact through month 60", () => { + const p = valid({ + cashNow: "1000000000", collectedPerMonth: "1000000000", paidOutPerMonth: "1000000000", + revenueGrowthPercent: "100", expenseGrowthPercent: "100", + plannedChanges: Array.from({ length: 10 }, (_, i) => change({ id: `id${i}`, direction: "in", amount: "1000000000", startMonth: "1", frequency: "monthly" })), + }).projection; + assert.equal(p.path[60].collectedCents, 57646075230342348800000000000n); + assert.equal(p.path[60].paidOutCents, 57646075230342348800000000000n); + assert.equal(p.path[60].plannedCents, 1000000000000n); + assert.equal(p.path[60].balanceCents, 60100000000000n); + assert.equal(p.path[60].atTodaysRateCents, 100000000000n); + assert.deepEqual(p.outcome, { kind: "coversCostsThroughout" }); + assert.equal(p.cashOut, null); + assert.equal(p.cashShortfallCents, null); + for (let month = 1; month <= 60; month += 1) { + assert.equal(p.path[month].balanceCents, p.path[month - 1].balanceCents + p.path[month].changeCents); + } +});