Skip to content

Repository files navigation

chargeback-report-generator

Turn per-team database cost attribution data into a self-contained monthly chargeback report you can send to finance.

CI Python 3.10+ License: MIT

Why this exists

Attributing database spend is the hard part, but it is not the last part. Once you have a tidy table of period, team, cost, someone still has to turn it into a document that a finance business partner will accept — one that explains itself, survives being forwarded as an email attachment, prints to a clean PDF, and holds up when a team lead says "that number is wrong".

The usual options are a notebook whose output nobody else can open, a BI dashboard behind SSO that finance does not have, or a spreadsheet that quietly stops footing after someone rounds per-row. This tool takes the attribution CSV you already produce and emits one HTML file with every chart and stylesheet inlined. No CDN, no JavaScript, no network. Open it from a mail attachment on a plane and it renders identically.

It also does the two things people always ask for immediately afterwards: it says in plain English what moved, and it flags the movements that are actually unusual for that particular team rather than the ones that merely look big.

Screenshots

The generated report — header, executive summary, breakdown table, charts, anomaly callouts, budget vs actual (full page, rendered in Chromium):

The generated chargeback report rendered in a browser

preview renders the same content in the terminal, which is what you want in CI:

Terminal output of the preview subcommand

diff prints just the movements between two periods:

Terminal output of the diff subcommand

The charts are hand-authored inline SVG — no matplotlib, no chart library — and are lifted out of the report unchanged here:

Stacked bar chart of spend by team over fourteen periods

Line chart of total spend across fourteen periods

The real artifact is the generated example report itself: a single ~38 KB file with everything embedded. Clone the repo and open it locally — GitHub's file view shows the source rather than rendering it, though the htmlpreview service will render it from the raw URL if you want a look without cloning.

Quick start

Not published to PyPI — clone it and run it.

git clone https://github.com/db-cost-quota/chargeback-report-generator.git
cd chargeback-report-generator

python -m venv .venv
.venv/bin/pip install -e ".[dev]"

Everything below runs offline against the bundled 14-month, six-team example dataset.

# Check the input before you trust it
.venv/bin/python -m chargeback_report_generator validate \
  -i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml

# Eyeball the report in the terminal
.venv/bin/python -m chargeback_report_generator preview \
  -i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml

# Build the report
.venv/bin/python -m chargeback_report_generator build \
  -i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml \
  -o report.html

xdg-open report.html   # or open / start

If you install the package into a virtualenv, the console script chargeback-report is equivalent to python -m chargeback_report_generator.

How it works

The arithmetic

Every amount is a decimal.Decimal from the moment it is parsed. Sums are exact; rounding to cents happens once, at display time.

That last part matters more than it sounds. If you round each team's total independently, the column does not add up to the reported total — twelve rows of 1.005 round to 12.12 while the true total rounds to 12.06. So the per-group figures are rounded with largest-remainder allocation: round everything to cents, then hand the residual cents back to the groups whose rounding lost the most. The table always foots exactly, and there is a test that proves it on adversarial inputs.

Period-over-period deltas

Deltas compare the reported period against the immediately preceding period present in the data (not the calendar — validate warns separately about calendar gaps).

Percentages are deliberately omitted, and shown as n/a, whenever the prior value is zero. A percentage against a zero baseline is undefined, and "+∞%" in a document going to finance is worse than an honest blank. New teams are labelled new and carry an absolute figure only; teams that stop billing are labelled ended with a −100% change.

Anomaly detection

Two independent signals, both of which must clear a minimum absolute change so that noise on tiny teams is not promoted to a callout:

  1. Percent change against the previous period. This is what a human notices, and it is the signal a team lead will check you on.
  2. Robust z-score against the team's own history, computed from the median and the median absolute deviation (MAD), scaled by 0.6745 so it is comparable to a normal z-score.

The second exists because the textbook mean/standard-deviation z-score is close to useless here: a single large spike inflates the standard deviation enough to hide itself. MAD is unaffected by up to half the samples being outliers, so a genuine one-month spike scores in the double digits while a team that swings 40% every month does not fire every month. A team needs a configurable minimum number of prior periods before the z-score is considered at all, and if a team's history is perfectly flat the score is reported as undefined rather than infinite.

Charts

Three hand-authored inline SVGs: a stacked bar of spend by team over the history window, a horizontal bar of the current period's split, and a trend line of total spend.

Horizontal bars rather than a treemap for the split, because treemap tiles for small teams are too narrow to hold a label without clipping — and the entire point of that section is that every team can read its own line.

Each chart carries role="img" with a <title> and a <desc> stating the actual numbers, so a screen-reader user gets what a sighted reader gets from the picture. The palette is Okabe-Ito minus the pale yellow: distinguishable under deuteranopia, protanopia and tritanopia, and separable by lightness on a monochrome office printer. Axis ranges snap to readable boundaries, tick labels thin themselves out when the bands get narrow, and the legend wraps on measured text width, so labels do not overlap or spill outside the viewBox. Tests assert all of that.

Limitations — read these before you rely on it

  • No FX conversion. Rows must arrive pre-normalised to one currency. Mixed currencies produce a warning telling you which codes to fix upstream, not a silent conversion at some rate this tool invented.
  • No cost attribution. This is a reporting tool. It presents whatever attribution you fed it; it cannot tell you whether your allocation keys are fair. Document them in methodology so the appendix can defend them.
  • Monthly granularity. Periods are YYYY-MM. Daily reporting is a different shape of problem and a different tool.
  • The z-score needs history. With fewer than min_history prior periods only the percent change fires. On a brand-new dataset expect the first few reports to be noisier.
  • --group-by affects the table, not the anomaly detector. Anomalies are always computed per team, because a per-team-per-app z-score on a sparse grid is mostly noise.

Usage

All subcommands share the input flags:

Flag Meaning
--input, -i Attribution data: .csv, .json or .jsonl. Required.
--config, -c config.yaml. Optional; defaults are used without it.
--budgets, -b budgets.yaml. Optional; omit to drop the budget section.
--period, -p Billing period to report, e.g. 2026-06. Defaults to the latest in the data.
--periods History window length for charts and sparklines. Overrides the config.
--group-by Comma-separated dimensions: team,app,service,environment,tenant. Default team.
--generated-on Pin the report date (YYYY-MM-DD) so output is byte-reproducible in CI.

build

python -m chargeback_report_generator build \
  -i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml \
  -p 2026-06 --periods 12 --group-by team,app \
  -f html --theme light -o report.html
  • --format html — the self-contained report. The default, and the point of the tool.
  • --format email-html — table-based layout with inline styles only, no <style> block. Outlook's rendering engine ignores most modern CSS and some clients strip <style>; this variant uses the attributes-and-inline-declarations approach that has worked since 2005.
  • --format csv / --format json — the same numbers, machine-readable. The CSV carries a TOTAL row that reconciles with the body; the JSON carries the whole report including summary prose, anomalies, pools and budgets under a chargeback-report/v1 schema tag.
  • --format pdf — needs the optional extra (below).
  • --theme light|printprint flattens the card borders for paper.
  • --template-dir DIR — a directory containing your own report.html.j2 and/or email.html.j2. Anything absent falls back to the packaged template.

preview

Renders the whole report — summary, table, sparklines, pools, anomalies, budgets — as rich terminal output. Useful for reviewing a report in a CI log without downloading an artifact. --width N forces the console width for reproducible output.

diff

Compares two periods and prints only the movements.

python -m chargeback_report_generator diff -i examples/attribution.csv --against 2025-05
python -m chargeback_report_generator diff -i examples/attribution.csv -f csv -o moves.csv

--against names an explicit baseline (default: the period immediately before --period), and --format table|csv|json selects the output. Useful standalone in a monthly review.

validate

Checks the input schema, the config and the budgets, and reports:

  • every bad row at once, with the row number and the specific field — not just the first one, because fixing a billing export one error per run is miserable;
  • currency codes that do not match the reporting currency;
  • budgets defined for teams that have no cost rows;
  • calendar months between the first and last period that have no data at all.

Exit codes: 0 fine, 2 bad input or config, 3 PDF requested without WeasyPrint.

PDF output

PDF is optional because its dependency chain is not:

pip install -e ".[pdf]"     # WeasyPrint; also needs pango, cairo and gdk-pixbuf
python -m chargeback_report_generator build -i ... -f pdf -o report.pdf

Without it, build -f pdf exits with a message telling you exactly this. The no-dependency path is the browser: open the HTML report and use Print → Save as PDF. The report ships @media print rules with an A4 page box, repeating table headers, and break-inside: avoid on charts and callouts, so this produces a clean document rather than a screenshot of a web page.

Configuration

examples/config.yaml is fully commented; every key is optional.

currency: USD
periods: 14
theme: light

brand:
  org_name: Northwind Data Platform
  report_title: Database Chargeback Report
  accent: "#1f4e79"
  logo_path: logo.png        # embedded as a data URI, relative to this file

data_source: >-
  AWS Cost and Usage Report joined to the service catalogue on the owner_team tag.
coverage_note: >-
  94.2% of database spend carries an owner_team tag; the remainder is allocated pro-rata.

units_label: USD / 1k queries
measured_pools: [compute, storage]
allocated_pools: [io, backup]

methodology:
  - Shared Aurora clusters are split by each team's share of pg_stat_statements total_exec_time.

anomalies:
  pct_change: 25        # flag a period-over-period move beyond ±25%
  z_score: 3.5          # ...or a robust z-score beyond ±3.5
  min_abs_change: 1500  # ...but only if the absolute change clears this
  min_history: 4        # periods of history required before the z-score is used

measured_pools and allocated_pools drive the "Basis" column and the appendix. Being explicit about which pools are metered and which are estimated is what makes the report survivable in an argument.

budgets.yaml accepts a shorthand mapping or full objects:

teams:
  platform-core:
    monthly: 46000
    note: Includes the shared Aurora control plane.
  checkout: 34000

Integrating with your own data

Input schema

One row per (period, team, app, service) — or per resource; the tool aggregates.

Column Required Notes
period yes YYYY-MM.
team yes The chargeback owner.
cost yes Decimal. Commas, $ and negatives (credits) are accepted.
app no Defaults to -.
service no Defaults to -.
currency no Three-letter code, defaults to USD. Must be consistent.
environment no Available to --group-by.
tenant no Available to --group-by.
units no Denominator for the cost-per-unit column: active tenants, 1k queries, whatever units_label says.
anything else numeric no Treated as a cost pool and shown in the cost-pools section (compute, storage, io, backup, …).

Non-numeric extra columns are ignored rather than rejected, so you can carry cost-centre codes through without stripping them first.

period,team,app,service,environment,cost,currency,units,compute,storage,io,backup
2026-06,platform-core,auth-api,aurora-postgres,prod,7425.80,USD,223783,4559.00,1756.77,783.16,326.87

Producing it from an AWS Cost and Usage Report

Group the CUR by your ownership tag and the service dimension, then pivot to the columns above:

-- Athena over the CUR, one row per month/team/service
SELECT
    date_format(line_item_usage_start_date, '%Y-%m')            AS period,
    coalesce(resource_tags_user_owner_team, 'unattributed')     AS team,
    coalesce(resource_tags_user_app, '-')                       AS app,
    product_product_name                                        AS service,
    sum(line_item_unblended_cost)                               AS cost,
    'USD'                                                       AS currency,
    sum(CASE WHEN line_item_usage_type LIKE '%InstanceUsage%'
             THEN line_item_unblended_cost ELSE 0 END)          AS compute,
    sum(CASE WHEN line_item_usage_type LIKE '%StorageUsage%'
             THEN line_item_unblended_cost ELSE 0 END)          AS storage,
    sum(CASE WHEN line_item_usage_type LIKE '%IOUsage%'
             THEN line_item_unblended_cost ELSE 0 END)          AS io
FROM cur.line_items
WHERE product_product_family IN ('Database Instance', 'Database Storage')
  AND line_item_usage_start_date >= date_add('month', -14, current_date)
GROUP BY 1, 2, 3, 4
ORDER BY 1, 5 DESC;

Splitting a shared cluster between teams

Untagged shared clusters are the usual gap. Attribute them by each team's share of execution time, then multiply through the cluster's monthly bill:

-- PostgreSQL: per-team share of a shared cluster, from pg_stat_statements.
-- Assumes you map rolname -> team, and that you snapshot and reset this monthly.
SELECT
    r.rolname                                                   AS team,
    sum(s.total_exec_time)                                      AS exec_ms,
    sum(s.calls)                                                AS calls,
    sum(s.total_exec_time)
      / nullif(sum(sum(s.total_exec_time)) OVER (), 0)          AS cost_share
FROM pg_stat_statements s
JOIN pg_roles r ON r.oid = s.userid
GROUP BY r.rolname
ORDER BY exec_ms DESC;

Record what you did in the methodology list in config.yaml — it renders into the appendix, and that is the section that ends the argument.

In a monthly job

build is deterministic: same input, same --generated-on, byte-identical output. That makes it safe to commit the report or diff it against last month's.

python -m chargeback_report_generator validate -i attribution.csv -c config.yaml || exit 1
python -m chargeback_report_generator build \
  -i attribution.csv -c config.yaml -b budgets.yaml \
  -o "reports/$(date +%Y-%m).html"
python -m chargeback_report_generator build \
  -i attribution.csv -c config.yaml -b budgets.yaml \
  -f email-html -o "reports/$(date +%Y-%m).email.html"

Development

.venv/bin/ruff check .
.venv/bin/ruff format --check .
.venv/bin/mypy src
.venv/bin/pytest -q

295 tests covering the rounding reconciliation, delta edge cases (new teams, departed teams, zero baselines), the robust z-score, budget utilization, currency handling, Jinja autoescaping against injected markup in team names, HTML structure via a parser, every SVG parsing as XML with finite coordinates and in-bounds labels, the self-contained property, the print CSS, and CSV/JSON agreement with the HTML.

Regenerating the demo artifacts under docs/:

.venv/bin/python examples/generate_data.py        # only if the sample data changed
.venv/bin/python docs/generate_screenshots.py

The terminal screenshots come from rich's recording console; the full-page report screenshot uses headless Chromium if one is on PATH and is skipped cleanly if not. Both are pinned to a fixed report date so re-running does not churn the diff — and tests/test_cli.py fails if docs/example-report.html drifts from what the current code produces.

Further reading

Background on the practices this tool automates:

License

MIT — see LICENSE.

About

Turn per-team database cost attribution data into a self-contained monthly chargeback report — inline SVG charts, anomaly callouts and budget tracking, no JS required.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages