Build ChartMogul
KINDAback to the verdict
A subscription analytics layer over billing data whose difficulty is definitional rather than computational: the arithmetic is easy, the disagreement about what counts is not, and every number has to carry its own definition to be worth anything.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the import and the metric computation.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why Movements, definitions and exact integer arithmetic over billing history.
Get it Docker or your package manager. open ↗
Verify
psql connects and integer sums are exact - accountfree with an existing account
Why Failed payments, retries, mid-period changes and refunds are the cases that break naive imports, and none of them appear in generated data.
Get it Read-only API access to a billing account with at least a year of history including refunds and failed payments.
Verify
Imported totals reconcile against the system's own report - decidefree, and it takes a conversation
Why Annual spreading, discounts, refunds, set-up fees and usage each change the figures materially, and deciding them after seeing the numbers means choosing whichever looks best.
Get it Write down how each is treated and why, and agree it with whoever will quote the figures.
Verify
A written definition covers all five and appears in the configuration - have readyfree
Why Movement classification is only testable where a real customer downgraded then upgraded, or churned and returned, which generated data never contains.
Get it Use the real history rather than a fixture, and identify the awkward customers before computing anything.
Verify
At least three customers with mid-month changes or returns are identified
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- customers(id, external_id, first_paid_at, churned_at, segments_json) - subscriptions(id, customer_id, plan_id, started_at, ended_at, interval, amount_minor, currency, discount_json) - invoices(id, customer_id, issued_at, paid_at, refunded_at, lines_json, amount_minor, currency, fx_rate) - metric_definitions(id, name, version, rules_json, note, effective_from) · what counts, written down - movements(month, customer_id, kind, amount_minor, definition_version) · kind is new, expansion, contraction, churn or reactivation - metric_values(metric_id, definition_version, period, value_minor, components_json, computed_at) - A movement records the definition version that classified it, so a series computed under two definitions is visibly two series rather than one that jumped.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/metrics | Imported billing history for a real business. |
BILLING_API_KEYsecret | required | a read-only key | Read only. This never writes to the billing system. |
DEFINITIONS_PATH | required | ./config/definitions.json | The five decisions, versioned, displayed beside every figure. |
REPORTING_CURRENCY | required | USD | For conversion. Source amounts keep their own currency and rate. |
FX_POLICY | required | rate_at_invoice | Present as a constant. Re-converting rewrites history every morning. |
REACTIVATION_WINDOW_DAYS | required | 90 | A returning customer within this is reactivation rather than new. |
The build, in order
Importing billing without becoming billing
Every charge, refund and change is imported and reconciles to the source.
Failures and retries, not only successful charges.
Files
src/import.tsExactly. A difference is a defect rather than a rounding matter.
Files
tests/reconcile.test.tsRe-running must change nothing.
Files
src/import.ts
done when · tick each as it passeswatch out- Importing only successful invoices misses the failed payment that succeeded three days later, so a customer appears churned and then reactivated in the same month, which corrupts every movement figure. Import the full event history including failures and retries, and derive state from it.
Definitions as data
Every metric has a written rule set you can read.
Annual spreading, discounts, refunds, fees, usage. Five decisions minimum.
Files
config/definitions.jsonSo a disagreement is about the rule rather than the arithmetic.
Files
src/report.tsA changed definition changes history and must be visible.
Files
src/definitions.ts
done when · tick each as it passeswatch out- Encoding the definitions in code means the answer to what counts is spread across functions nobody reads, and when an investor asks how you compute recurring revenue the honest answer is that you would have to go and look. Store the rules as data, print them beside the number, and version them.
Movements, where definitions bite
Every change in revenue is classified consistently and traceably.
Recording which version classified each movement.
Files
src/movements.tsIf they do not, one of the categories is wrong.
Files
tests/movement-sum.test.tsA customer returning after two months is a different event from one after two years.
Files
src/movements.ts
done when · tick each as it passeswatch out- A customer who downgrades on the first of the month and upgrades on the twentieth can be recorded as contraction then expansion, as a single net change, or as neither, and the three produce very different churn figures from identical facts. Decide the rule, record which was applied, and make it visible per movement.
More than one currency
Historic revenue does not change when exchange rates move.
Re-converting rewrites history every morning.
Files
src/currency.tsSo the figure can be reproduced by someone else.
Files
src/report.tsIt happens and it should not corrupt the customer's history.
Files
src/currency.ts
done when · tick each as it passeswatch out- Converting all historic revenue at today's rate means last year's figures change every day, so a report run this morning disagrees with the one you sent an investor last week and neither is wrong. Fix the rate at the invoice and never re-convert.
Cohorts, segments and settling argumentsproduct builder
Every figure opens to the customers and invoices behind it.
Two definitions produce two authoritative numbers that disagree.
Files
src/cohorts.tsAcross every metric, in the suite.
Files
tests/drill-sums.test.tsSo the figures can be reproduced rather than trusted.
Files
src/export.ts
done when · tick each as it passeswatch out- A cohort chart computed with a slightly different definition from the headline metric produces two numbers that both look authoritative and disagree, and nobody can work out why. Compute everything from the same stored definitions, and assert in the suite that a cohort's totals reconcile to the corresponding movements.
That is the whole plan for ChartMogul. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Being the billing system.
- Accounting revenue recognition.
- Forecasting.
- Reconciling across several billing systems.
- session replay privacy tooling
- enterprise governance and integrations
- high-volume ingestion and retention
- bot and identity resolution
- Segment comparison with the same definitions applied, which is where the questions go next
- A definition change preview showing what a series would look like under a new rule
- Customer-level revenue history, which is what account managers actually ask for
Need the files? The project pack on the verdict page hands your agent the whole brief · more analytics.