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
ltv_cac(£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 monthsltv_cac(£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 12ltv_cac(£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.
pub fn ltv_cac(arpa: &Money, gross_margin_basis_points: i64, monthly_churn_basis_points: i64, cac: &Money) -> LtvCac
| arpa | Money | average monthly recurring revenue per account |
| gross_margin_basis_points | int | 0 to 10000; 8000 is an 80% gross margin |
| monthly_churn_basis_points | 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LtvCac {
/// ARPA x gross margin / monthly churn, rounded half-up; null when churn is 0
pub ltv: Option<Money>,
/// LTV / CAC, rounded half-up; 30000 is 3x; null when churn or CAC is 0
pub ltv_to_cac_basis_points: Option<i64>,
/// CAC / (ARPA x gross margin), rounded up to whole months; null when ARPA x margin is 0
pub payback_months: Option<i64>,
}
Your code names it in one line, in the file that uses it
fune!(subscriptions.ltv-cac@^1); // then call ltv_cac(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
const MAX_EXACT: i64 = 9007199254740991;
fn check_rate(name: &str, value: i64) {
if !(0..=10000).contains(&value) {
panic!("{} must be between 0 and 10000, received {}", name, value);
}
}
/// LTV, LTV:CAC and CAC payback, each computed as one exact division from the
/// inputs so no figure inherits another's rounding.
///
/// # Panics
/// Panics on negative amounts, rates outside 0..=10000, mixed currencies, or
/// inputs beyond the shared exact range.
pub fn ltv_cac(arpa: &Money, gross_margin_basis_points: i64, monthly_churn_basis_points: i64, cac: &Money) -> LtvCac {
assert_same_currency(arpa, cac);
if arpa.minor < 0 || cac.minor < 0 {
panic!(
"ARPA and CAC must not be negative, received {} and {}",
arpa.minor, cac.minor
);
}
check_rate("gross margin basis points", gross_margin_basis_points);
check_rate("monthly churn basis points", monthly_churn_basis_points);
// Monthly margin per account, in minor units x 10000. Checked in i128 so
// the guard itself cannot overflow.
let margin = arpa.minor * gross_margin_basis_points;
let churn = monthly_churn_basis_points;
if (margin as i128) * 10000 > MAX_EXACT as i128
|| (cac.minor as i128) * 10000 > MAX_EXACT as i128
|| (churn as i128) * (cac.minor as i128) > MAX_EXACT as i128
{
panic!("amounts too large for exact arithmetic (2^53 - 1)");
}
LtvCac {
ltv: if churn == 0 {
None
} else {
Some(money(round_div(margin, churn, "half-up"), &arpa.currency))
},
ltv_to_cac_basis_points: if churn == 0 || cac.minor == 0 {
None
} else {
Some(round_div(margin * 10000, churn * cac.minor, "half-up"))
},
payback_months: if margin == 0 {
None
} else {
Some(round_div(cac.minor * 10000, margin, "up"))
},
}
}
pub fn ltv_cac_to_value(r: &LtvCac) -> Value {
Value::obj(vec![
(
"ltv",
match &r.ltv {
Some(m) => money_to_value(m),
None => Value::Null,
},
),
(
"ltvToCacBasisPoints",
match r.ltv_to_cac_basis_points {
Some(n) => Value::Int(n),
None => Value::Null,
},
),
(
"paybackMonths",
match r.payback_months {
Some(n) => Value::Int(n),
None => Value::Null,
},
),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
ltv_cac_to_value(<v_cac(
&money_from_value(&args[0]),
args[1].as_i64(),
args[2].as_i64(),
&money_from_value(&args[3]),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./subscriptions.ltv-cac-1.0.0-rust.fune, or fetch it from a terminal with fune pull subscriptions.ltv-cac@1.0.0:rust.
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 |