Skip to content

feat(fleet): accept a paired client's own earnings history - #256

Merged
GeiserX merged 5 commits into
mainfrom
feat/earnings-import-endpoint
Aug 6, 2026
Merged

feat(fleet): accept a paired client's own earnings history#256
GeiserX merged 5 commits into
mainfrom
feat/earnings-import-endpoint

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

POST /api/workers/earnings-import lets a client that has been reading a provider account by itself hand that history to the server. CashPilot Desktop is the case that prompted it: run standalone for months, pair, and the fleet view started on the day of pairing — every earlier day was gone from the total.

This is the second half of CashPilot-Desktop-xjr. The first half (#255) added the source column and made both delta readers key on (platform, source); this adds the write path that uses it.

Why separate series, rather than merging into one history

Earnings are stored as cumulative balance readings, and an earned figure is the clamped delta between consecutive readings. Interleaving two samplers of the same provider account is therefore not harmless: sampler A reads 12.0, sampler B reads 11.8 an hour later, and the negative step clamps to zero. Every crossing loses a real gain and the total comes out systematically understated.

Storing each client's readings under its own source means each series is differenced on its own, and the results are summed. Nothing merges, nothing is double-counted, and unlinking a client leaves its own series intact — which is what makes "show only what this machine earned alone" possible later.

Security

Two properties, both tested with negative controls:

  • The source comes from the authenticated worker (cid), never from the request body. The model carries no source field at all; a test sends one anyway, so a future model change that started honouring it fails here rather than silently letting a client overwrite the server's own series.
  • Only a fully enrolled worker may import (state == "ok"). enroll and reissue both mean the caller presented the shared key, which every worker holds — accepting it would let anyone with that token write a history for any client_id they named. The 403 says how to proceed rather than just refusing.

Unknown slugs are skipped and reported rather than stored: storing one would create a platform the catalog cannot name, render, or ever collect for again, and a silent drop looks identical to a successful import.

Idempotent by construction — the (platform, source, date) unique index means a re-pair or a retried import updates a day rather than adding a second reading for it, which would difference against itself and read as zero.

Verification

  • 14 new tests, all six properties confirmed by negative control: hardcoding source="server", allowing shared-key holders, storing unknown slugs, dropping the currency normalisation, coercing an absent FX rate to 0.0, and accepting a blank client_id each fail the tests that claim to catch them.
  • Full suite: 3577 passed, 6 skipped, coverage 95.51% (gate 90%).
  • ruff check + ruff format --check clean; node --check, currency_check.mjs, fleet_staleness_check.mjs all pass.

Docs: docs/fleet.md gains the endpoint, the payload shape, and the three deliberate constraints.

Summary by CodeRabbit

  • New Features

    • Added an authenticated earnings-history import endpoint for workers.
    • Supports validated dates, currencies, exchange rates, bounded batch sizes, and duplicate-safe daily updates.
    • Filters unknown catalog entries and reports skipped items.
    • Preserves source isolation and associates imports with the authenticated worker.
  • Bug Fixes

    • Improved request-validation error serialization, including safe handling of non-finite numeric values.
    • Added atomic batch processing to prevent partial imports after failures.
  • Documentation

    • Added usage guidance and validation details for earnings-history imports.

A client that has been reading a provider account on its own -- CashPilot
Desktop before it was paired -- had no way to hand that history to the
server, so the fleet view began on the day of pairing and every earlier
day was simply lost from the total.

POST /api/workers/earnings-import takes those readings and stores them
under the importing client's own source, which the source-aware schema
added in the previous change makes possible. Separate series matter
here: earnings are clamped deltas between consecutive readings of the
same balance, so interleaving two samplers of one account makes every
apparent drop clamp to zero and understates the total. Each series is
differenced on its own and the results are summed.

Two properties carry the security of it:

* The source comes from the AUTHENTICATED worker, never the request
  body, so no client can write into another's history or into the
  server's own.
* Only a fully enrolled worker may import. A caller still presenting the
  shared enrollment key gets 403 with instructions to heartbeat first --
  every worker holds that key, and this writes durable money data.

Re-sending a day updates it rather than appending, so a retried or
repeated import is safe by construction.

Refs: CashPilot-Desktop-xjr
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 730aa8fc-1287-4444-876b-75e465b296a6

📥 Commits

Reviewing files that changed from the base of the PR and between f938d6a and 7d6bc40.

📒 Files selected for processing (5)
  • app/database.py
  • app/main.py
  • docs/fleet.md
  • tests/test_beads_batch_63.py
  • tests/test_optional_runtime.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/fleet.md
  • app/main.py

📝 Walkthrough

Walkthrough

Added a worker-authenticated earnings import endpoint with validated dates, finite numeric fields, bounded batches, source isolation, catalog filtering, and idempotent upserts. Added safe validation-error serialization, transactional bulk storage, tests, documentation, and Docker image tag updates to version 1.15.

Changes

Worker earnings import

Layer / File(s) Summary
Validation error serialization
app/main.py, tests/test_beads_batch_63.py
Validation errors now serialize non-finite floats safely and remain descriptive HTTP 422 responses.
Validated earnings import flow
app/main.py, tests/test_beads_batch_63.py, docs/fleet.md
The endpoint validates authentication, client IDs, dates, finite numeric values, batch size, and catalog slugs. It performs source-aware imports and reports imported counts and skipped slugs.
Transactional bulk earnings storage
app/database.py, tests/test_beads_batch_63.py
Bulk imports use one transaction with idempotent upserts, FX-rate preservation, rollback handling, and connection recovery.
Release wiring and regression coverage
docker-compose.yml, docker-compose.fleet.yml, tests/test_beads_batch_63.py, tests/test_optional_runtime.py
Compose services now use image version 1.15. Tests cover the new import behavior and runtime call-count assertion.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: allowing paired clients to submit their own earnings history.
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.
✨ 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/earnings-import-endpoint

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 added 2 commits August 6, 2026 03:28
Two ways the new endpoint could be handed input it would store without
complaint.

The date was free text. Both delta readers ORDER BY it, so a reading
dated 2026-1-2 or 01/02/2026 sorts into the wrong place in its own
series and the readings either side then difference against the wrong
neighbour. It fails silently, only for the client that sent it, and only
in the earned figure -- never in the balance the dashboard shows. It is
now required to be YYYY-MM-DD and a real calendar day, so 2026-02-30 is
refused rather than stored.

The readings list was unbounded. One authenticated client could hand the
server an arbitrarily large body to parse and then write row by row; a
single compromised worker is enough. Capped at 2000, comfortably above
an honest import (the server keeps 400 days and a client chunks at
1000).

Validation runs before authentication, so a malformed body cannot be
used to probe which client ids exist.

Refs: CashPilot-Desktop-xjr
v1.15.0 released and the example compose files still pinned 1.14, so
anyone following the quickstart deployed a series behind. The pin test
caught it -- it exists because a stale pin is what gave issue #188 a
version with a first-run bug that had been fixed for months.

Unrelated to this branch's change, but it fails CI on every branch until
it is fixed.
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.50%. Comparing base (2bb3a52) to head (7d6bc40).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #256      +/-   ##
==========================================
+ Coverage   95.45%   95.50%   +0.05%     
==========================================
  Files          47       47              
  Lines        6202     6274      +72     
==========================================
+ Hits         5920     5992      +72     
  Misses        282      282              
Files with missing lines Coverage Δ
app/database.py 95.24% <100.00%> (+0.10%) ⬆️
app/main.py 97.81% <100.00%> (+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.

@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

Found by re-reading the endpoint rather than from a report.

JSON has no NaN or Infinity. Python's parser accepts them anyway, so
{"balance": NaN} was stored verbatim. One such reading poisons every
delta taken from that series -- NaN - x is NaN, and every comparison
against it is False, so the clamp silently misbehaves -- the account
total becomes NaN, and serialising that back out emits a bare NaN that
JSON.parse rejects. A single bad reading from one client breaks the
dashboard for everyone. Both float fields now refuse non-finite values.

That exposed a second problem underneath: FastAPI's 422 body echoes the
offending input, so the REJECTION could not be serialised either and the
client got a 500 for what is squarely a bad request. A
RequestValidationError handler now renders non-finite floats as their
names -- keeping the message diagnostic rather than dropping the field --
and fixes the whole class instead of the one endpoint that takes a float
today.

My first version of that handler broke every OTHER validation error: a
custom validator's error carries the raised ValueError OBJECT in ctx,
which is not serialisable, and skipping jsonable_encoder turned each one
into a 500. Caught by the date tests, and now pinned by its own
regression test.

Also: the skipped list is deduplicated. A client pushing 400 days of a
platform this server does not know got the same name back 400 times --
a response that grows with the request, echoing client-supplied strings,
and saying nothing the set does not.

A negative control showed the sanitiser's bool guard was dead (bool
subclasses int, not float), so it is gone along with the comment that
justified it incorrectly.

Refs: CashPilot-Desktop-xjr
@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

Self-review of this diff found two real defects, both now fixed in f938d6a:

A NaN balance was accepted and stored. JSON has no NaN or Infinity; Python's parser accepts them anyway. One such reading poisons every delta taken from that series — NaN - x is NaN, and every comparison against it is False, so the clamp misbehaves silently — the account total becomes NaN, and serialising that back out emits a bare NaN that JSON.parse rejects. One bad reading from one client breaks the dashboard for everyone. Confirmed by driving the real request before fixing it: STORED balance: nan fx: inf.

Rejecting one was then a 500. FastAPI's 422 body echoes the offending input, so the rejection itself could not be serialised. A RequestValidationError handler now renders non-finite floats as their names — diagnostic rather than dropping the field — which fixes the class, not just the one endpoint that takes a float today.

My first version of that handler broke every other validation error (a custom validator's error carries the raised ValueError object in ctx, so skipping jsonable_encoder turned each into a 500). The date tests caught it; it now has its own regression test.

Also deduplicated skipped: 400 days of one unrecognised platform returned the same name 400 times.

One negative control passed, which meant the code was wrong to have the guard rather than the test being wrong: the sanitiser's bool branch was dead (bool subclasses int, not float), so it is gone along with the comment that justified it incorrectly.

3607 tests pass, coverage 95.53%. Five new negative controls, each failing the test that claims to catch it.

@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 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.

@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: 3

🧹 Nitpick comments (2)
tests/test_beads_batch_63.py (2)

156-163: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pin the (blank) placeholder in the response.

The endpoint reports a blank slug as "(blank)" in skipped. This test asserts only imported == 0. A change that dropped blank slugs silently would still pass.

♻️ Proposed addition
         assert resp.json()["imported"] == 0
+        assert resp.json()["skipped"] == ["(blank)"]
         upsert.assert_not_awaited()
🤖 Prompt for 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.

In `@tests/test_beads_batch_63.py` around lines 156 - 163, Update
test_a_blank_slug_is_skipped to also assert that the response JSON contains
"(blank)" in its skipped results, while preserving the existing imported count
and upsert.assert_not_awaited checks.

462-473: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Read the import cap from EarningsImport instead of hardcoding 2000.

assert chunk <= 2000 tests two literals, so it passes even if EarningsImport.readings.max_length changes. Store the limit in a limit variable from EarningsImport.model_fields["readings"] metadata and assert chunk <= limit so the test fails when the model cap changes.

🤖 Prompt for 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.

In `@tests/test_beads_batch_63.py` around lines 462 - 473, The
test_a_real_sized_import_still_fits assertion hardcodes the import cap instead
of checking the EarningsImport model configuration. Read the limit from
EarningsImport.model_fields["readings"] metadata into a limit variable, then
assert chunk <= limit while preserving the existing catalog-size validation.
🤖 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 3861-3876: Add an upsert_earnings_bulk helper in the database
layer that accepts the filtered readings, opens one connection, reuses the
existing upsert SQL via executemany, commits once, and preserves connection
cleanup/error behavior from upsert_earnings. In the request handler’s
reading-processing flow, retain slug validation and skipped counting, collect
valid rows, then call upsert_earnings_bulk once and update written using the
batch result instead of invoking upsert_earnings per reading.

In `@docs/fleet.md`:
- Around line 44-52: Update the request documentation around the fleet payload
example to show the required Authorization header using the per-worker key, and
document that a blank or missing client_id produces a 400 response.

In `@tests/test_beads_batch_63.py`:
- Line 59: Replace the tuple-expression assertions at
tests/test_beads_batch_63.py lines 59-59 and 460-460 with explicit assertions on
upsert.await_count == 0, preserving the messages “a shared-key holder wrote
earnings” and “the server wrote rows from a body it should have refused”
respectively, so the failure messages are effective and Ruff B018 is satisfied.

---

Nitpick comments:
In `@tests/test_beads_batch_63.py`:
- Around line 156-163: Update test_a_blank_slug_is_skipped to also assert that
the response JSON contains "(blank)" in its skipped results, while preserving
the existing imported count and upsert.assert_not_awaited checks.
- Around line 462-473: The test_a_real_sized_import_still_fits assertion
hardcodes the import cap instead of checking the EarningsImport model
configuration. Read the limit from EarningsImport.model_fields["readings"]
metadata into a limit variable, then assert chunk <= limit while preserving the
existing catalog-size validation.
🪄 Autofix

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: c358c88c-317c-411f-9be0-f7f9361beabd

📥 Commits

Reviewing files that changed from the base of the PR and between 2bb3a52 and f938d6a.

📒 Files selected for processing (5)
  • app/main.py
  • docker-compose.fleet.yml
  • docker-compose.yml
  • docs/fleet.md
  • tests/test_beads_batch_63.py

Comment thread app/main.py Outdated
Comment thread docs/fleet.md Outdated
Comment thread tests/test_beads_batch_63.py Outdated
@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

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.

A thousand-reading import committed a thousand times. Every commit is an
fsync that takes SQLite's write lock, so one import serialised a thousand
disk syncs against this server's own collector and request latency
tracked sync cost rather than row count. upsert_earnings_many does the
same upsert with executemany and one commit.

Only half the reported cause was real, and the distinction matters for
anyone reading this later: _get_db hands out a borrowed handle on a
SHARED per-loop connection whose close() is a documented no-op, so the
loop was never opening and closing a thousand connections. It was
committing a thousand times.

Batching then introduced a bug of its own, which the tests caught: the
connection is shared and outlives the request, so a failed batch left an
abandoned transaction holding the write lock and the NEXT write blocked
for twelve seconds before timing out. It now rolls back.

Two more from the same review:

* docs/fleet.md gains the Authorization header and every status the
  endpoint can answer with. A reader integrating against that page alone
  could not previously construct a valid request.
* Two assertions were tuple expressions -- `mock.assert_not_awaited(),
  "why"` builds a tuple and discards the message, so a red build showed
  the mock's generic text instead of the reason. Fixed here and in
  test_optional_runtime.py.

The review said ruff's B018 catches that pattern when bugbear is enabled.
Bugbear IS enabled here and B018 is not ignored, and ruff 0.15.14 passes
it clean -- checked against a minimal probe rather than assumed. So
nothing in CI would have caught a recurrence, and there is now an
AST-based test that does. Structural, not a string search, because a
string search would match the pattern inside its own docstring.

One negative control PASSED, which meant the test was wrong: the
transactionality test raised while BUILDING the rows, before any SQL ran,
so it proved only that the row build validates first and it passed
against a writer that committed after every row. It now fails inside the
statement, and both it and the wedged-connection test fail under their
controls.

Reported by CodeRabbit on PR #256.
@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

All three addressed in 7d6bc40.

Batching — implemented as upsert_earnings_many (one executemany, one commit). One correction to the diagnosis, because it matters for whoever reads this next: _get_db hands out a borrowed handle on a shared per-loop connection whose close() is a documented no-op (app/database.py _BorrowedConnection), so the loop was never opening and closing a thousand connections. It was committing a thousand times — and that half is entirely real, since every commit is an fsync holding SQLite's write lock against this server's own collector.

Batching then introduced a bug of its own, which the new tests caught: the shared connection outlives the request, so a failed batch left an abandoned transaction holding the write lock and the next write blocked for twelve seconds before timing out. It now rolls back.

Docsdocs/fleet.md gains the Authorization header and a table of every status the endpoint answers with (200/400/401/403/422).

Tuple assertions — fixed at both sites, plus a third the diff didn't touch (test_optional_runtime.py:38).

One correction there too, and it changes what the right fix is. The finding says Ruff's B018 catches this when bugbear is enabled. Bugbear is already enabled in this repo (B is in select; B018 is not ignored) and ruff 0.15.14 passes the pattern clean — checked against a minimal probe rather than assumed:

m = Mock()
m.assert_not_called(), "this message is dead"

All checks passed!, exit 0.

So nothing in CI would have caught a recurrence. There is now an AST-based test that does — structural rather than a string search, because a string search would match the pattern inside its own docstring.

One negative control passed, which meant my test was wrong, not the code. The transactionality test raised while building the rows, before any SQL ran — so it proved only that the row build validates first, and it passed against a writer that committed after every row. It now fails inside the statement. Both it and the wedged-connection test fail under their controls.

3619 tests pass, coverage 95.55%.

@GeiserX

GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 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.

@GeiserX
GeiserX merged commit 55196e8 into main Aug 6, 2026
8 checks passed
@GeiserX
GeiserX deleted the feat/earnings-import-endpoint branch August 6, 2026 11:48
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