Earnback architecture
Consolidated on 2026-11-05 from a first plan and two independent reviews (Fable, Opus); projects, digests and notifiers added the same day after a second review round.
Product
Earnback answers four questions for studios of 1–20 people that buy a few hundred to a few thousand euros of ads a month, across one or several games:
- What did my players cost, per channel, campaign, video and country?
- Do they open the game, play and come back?
- How much have they earned back through ads and purchases, and how fast?
- Is the running test on track: budget, pass marks, next review?
Principles:
- Local-first. Data stays on the machine: no account, no telemetry, no server. The only thing that leaves it is the optional daily digest, to a channel the user connects.
- Read-only. It never changes a campaign.
- Honest numbers. Every metric knows its source, its dimensions and its caveats.
- One command:
earnbacksyncs and opens the report.
Shape
One npm package (earnback) with zero runtime dependencies. Requires Node ≥ 22.13 for node:sqlite.
src/shared/ browser-safe, pure: domain records, channels, dates, metrics, plans, snapshot format
digest/ buildDigest (snapshot + last sent state → digest) and its text, HTML and Markdown formats
src/node/ CLI side
config/ earnback.config.json: types, validation, loading, init template, section helpers
secrets/ SecretStore: env var → config path → OS config folder
storage/ Store interface + SQLite implementation with additive migrations (data, sync state, digest state)
http/ HttpClient: JSON fetch, retries with backoff, error translation
auth/ Google (service account JWT, desktop OAuth loopback + PKCE), loopback helper
connectors/ Connector interface, registry, one folder per source
sync/ sync engine: windows, app resolution, foreign-app filtering, isolation of failures
snapshot/ builds the columnar snapshot from the store
report/ injects one or more snapshots into the built web template
workspace/ workspace.json: registered projects, the default one, notifier settings; project selection
notify/ Notifier interface, registry, Telegram and webhook (Slack, Discord, ntfy, JSON)
digest/ runDigest: build against the last sent state, deliver, save the state on success
demo/ deterministic synthetic studio
cli/ parseArgs entry and one file per command
web/ Vite + Preact + signals + Tailwind v4 + Chart.js → one self-contained index.html
docs/ connector setup guides, notifications, metric definitions
Dependency direction: cli → (sync | report | digest | demo | doctor) → (connectors | notify | storage | snapshot | workspace) → shared. web → shared. Connectors never import each other; notifiers never import connectors or the CLI. src/architecture.test.ts enforces this.
Data flow
- Sync.
earnback syncloads the config and secrets and asks each enabled connector for aSyncBatchover its window: from the last synced day minus the connector’srefetchDays, or the whole range with--full. Records carry an app key (package name, GA4 property, AdMob app id); the engine resolves it to a configured app and drops rows of apps the project doesn’t list (FOREIGN_APP), with a caveat naming the unknown keys. The store replaces that source’s rows inside the window, so rows outside it are never touched. A failing source keeps its old rows and records the error. Each source reports through which day its data is complete. - Snapshot.
buildSnapshotreads the store and maps GA4 source/medium to channels with the configured rules. Unmapped sources are listed, not hidden. It encodes every table column-wise with dictionary-coded strings, and adds sync status, caveats, plans and apps. - Report.
dist/web/index.html(built once) carries<script id="earnback-data" type="application/json">__EARNBACK_DATA__</script>. The report writer replaces exactly that element’s content with an array of snapshots (escaped for a script context) and writes.earnback/report.htmlinto the project folder, orportfolio.htmlin the Earnback home for several projects. The page has a CSP withconnect-src 'none'. - Open. The browser opens the file. Filters live in the URL hash, so views can be bookmarked.
- Digest.
earnback digestsyncs, builds the snapshot and hands it with the last sent digest state tobuildDigest.runDigestdelivers the result to every enabled notifier and saves the new state only when at least one accepted it.
Projects and workspace
A project is a folder with earnback.config.json; its data lives in <project>/.earnback/. The workspace (workspace.json in the Earnback home: EARNBACK_HOME, %APPDATA%\earnback or ~/.config/earnback) lists the registered project folders, the default one and the notifier settings, never secrets. Project secrets live in <home>/<project>/, notifier secrets in <home>/_workspace/ (hence _workspace is not a valid project name).
selectRoots picks the projects a command runs on: --all (every registered one), --project <name|path>, the project containing the current folder, else the default. forEachRoot runs a command per project and isolates failures, so one broken project doesn’t stop the others.
Data model (tables)
| Table | Grain | From |
|---|---|---|
| campaigns | source, campaign | spend connectors |
| spend | source, date, campaign, country (‘’ = not broken down) | spend connectors, CSV |
| creatives | source, date, campaign, creative | spend connectors |
| placements | source, date, campaign, placement | spend connectors |
| funnel | app, cohort date, source key, country, step | GA4 |
| activity | app, cohort date, source key, country, date | GA4 |
| cohort_revenue | app, cohort date, source key, country, date | GA4 (ad revenue + purchases) |
| ad_revenue | app, date, country, format | AdMob |
| sync_state | source: last run, ok, message, synced until, complete until, time zone, caveats | sync engine |
| digest_state | the last delivered digest: day, alert keys, mark verdicts | digest runner |
Plans (tests with budget limits, pass marks and review dates) and channel rules are configuration, not synced data. Migrations are append-only.
Metrics and filters
- Filters are period, app, channel, campaign, country and granularity (day/week).
- Each dataset declares which filters it can honour. AdMob has no channel or campaign; spend without a country breakdown can’t honour a country filter.
- A measure carries the filters it could not honour. A ratio of two measures is shown only when neither side has unhonoured filters; otherwise the UI shows “–” with the reason.
- Cost per paid player is a range: spend ÷ all new players (floor) to spend ÷ players credited to paid channels (ceiling). With no paid players yet, the floor is shown as “at least”.
- Players exist only from the analytics’ first day with players (
playersSince). The Overview flags spend before that day; the digest leaves it out of cost per player and tracking alerts. - Retention and payback are cohort-based: players grouped by first-open day, revenue summed by days since first open.
Definitions for users: docs/metrics.md.
Connectors
| Id | Provides | Auth | Status |
|---|---|---|---|
| google-ads | campaigns, spend, creatives, placements | service account or desktop OAuth (scope adwords); add as read-only user | v0.1 |
| meta | campaigns, spend (country), creatives, placements | system-user or long-lived token, ads_read |
v0.1 |
| tiktok | campaigns, spend (country), creatives | own TikTok developer app; https redirect, the user pastes the landing URL; long-term token | v0.1, untested against a live account |
| ga4 | funnel, activity, cohort_revenue | service account or desktop OAuth (analytics.readonly) | v0.1 |
| admob | ad_revenue | desktop OAuth only (admob.readonly); consent screen “In production” | v0.1 |
| csv | spend (and campaigns) from files | none | v0.1 |
| google-play, app-store-connect, apple-search-ads, unity/applovin, revenuecat | later |
A connector is { id, label, provides, refetchDays, docs, parseConfig, check, sync, login?, connect? } (src/node/connectors/types.ts). It receives a ConnectorContext (its parsed settings, secrets, HTTP, app resolver, funnel events, project root, log) and returns a SyncBatch.
Digest and notifiers
buildDigest(snapshot, previous)is pure. Alerts carry stable keys (budget:…,mark:…,delivery:…,tracking,sync:…,stale:…), so a later digest marks them new or “still”.loudis true only when an alert is new or a mark verdict got worse; notifiers send silently otherwise.- Only complete days are reported. The footer says through which day each source is complete.
- A
Notifieris{ id, label, docs, parseConfig, check, send, setup? }(src/node/notify/types.ts). It is configured once per person in the workspace, not per project. Telegram uses the Bot API in HTML mode, split at 4,096 characters; webhooks post Slack mrkdwn, Discord content (mentions disabled), ntfy text with a priority, or JSON. - Scheduling is the operating system’s job:
earnback scheduleprints a cron line or a Windows Task Scheduler command (-StartWhenAvailable; throughwsl.exeunder WSL). The once-a-day rule makes catch-up runs harmless.
Web app
Five pages:
- Overview: running tests on top, KPI tiles, spend vs revenue chart, channel table.
- Campaigns: campaigns, placements, creatives.
- Players: funnel per channel, new players, retention triangle.
- Revenue: ad vs purchase revenue, payback per channel, countries, formats.
- Portfolio (several projects only): every project’s spend, players, revenue and alerts side by side, and a project switcher.
Sources and definitions sit in a panel. Every chart has a table view. Light and dark themes come from validated colour tokens.
CLI
| Command | Does |
|---|---|
earnback |
sync, then open |
earnback init [name] |
writes the config, ignores .earnback/, registers the project |
earnback projects [add|remove|default] |
lists or manages registered projects |
earnback connect <google|tiktok> |
guided auth for a source, plus a test call |
earnback connect <telegram|webhook> |
guided setup for a digest channel, plus a test call |
earnback sync [--full] [--source id] |
runs the sync |
earnback open [--no-sync] [--no-open] |
builds (and opens) the report |
earnback digest [--send] [--force] |
previews or delivers today’s digest |
earnback doctor |
checks config, secrets and every connection |
earnback check |
evaluates plans; exits 1 when a budget limit or stop mark is crossed |
earnback schedule [--time HH:MM] |
prints how to send the digest daily |
earnback demo |
synthetic studio |
-p/--project and --all select projects for sync, open, digest and check.
Later
serve with live reload, Google Play report files, App Store Connect, Apple Search Ads, ad-network revenue connectors, currency conversion (v0.1 flags a source whose currency differs from the config’s), custom GA4 panels, LTV forecasts, a write mode in a separate opt-in package.