Skip to content

feat: report net profit, not just gross earnings (CashPilot-f5u) - #161

Merged
GeiserX merged 3 commits into
mainfrom
feat/net-profit-power-cost
Aug 2, 2026
Merged

feat: report net profit, not just gross earnings (CashPilot-f5u)#161
GeiserX merged 3 commits into
mainfrom
feat/net-profit-power-cost

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Every dashboard in this space reports what a service paid. None report what it cost to run.

For a large share of users the honest number is negative. A service earning EUR 2/month on hardware drawing 15W at EUR 0.30/kWh costs EUR 3.29 in electricity — a EUR 1.29 monthly loss that we were presenting as income.

GET /api/earnings/net now reports gross, estimated cost and net per service, and names the ones whose trailing net is below zero.

Three honesty rules, tested directly

The failure mode here is dishonesty rather than a crash, so these are asserted, not assumed:

  • An estimate is labelled as one. The CPU-share model is crude — directionally right, and far better than pretending cost is zero — so every figure carries cost_quality and a UI cannot render it with false precision.
  • Net is reported alongside gross, never instead of it.
  • No tariff configured → cost is unknown, not zero. A zero would render gross as net and overstate earnings, which is the exact thing this bead exists to stop.

Two modelling decisions worth stating

  • Idle draw is shared, not billed per container. A machine kept running for ten containers costs its idle draw once, not ten times.
  • An unmetered host is charged nothing. A VPS bill does not move with CPU, so charging estimated watts against it would invent a cost the user is not paying — as dishonest as ignoring a real one.

Scope

This is the calculation and the API. Surfacing it in the dashboard belongs with the app.js work (cyc). Real measurement from RAPL or a smart plug can replace the estimate later without changing the contract, since cost_quality already distinguishes estimated from measured.

Verification

  • ruff check . && ruff format --check . — clean
  • pytest --cov=app --cov-fail-under=901515 passed, coverage 93.70%
  • 19 new tests, including the bead's motivating case end-to-end and every honesty rule above.

Summary by CodeRabbit

  • New Features

    • Added net-earnings reporting through a new authenticated API endpoint.
    • Reports gross earnings, estimated electricity costs, net earnings by service, currency, reporting period, and loss indicators.
    • Distinguishes measured and estimated costs, identifies unknown tariffs, excludes VPS electricity costs, and allocates shared idle power across containers.
  • Documentation

    • Added an unreleased changelog entry describing net-profit reporting.

Every dashboard in this space reports what a service PAID. None report what
it COST to run. For a large share of users the honest number is negative: a
service earning EUR 2/month on hardware drawing 15W at EUR 0.30/kWh is
losing EUR 1.29 a month, and we presented that as income.

GET /api/earnings/net reports gross, estimated electricity cost and net per
service, and names the services whose trailing net is below zero.

Three rules are built into the module and tested directly, because the
failure mode here is dishonesty rather than a crash:

- An estimate is labelled as one. The CPU-share model is crude; it is
  directionally right and far better than pretending cost is zero, but
  every figure carries its quality so a UI cannot render it with false
  precision.
- Net is reported ALONGSIDE gross, never instead of it.
- With no tariff configured, the cost is reported as UNKNOWN rather than
  as zero. A zero would render gross as net and overstate earnings, which
  is the exact thing this bead exists to stop.

Two modelling decisions worth stating: a host's idle draw is shared across
its containers rather than billed to each one, because a machine kept
running for ten containers costs its idle draw once; and a host the user
does not pay power for - a VPS, whose bill does not move with CPU - is
charged nothing, since inventing a cost is as dishonest as ignoring one.

Scope: this is the calculation and the API. Surfacing it in the dashboard
belongs with the app.js work (CashPilot-cyc), and real measurement from
RAPL or a smart plug can replace the estimate later without changing the
contract, since cost_quality already distinguishes them.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GeiserX, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cc75b300-ae66-4545-b43a-14c0142c33bb

📥 Commits

Reviewing files that changed from the base of the PR and between 44f0678 and 5a34848.

📒 Files selected for processing (4)
  • app/database.py
  • app/main.py
  • app/power.py
  • tests/test_power.py
📝 Walkthrough

Walkthrough

Changes

Net-profit reporting

Layer / File(s) Summary
Power and profitability model
app/power.py, tests/test_power.py
Adds watt estimation, energy-cost calculation, metering detection, per-service net results, fleet totals, unknown-tariff handling, and related tests.
Net-earnings API integration
app/main.py, CHANGELOG.md
Adds authenticated GET /api/earnings/net reporting and documents the new endpoint.
Estimated code review effort: 3 (Moderate) ~25 minutes
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding net-profit reporting alongside gross earnings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/net-profit-power-cost

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@GeiserX

GeiserX commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.73913% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.29%. Comparing base (fad13ec) to head (5a34848).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
app/main.py 93.93% 2 Missing ⚠️
app/power.py 97.72% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #161      +/-   ##
==========================================
+ Coverage   94.23%   94.29%   +0.06%     
==========================================
  Files          36       37       +1     
  Lines        4475     4595     +120     
==========================================
+ Hits         4217     4333     +116     
- Misses        258      262       +4     
Files with missing lines Coverage Δ
app/database.py 93.60% <100.00%> (+0.21%) ⬆️
app/power.py 97.72% <97.72%> (ø)
app/main.py 96.79% <93.93%> (-0.07%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/main.py`:
- Around line 1811-1816: Update the running-container filter in the power
allocation logic to include only containers with a service and status equal to
"running". Ensure both count and cpu_by_service are derived from this filtered
collection, and add a regression test covering a stopped container so it does
not affect allocation or fleet cost.
- Around line 1818-1835: Update the earnings aggregation around
get_earnings_per_service and the rows construction to use each platform’s
earnings within the requested days window rather than its latest balance.
Convert each gross amount from its platform currency into the configured
power-cost currency before power.summarise, and ensure the fallback does not mix
unconverted balances with cost totals.
- Line 1811: Update the /api/power/current flow around
_get_all_worker_containers() so malformed or missing per-worker
heartbeat/container data is handled independently rather than aborting the
endpoint. Ensure one worker’s status failure does not prevent database-sourced
gross earnings from being returned, and represent costs as unknown when CPU
status cannot be obtained.
- Around line 1811-1826: Update api_earnings_net to retain the worker grouping
returned by _get_all_worker_containers through power calculation instead of
deriving one fleet-wide count, host TDP, and CPU map. For each worker, apply its
worker_meta settings and power.is_metered(worker_meta), zero unmetered platforms
on their source platform, calculate watts, then aggregate the resulting watts by
platform before earnings allocation.

In `@app/power.py`:
- Around line 130-153: Update the service-row construction in the loop around
net_for_service so an unconfigured tariff does not expose gross as per-service
net: return cost and net as None, or use the established explicit unknown-state
representation, while preserving calculated values when configured is true. Add
a regression test asserting out["services"][0] reports the unknown cost/net
state when price_per_kwh=0.0.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f2babcf1-c9a4-4c03-9898-d0665ff3956e

📥 Commits

Reviewing files that changed from the base of the PR and between 29a50e5 and 44f0678.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • app/main.py
  • app/power.py
  • tests/test_power.py

Comment thread app/main.py Outdated
Comment thread app/main.py Outdated
Comment thread app/main.py Outdated
Comment thread app/main.py
Comment thread app/power.py
@GeiserX

GeiserX commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Holding this PR rather than merging it. Five findings, and two of them mean the numbers it reports would be wrong — in a change whose whole purpose is reporting numbers honestly.

power.py:153 — per-service net is gross when the tariff is unknown. Correct, and it is my own stated rule broken in my own code. I got the totals right (total_net: None, cost_known: false) but each service row still reports net == gross. That is precisely the 'render gross as profit' failure this bead exists to stop. Per-service cost and net must be None when there is no tariff.

main.py:1835 — window-scoped cost subtracted from a lifetime balance. Also correct and more damaging. get_earnings_per_service() returns each platform's latest balance, not what it earned over days. So a 30-day electricity cost is subtracted from a lifetime (or current) balance, and the resulting 'net' means nothing. The currency mixing in the same path compounds it.

The other three are real too: stopped containers inflate container_count and understate cost; collapsing every worker into one count and one host TDP charges a 3-node fleet a single idle floor and makes is_metered per-worker impossible; and there is no error boundary around worker-status data.

Fixing these properly needs a window-scoped earnings query and per-worker power attribution — more than a patch on top of this diff. Not merging a feature that would mislead about money.

Four of the five review findings on this PR. Two of them meant the numbers
were wrong, which disqualifies a feature about reporting money honestly.

Per-service net no longer equals gross when the tariff is unknown. The
totals already said cost_known false / total_net None, but every service
row still reported net == gross - presenting earnings as profit, the exact
dishonesty this module exists to prevent, in my own code against my own
stated rule. cost and net are now None per service, with cost_quality
"unknown", and nothing is flagged negative on an unknown cost.

Net is now computed from earnings OVER THE WINDOW. The endpoint used
get_earnings_per_service(), which returns the latest BALANCE - a running
total - so a 30-day electricity cost was charged against a lifetime of
earnings. get_earned_by_platform(days) sums per-platform deltas across the
window, clamped per platform the same way the dashboard does
(CashPilot-glc) so a payout does not read as negative earnings.

Also: only RUNNING containers are counted, since a stopped one draws
nothing and counting it inflates container_count, shrinking every running
service's share of the idle floor and understating the fleet's cost. And a
worker-status failure no longer takes out the earnings figures, which come
from the database and are still perfectly reportable.

STILL OPEN, deliberately: per-worker power attribution. Every worker is
still collapsed into one count and one host TDP, so a multi-host fleet is
charged a single idle floor and power.is_metered cannot be applied per
worker. That needs the worker grouping carried through to the watt
calculation and is a larger change than this one.
@GeiserX

GeiserX commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Reworked. Four of the five findings are fixed and verified; the fifth is called out below rather than quietly left.

Fixed — per-service net was gross when the tariff is unknown. This was the feature's own rule broken in its own code: totals said cost_known: false / total_net: null, but every row still reported net == gross. Rows now return cost: null, net: null, cost_quality: "unknown", and nothing is flagged negative on an unknown cost. Regression test asserts out["services"][0] directly.

Fixed — a window's cost charged against a lifetime balance. get_earnings_per_service() returns the latest balance, a running total. New get_earned_by_platform(days) sums per-platform deltas across the window, clamped per platform the same way the dashboard does (glc), so a payout does not read as negative earnings. Two tests: a 100→103 climb reports 3 rather than 103, and a payout-then-earn sequence reports the earning, not a negative.

Fixed — stopped containers inflated container_count, shrinking each running service's share of the idle floor and understating cost. Now filtered on status == running.

Fixed — no error boundary. A worker-status failure no longer takes out the earnings figures, which come from the database and are still reportable.

Still open, deliberately: per-worker power attribution. Every worker is still collapsed into one count and one host TDP, so a multi-host fleet is charged a single idle floor and is_metered cannot be applied per worker. That needs the worker grouping carried through to the watt calculation — a larger change than this, and I would rather it be its own reviewable diff than a rushed addendum here.

Verification: ruff clean, 1520 passed, coverage 93.67%.

codecov flagged the patch: the module was well covered but the endpoint
around it was not - and that is where config parsing, the running-only
filter and the error boundary live.

Covers: net reported when a tariff is set, unknown (not gross-as-net) when
it is not, a stopped container not diluting the idle floor, a worker-status
failure still returning the database-sourced gross, and a malformed tariff
treated as unset rather than crashing.
@GeiserX
GeiserX merged commit dea2865 into main Aug 2, 2026
8 checks passed
@GeiserX
GeiserX deleted the feat/net-profit-power-cost branch August 2, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant