Skip to content
smartargs
EarnbackDocumentation

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:

  1. What did my players cost, per channel, campaign, video and country?
  2. Do they open the game, play and come back?
  3. How much have they earned back through ads and purchases, and how fast?
  4. 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: earnback syncs 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

  1. Sync. earnback sync loads the config and secrets and asks each enabled connector for a SyncBatch over its window: from the last synced day minus the connector’s refetchDays, 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.
  2. Snapshot. buildSnapshot reads 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.
  3. 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.html into the project folder, or portfolio.html in the Earnback home for several projects. The page has a CSP with connect-src 'none'.
  4. Open. The browser opens the file. Filters live in the URL hash, so views can be bookmarked.
  5. Digest. earnback digest syncs, builds the snapshot and hands it with the last sent digest state to buildDigest. runDigest delivers 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”. loud is 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 Notifier is { 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 schedule prints a cron line or a Windows Task Scheduler command (-StartWhenAvailable; through wsl.exe under WSL). The once-a-day rule makes catch-up runs harmless.

Web app

Five pages:

  1. Overview: running tests on top, KPI tiles, spend vs revenue chart, channel table.
  2. Campaigns: campaigns, placements, creatives.
  3. Players: funnel per channel, new players, retention triangle.
  4. Revenue: ad vs purchase revenue, payback per channel, countries, formats.
  5. 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.