subscriptions.ltv-cac
Customer lifetime value, LTV to CAC ratio and CAC payback months from ARPA, gross margin, churn and CAC.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 14 tests, run in TypeScript, Python and Rust.
What it does
The three unit-economics figures SaaS investors ask for, from four inputs:
LTV = ARPA x gross margin / monthly customer churn LTV : CAC = LTV / CAC CAC payback = CAC / (ARPA x gross margin) in months
For example
ltvCac(£100.00, 80%, 2.5%, £1,200.00)→ ltv £3,200.00, ltv to cac basis points 266.67%, payback months 15 ARPA 100.00, 80% margin, 2.5% churn, CAC 1,200.00: payback exactly 15 monthsltvCac(£100.00, 80%, 2.5%, £1,000.00)→ ltv £3,200.00, ltv to cac basis points 320%, payback months 13 the same customer with CAC 1,000.00: 12.5 months of payback is 13, not 12ltvCac(£9.99, 75%, 7%, £500.00)→ ltv £107.04, ltv to cac basis points 21.41%, payback months 67 awkward figures: every result is rounded once from the inputs
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function ltvCac(arpa: Money, grossMarginBasisPoints: number, monthlyChurnBasisPoints: number, cac: Money): LtvCac
| arpa | Money | average monthly recurring revenue per account |
| grossMarginBasisPoints | int | 0 to 10000; 8000 is an 80% gross margin |
| monthlyChurnBasisPoints | int | 0 to 10000; 250 is 2.5% of customers lost a month |
| cac | Money | customer acquisition cost: sales and marketing spend per new customer |
| returns | LtvCac |
The type it declares, generated into your project
/** Unit economics for one average customer. */
export interface LtvCac {
/** ARPA x gross margin / monthly churn, rounded half-up; null when churn is 0 */
readonly ltv: Money | null;
/** LTV / CAC, rounded half-up; 30000 is 3x; null when churn or CAC is 0 */
readonly ltvToCacBasisPoints: number | null;
/** CAC / (ARPA x gross margin), rounded up to whole months; null when ARPA x margin is 0 */
readonly paybackMonths: number | null;
}
Your code names it in one line, in the file that uses it
import { ltvCac } from "#fune/subscriptions.ltv-cac@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type LtvCac } from "./subscriptions_ltv_cac_types.ts";
const MAX_EXACT = 9007199254740991;
function checkRate(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0 || value > 10000) {
throw new RangeError(`${name} must be between 0 and 10000, received ${value}`);
}
}
/**
* LTV, LTV:CAC and CAC payback, each computed as one exact division from the
* inputs so no figure inherits another's rounding.
*/
export function ltvCac(arpa: Money, grossMarginBasisPoints: number, monthlyChurnBasisPoints: number, cac: Money): LtvCac {
assertSameCurrency(arpa, cac);
if (arpa.minor < 0 || cac.minor < 0) {
throw new RangeError(`ARPA and CAC must not be negative, received ${arpa.minor} and ${cac.minor}`);
}
checkRate("gross margin basis points", grossMarginBasisPoints);
checkRate("monthly churn basis points", monthlyChurnBasisPoints);
// Monthly margin per account, in minor units x 10000.
const margin = arpa.minor * grossMarginBasisPoints;
if (margin * 10000 > MAX_EXACT || cac.minor * 10000 > MAX_EXACT || monthlyChurnBasisPoints * cac.minor > MAX_EXACT) {
throw new RangeError("amounts too large for exact arithmetic (2^53 - 1)");
}
const churn = monthlyChurnBasisPoints;
return {
ltv: churn === 0 ? null : money(roundDiv(margin, churn, "half-up"), arpa.currency),
ltvToCacBasisPoints: churn === 0 || cac.minor === 0 ? null : roundDiv(margin * 10000, churn * cac.minor, "half-up"),
paybackMonths: margin === 0 ? null : roundDiv(cac.minor * 10000, margin, "up"),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add subscriptions.ltv-cac
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./subscriptions.ltv-cac-1.0.0-typescript.fune, or fetch it from a terminal with fune pull subscriptions.ltv-cac@1.0.0:typescript.
The whole function, every language, is one file too: subscriptions.ltv-cac-1.0.0.fune, 14,952 bytes, sha256 725964865268be00d902a309ce0d11889e068dda8249a82b4d7074e26c23c6ae. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before subscriptions.ltv-cac
after — your function gets the result and the arguments, and returns the final result.
// fune: after subscriptions.ltv-cac
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-div in subscriptions.ltv-cac
// fune: replace money.amount in subscriptions.ltv-cac
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show subscriptions.ltv-cac --steps.
// fune: step subscriptions.ltv-cac after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| ARPA 100.00, 80% margin, 2.5% churn, CAC 1,200.00: payback exactly 15 months | £100.00, 80%, 2.5%, £1,200.00 | → | ltv £3,200.00, ltv to cac basis points 266.67%, payback months 15 |
| the same customer with CAC 1,000.00: 12.5 months of payback is 13, not 12 | £100.00, 80%, 2.5%, £1,000.00 | → | ltv £3,200.00, ltv to cac basis points 320%, payback months 13 |
| awkward figures: every result is rounded once from the inputs | £9.99, 75%, 7%, £500.00 | → | ltv £107.04, ltv to cac basis points 21.41%, payback months 67 |
| no churn: the simple formula has no finite lifetime, so LTV and the ratio are null | £100.00, 80%, 0%, £1,200.00 | → | ltv —, ltv to cac basis points —, payback months 15 |
| no acquisition cost: no ratio, and payback is immediate | £100.00, 80%, 2.5%, £0.00 | → | ltv £3,200.00, ltv to cac basis points —, payback months 0 |
| no gross margin: nothing is ever paid back | £100.00, 0%, 2.5%, £10.00 | → | ltv £0.00, ltv to cac basis points 0%, payback months — |
| no revenue: LTV zero and payback never | £0.00, 80%, 2.5%, £10.00 | → | ltv £0.00, ltv to cac basis points 0%, payback months — |
| 100% margin and 100% churn: LTV is one month's ARPA | £50.00, 100%, 100%, £50.00 | → | ltv £50.00, ltv to cac basis points 100%, payback months 1 |
| in US dollars | $250.00, 70%, 3%, $3,000.00 | → | ltv $5,833.33, ltv to cac basis points 194.44%, payback months 18 |
| a margin above 100% is an error | £100.00, 100.01%, 2.5%, £10.00 | → | error: gross margin basis points must be between 0 and 10000 |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a negative churn rate is an error | £100.00, 80%, -0.01%, £10.00 | → | error: monthly churn basis points must be between 0 and 10000 |
| a negative CAC is an error | £100.00, 80%, 2.5%, -£0.01 | → | error: ARPA and CAC must not be negative |
| ARPA and CAC in different currencies are an error | £100.00, 80%, 2.5%, $10.00 | → | error: currency mismatch |
| amounts beyond the exact range are an error rather than a wrong answer | £1,000,000,000.00, 100%, 2.5%, £10.00 | → | error: amounts too large for exact arithmetic |
More from the author
LTV is ChartMogul's published formula ("LTV = ARPA x Gross Margin / Customer Churn Rate"): a customer who pays ARPA a month and churns with probability c each month stays 1/c months on average, and only the gross margin of what they pay is value. CAC payback is the usual gross-margin-adjusted form: how many months of margin it takes to earn back what it cost to win the customer. With ARPA 100.00, an 80% margin, 2.5% monthly churn and CAC 1,200.00, LTV is 3,200.00, the ratio is 2.67x and payback is 15 months.
Rounding. Each figure is one exact integer division from the inputs, never built from another rounded figure: LTV is rounded half-up to the minor unit, the ratio half-up to a basis point (30000 is 3x, the common target), and payback is rounded up to whole months, because a customer who has paid back 12.5 months of CAC has not paid it back after 12.
Zeroes are answers, not errors. With no churn the simple formula has no finite lifetime, so LTV and the ratio are null; with no CAC the ratio is null and payback is 0 months; with no margin (or no revenue) payback is null, because it never happens.
The formula assumes churn is constant over a customer's life, which in practice overstates LTV: churn is usually highest in the first months. Use monthly figures for all three rates; for annual churn, convert first.
Errors: a negative ARPA or CAC, rates outside 0 to 10000, amounts in different currencies, and inputs so large the intermediate products leave the exact integer range (2^53 - 1) that all three languages share.
Sources: ChartMogul, "Customer Lifetime Value (LTV)", https://chartmogul.com/saas-metrics/ltv/ (formula and LTV:CAC ratio).
Files
| Path | Bytes |
|---|---|
| README.md | 1,933 |
| impl/python.py | 1,771 |
| impl/rust.rs | 2,937 |
| impl/typescript.ts | 1,679 |
| vectors.json | 3,454 |