Revenue Bar
Stripe + Paddle net revenue on your bar. Day, week, month and year to date with trend sparklines and same-point deltas.
How to run
This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:
python app.py
Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP
with --host <address>.
No bar at hand? Run the
BUSY Bar Emulator
and point the app at it: python app.py --host 127.0.0.1:8080.
Source code
app.py
#!/usr/bin/env python3
"""Live Stripe + Paddle net revenue on your BUSY Bar: day, week, month and year to date with deltas and sparklines.
python3 app.py # BUSY Bar over USB (always 10.0.4.20)
python3 app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python3 app.py --test # fake data, no API keys needed
python3 app.py --selftest # offline checks of the math, exit 0/1
Environment variables (shell or a .env file next to app.py, shell wins):
STRIPE_API_KEY restricted read-only key (Balance transaction read access)
PADDLE_API_KEY Paddle API key
PADDLE_ENV live (default) or sandbox
WEEK_START mon (default) or sun
CURRENCY usd (default), gbp, eur, ... transactions in any other
currency are skipped. Non-usd hides the $ prefix, since
the device fonts cannot render pound or euro signs.
Either provider key alone is fine. Revenue definition: charges minus refunds,
excluding tax Paddle collects, before provider fees. One currency only.
"""
import argparse
import datetime as dt
import json
import os
import signal
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
APP = "revenue-bar"
W, H = 72, 16
PRIORITY = 30
COLORS = {
"label": "#6E7C93FF",
"value": "#FFD24AFF",
"up": "#3FD35AFF",
"down": "#FF5A4EFF",
"spark": "#2EA84CFF",
"spark_hot": "#5DFF7EFF",
"stale": "#8A8A8AFF",
"spark_stale": "#103B1BFF",
}
SCREENS = ["day", "week", "month", "year"]
LABELS = {"day": "DAY", "week": "WK", "month": "MO", "year": "YR"}
def _load_dotenv():
"""Populate os.environ from a .env next to this script. Shell values win."""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
k, v = k.strip(), v.strip()
if k and k not in os.environ:
os.environ[k] = v
except OSError:
pass
def env(name, default=None):
return os.environ.get(name, default)
def parse_args(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--host", default="10.0.4.20", help="bar ip[:port], emulator: 127.0.0.1:8080")
p.add_argument("--interval", type=int, default=300, help="seconds between revenue API polls")
p.add_argument("--dwell", type=int, default=5, help="seconds per screen in the rotation")
p.add_argument("--test", action="store_true", help="run with deterministic fake data, no keys")
p.add_argument("--selftest", action="store_true", help="run offline math checks and exit")
return p.parse_args(argv)
def cur_code():
return env("CURRENCY", "usd").lower()
VALUE_MAX_CHARS = 8
def money_str(cents, max_chars=VALUE_MAX_CHARS):
"""Money string within max_chars. The full comma-formatted number is
preferred, K/M contractions only when it will not fit. USD gets a $
prefix, other currencies get no symbol (device fonts are ASCII, so
pound and euro signs cannot render). Minus rides on top of the budget."""
sym = "$" if cur_code() == "usd" else ""
sign = "-" if cents < 0 else ""
d = abs(cents) / 100
forms = [f"{d:,.0f}"]
if 1_000 <= d < 1_000_000:
forms += [f"{d / 1e3:.1f}K", f"{d / 1e3:.0f}K"]
elif d >= 1_000_000:
forms += [f"{d / 1e6:.2f}M", f"{d / 1e6:.1f}M", f"{d / 1e6:.0f}M"]
for f in forms:
if len(sym + f) <= max_chars:
return sign + sym + f
return sign + sym + forms[-1]
def day_key(d):
return d.strftime("%Y-%m-%d")
def sum_range(buckets, start, end, providers=None):
"""Inclusive sum of day buckets, optionally restricted to providers."""
total = 0
d = start
while d <= end:
day = buckets.get(day_key(d))
if day:
for prov, cents in day.items():
if providers is None or prov in providers:
total += cents
d += dt.timedelta(days=1)
return total
def range_start(rk, ref, week_start):
if rk == "day":
return ref
if rk == "week":
wd = ref.weekday() if week_start == "mon" else (ref.weekday() + 1) % 7
return ref - dt.timedelta(days=wd)
if rk == "month":
return ref.replace(day=1)
return ref.replace(month=1, day=1)
def add_months(d, n):
m = d.month - 1 + n
y = d.year + m // 12
m = m % 12 + 1
last = [31, 29 if y % 4 == 0 and (y % 100 != 0 or y % 400 == 0) else 28,
31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]
return dt.date(y, m, min(d.day, last))
def prev_anchor(rk, now):
if rk == "day":
return now - dt.timedelta(days=1)
if rk == "week":
return now - dt.timedelta(days=7)
if rk == "month":
d = add_months(now.date(), -1)
else:
d = add_months(now.date(), -12)
return dt.datetime.combine(d, now.time())
def to_date(buckets, rk, now, week_start, providers=None):
return sum_range(buckets, range_start(rk, now.date(), week_start), now.date(), providers)
def prev_same_point(buckets, rk, now, week_start, providers=None):
# ponytail: the previous period's trailing day is scaled by fraction of day
# elapsed now, assuming revenue was uniform across that day.
anchor = prev_anchor(rk, now)
start = range_start(rk, anchor.date(), week_start)
full = 0
if anchor.date() > start:
full = sum_range(buckets, start, anchor.date() - dt.timedelta(days=1), providers)
frac = (now - dt.datetime.combine(now.date(), dt.time())).total_seconds() / 86400
partial = sum_range(buckets, anchor.date(), anchor.date(), providers) * frac
return full + int(partial)
def delta_pct(cur, prev):
if prev <= 0:
return None
return max(-999, min(999, round((cur / prev - 1) * 100)))
def spark_series(buckets, rk, now, week_start):
today = now.date()
if rk == "day":
days = [today - dt.timedelta(days=i) for i in range(12, -1, -1)]
return [sum_range(buckets, d, d) for d in days]
if rk == "week":
w0 = range_start("week", today, week_start)
out = []
for i in range(12, -1, -1):
ws = w0 - dt.timedelta(days=7 * i)
out.append(sum_range(buckets, ws, min(ws + dt.timedelta(days=6), today)))
return out
if rk == "month":
m0 = today.replace(day=1)
out = []
for i in range(12, -1, -1):
ms = add_months(m0, -i)
me = add_months(ms, 1) - dt.timedelta(days=1)
out.append(sum_range(buckets, ms, min(me, today)))
return out
out = []
for m in range(1, today.month + 1):
ms = dt.date(today.year, m, 1)
me = add_months(ms, 1) - dt.timedelta(days=1)
out.append(sum_range(buckets, ms, min(me, today)))
return out
LAYOUT = {
"label_xy": (1, 1),
"delta_x": 24,
"value_x": 1,
# ponytail: "normal" clears the label band on 24.3.0 firmware where
# "large" is taller than the free height. Bump back if firmware fonts
# change, the bottom anchor keeps whatever font from clipping offscreen.
"value_font": "normal",
"label_font": "tiny",
"spark_right": 71,
"spark_pitch": 2,
# Firmware text bottom-anchors include ~2 rows of descender space that
# digits never use, so the glyph image must be lifted to match visually.
"glyph_lift": 2,
}
def build_model(buckets, now, week_start):
model = {}
for rk in SCREENS:
cur = to_date(buckets, rk, now, week_start)
model[rk] = {
"value": cur,
"delta": delta_pct(cur, prev_same_point(buckets, rk, now, week_start)),
"spark": spark_series(buckets, rk, now, week_start),
}
return model
def _text(eid, text, font, color, x, y, dwell, align=None):
el = {"id": eid, "type": "text", "text": text, "font": font, "color": color,
"x": x, "y": y, "display": "front", "timeout": dwell * 3}
if align:
el["align"] = align
return el
def _spark_rects(series, stale, dwell):
"""Sparkline as 1px-wide solid rectangles, right-aligned, bottom-anchored."""
mx = max(series) if series and max(series) > 0 else 0
# Short series (YEAR: up to 12 monthly bars) get wider bars to fill the region.
bw, pitch = (2, 3) if len(series) <= 8 else (1, LAYOUT["spark_pitch"])
x0 = LAYOUT["spark_right"] - (bw - 1) - pitch * (len(series) - 1)
# 1 px baseline "axis" under the bars, shown only while data is sparse
# (2 or fewer lit bars), so a lone bar reads as a chart, not a glitch.
# Bars always reserve the bottom row, so geometry is stable either way.
rects = []
if sum(1 for v in series if v > 0) <= 2:
rects.append({"id": "b", "type": "rectangle",
"x": x0, "y": H - 1,
"width": LAYOUT["spark_right"] - x0 + 1, "height": 1,
"fill": "solid",
"fill_colors": [COLORS["spark_stale"] if stale else BASELINE_COLOR],
"border_width": 0, "display": "front", "timeout": dwell * 3})
for i, v in enumerate(series):
if mx <= 0 or v <= 0:
continue
h = max(1, round(v / mx * SPARK_MAX))
hot = i == len(series) - 1
color = COLORS["spark_stale"] if stale else (
COLORS["spark_hot"] if hot else COLORS["spark"])
rects.append({"id": f"s{i}", "type": "rectangle",
"x": x0 + pitch * i, "y": H - 1 - h,
"width": bw, "height": h, "fill": "solid",
"fill_colors": [color], "border_width": 0,
"display": "front", "timeout": dwell * 3})
return rects
TRANSPARENT = "#00000000"
GLYPH = False # set at startup when a currency glyph asset was uploaded
GLYPH_W = 7 # 5 px glyph plus 2 px gap before the number
SPARK_MAX = 12 # bar height cap, keeps the graph clear of the delta row
BASELINE_COLOR = "#2A2F36FF"
POUND = [
"..XX.",
".X..X",
".X...",
"XXXX.",
".X...",
".X...",
"XXXXX",
]
def _png(w, h, rgba_rows):
"""Minimal RGBA PNG encoder, stdlib only."""
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data)))
raw = b"".join(b"\x00" + b"".join(bytes(px) for px in row) for row in rgba_rows)
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw))
+ chunk(b"IEND", b""))
def pound_png():
amber = (255, 210, 74, 255)
clear = (0, 0, 0, 0)
return _png(5, 7, [[amber if c == "X" else clear for c in row] for row in POUND])
def _park_text(eid, dwell):
return _text(eid, "x", "tiny", TRANSPARENT, 0, 0, dwell)
def _park_rect(eid, dwell):
return {"id": eid, "type": "rectangle", "x": 0, "y": 0, "width": 1,
"height": 1, "fill": "solid", "fill_colors": [TRANSPARENT],
"border_width": 0, "display": "front", "timeout": dwell * 3}
def screen_elements(rk, model, stale, dwell):
"""Every screen updates the same fixed id set (0-2 text, s0-s12 rects).
The firmware persists elements per id, so disjoint id sets would ghost
into each other, and delete-then-draw flashes the built-in UI through
the gap. Ids a screen does not use are parked fully transparent."""
els = {eid: _park_text(eid, dwell) for eid in "012"}
for i in range(13):
els[f"s{i}"] = _park_rect(f"s{i}", dwell)
els["b"] = _park_rect("b", dwell)
c_value = COLORS["stale"] if stale else COLORS["value"]
c_label = COLORS["stale"] if stale else COLORS["label"]
m = model[rk]
els["0"] = _text("0", LABELS[rk], LAYOUT["label_font"], c_label,
*LAYOUT["label_xy"], dwell)
if m["delta"] is not None:
c = COLORS["stale"] if stale else (
COLORS["up"] if m["delta"] >= 0 else COLORS["down"])
els["1"] = _text("1", f"{m['delta']:+d}%", "tiny", c,
LAYOUT["delta_x"], LAYOUT["label_xy"][1], dwell)
value_x = LAYOUT["value_x"] + (GLYPH_W if GLYPH else 0)
if GLYPH:
els["c"] = {"id": "c", "type": "image", "path": "pound.png",
"x": LAYOUT["value_x"], "y": H - LAYOUT["glyph_lift"],
"align": "bottom_left", "opacity": 35 if stale else 100,
"display": "front", "timeout": dwell * 3}
# Bottom-anchored so the tallest firmware font can never clip below
# the matrix, whatever its actual pixel height is.
els["2"] = _text("2", money_str(m["value"]), LAYOUT["value_font"],
c_value, value_x, H, dwell, "bottom_left")
for r in _spark_rects(m["spark"], stale, dwell):
els[r["id"]] = r
return list(els.values())
BASE = "http://10.0.4.20"
def set_host(host):
global BASE
BASE = "http://" + host.replace("http://", "").rstrip("/")
def api(method, path, body=None, raw=None, ctype="application/octet-stream"):
data, headers = None, {}
if raw is not None:
data, headers["Content-Type"] = raw, ctype
elif body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=5) as r:
out = r.read()
return json.loads(out) if out else None
def upload_asset(name, data):
q = urllib.parse.urlencode({"application_name": APP, "file": name})
api("POST", "/api/assets/upload?" + q, raw=data)
def draw(elements):
try:
api("POST", "/api/display/draw",
{"application_name": APP, "priority": PRIORITY, "elements": elements})
except urllib.error.HTTPError as e:
if e.code != 409: # 409 means a focus session owns the display
raise
def clear():
try:
api("DELETE", f"/api/display/draw?application_name={APP}")
except OSError:
pass
def fake_buckets(today):
"""500 days of plausible growing revenue. Deterministic via crc32."""
out = {}
for i in range(500):
d = today - dt.timedelta(days=499 - i)
k = day_key(d)
base = 60000 + zlib.crc32(k.encode()) % 90000
if d.weekday() >= 5:
base //= 2
base = int(base * (1 + i / 400))
out[k] = {"stripe": int(base * 0.66), "paddle": base - int(base * 0.66)}
return out
def run_loop(args, buckets, sync_fn):
week_start = env("WEEK_START", "mon")
stale_polls = 0
next_fetch = 0.0
idx = 0
outage = False
while True:
if sync_fn and time.monotonic() >= next_fetch:
stale_polls = sync_fn(buckets, stale_polls)
next_fetch = time.monotonic() + args.interval
model = build_model(buckets, dt.datetime.now(), week_start)
try:
draw(screen_elements(SCREENS[idx], model, stale_polls >= 3, args.dwell))
outage = False
except OSError as e:
if not outage:
print(f"[{dt.datetime.now():%H:%M:%S}] bar unreachable: {e}", file=sys.stderr)
outage = True
idx = (idx + 1) % len(SCREENS)
time.sleep(args.dwell)
STRIPE_TYPES = {"charge", "payment", "refund", "payment_refund"}
_warned_currency = False
def stripe_apply(events, buckets):
"""Fold balance transactions into day buckets. Refund amounts arrive negative."""
global _warned_currency
for ev in events:
if ev.get("type") not in STRIPE_TYPES:
continue
if ev.get("currency") != cur_code():
if not _warned_currency:
print(f"warning: skipping non-{cur_code()} transactions "
f"({ev.get('currency')})", file=sys.stderr)
_warned_currency = True
continue
k = day_key(dt.datetime.fromtimestamp(ev["created"]).date())
day = buckets.setdefault(k, {})
day["stripe"] = day.get("stripe", 0) + ev["amount"]
def _stripe_get(key, params):
url = "https://api.stripe.com/v1/balance_transactions?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def stripe_fetch(key, since_epoch):
events, params = [], {"limit": 100, "created[gte]": since_epoch}
while True:
page = _stripe_get(key, params)
events.extend(page["data"])
if not page.get("has_more"):
return events
params["starting_after"] = page["data"][-1]["id"]
def stripe_validate(key):
_stripe_get(key, {"limit": 1})
PADDLE_WINDOW_DAYS = 35
def paddle_base():
return ("https://sandbox-api.paddle.com" if env("PADDLE_ENV", "live") == "sandbox"
else "https://api.paddle.com")
def _paddle_date(iso):
d = dt.datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")
d = d.replace(tzinfo=dt.timezone.utc).astimezone()
return d.date()
def paddle_apply(txns, adjs, buckets, window_start):
"""Idempotently rebuild the paddle side of all buckets from window_start on."""
for k, day in list(buckets.items()):
if k >= day_key(window_start):
day.pop("paddle", None)
if not day:
del buckets[k]
global _warned_currency
for t in txns:
if t.get("currency_code") != cur_code().upper():
if not _warned_currency:
print(f"warning: skipping non-{cur_code()} transactions "
f"({t.get('currency_code')})", file=sys.stderr)
_warned_currency = True
continue
k = day_key(_paddle_date(t["billed_at"]))
day = buckets.setdefault(k, {})
day["paddle"] = day.get("paddle", 0) + int(t["details"]["totals"]["subtotal"])
for a in adjs:
if a.get("action") not in ("refund", "partial_refund", "chargeback"):
continue
if a.get("status") != "approved":
continue
k = day_key(_paddle_date(a["created_at"]))
day = buckets.setdefault(k, {})
day["paddle"] = day.get("paddle", 0) - int(a["totals"]["subtotal"])
def _paddle_get(key, url):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def _paddle_pages(key, url):
items = []
while url:
page = _paddle_get(key, url)
items.extend(page.get("data", []))
pg = page.get("meta", {}).get("pagination", {})
url = pg.get("next") if pg.get("has_more") else None
return items
_warned_adjustments = False
def paddle_fetch(key, base_url, since_iso):
q = urllib.parse.urlencode({"status": "completed", "billed_at[GTE]": since_iso,
"per_page": 200})
txns = _paddle_pages(key, f"{base_url}/transactions?{q}")
global _warned_adjustments
try:
adjs = _paddle_pages(key, f"{base_url}/adjustments?per_page=200")
except urllib.error.HTTPError as e:
# A key scoped to transactions only still works, minus refund tracking.
if e.code != 403:
raise
if not _warned_adjustments:
print("warning: paddle key lacks adjustment read permission, "
"refunds will not be subtracted", file=sys.stderr)
_warned_adjustments = True
adjs = []
return txns, adjs
def paddle_validate(key):
_paddle_get(key, f"{paddle_base()}/transactions?per_page=1")
CACHE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache.json")
def backfill_start(today):
return dt.date(today.year - 1, 1, 1)
def load_cache():
try:
with open(CACHE_PATH) as f:
c = json.load(f)
return {"buckets": c.get("buckets", {}),
"stripe_last_sync": c.get("stripe_last_sync"),
"paddle_backfilled": c.get("paddle_backfilled", False)}
except (OSError, ValueError):
return {"buckets": {}, "stripe_last_sync": None, "paddle_backfilled": False}
def save_cache(cache):
tmp = CACHE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cache, f)
os.replace(tmp, CACHE_PATH)
def make_sync(cache, providers):
stripe_key = env("STRIPE_API_KEY")
paddle_key = env("PADDLE_API_KEY")
def sync(buckets, stale_polls):
ok = False
now = dt.datetime.now()
if "stripe" in providers:
try:
last = cache["stripe_last_sync"]
if last is None:
since = int(dt.datetime.combine(
backfill_start(now.date()), dt.time()).timestamp())
else:
yesterday = dt.datetime.combine(
now.date() - dt.timedelta(days=1), dt.time())
since = min(last, int(yesterday.timestamp()))
# Idempotence: clear the stripe side of every bucket the refetch
# covers, so overlap and multi-day gaps never double-count.
cut = day_key(dt.datetime.fromtimestamp(since).date())
for k in list(buckets):
if k >= cut:
buckets[k].pop("stripe", None)
if not buckets[k]:
del buckets[k]
stripe_apply(stripe_fetch(stripe_key, since), buckets)
cache["stripe_last_sync"] = int(now.timestamp()) - 300
ok = True
except OSError as e:
print(f"[{now:%H:%M:%S}] stripe poll failed: {e}", file=sys.stderr)
if "paddle" in providers:
try:
wstart = (now.date() - dt.timedelta(days=PADDLE_WINDOW_DAYS)
if cache.get("paddle_backfilled")
else backfill_start(now.date()))
since_iso = wstart.strftime("%Y-%m-%dT00:00:00Z")
txns, adjs = paddle_fetch(paddle_key, paddle_base(), since_iso)
paddle_apply(txns, adjs, buckets, wstart)
cache["paddle_backfilled"] = True
ok = True
except OSError as e:
print(f"[{now:%H:%M:%S}] paddle poll failed: {e}", file=sys.stderr)
cache["buckets"] = buckets
save_cache(cache)
return 0 if ok else stale_polls + 1
return sync
def selftest():
checks = 0
# Task 1 sanity
assert parse_args([]).host == "10.0.4.20"
assert parse_args(["--host", "127.0.0.1:8080"]).host == "127.0.0.1:8080"
checks += 2
# Task 2: money_str. Full numbers preferred, contraction only past budget.
assert money_str(128400) == "$1,284"
assert money_str(999900) == "$9,999"
assert money_str(1000000) == "$10,000"
assert money_str(3197000) == "$31,970"
assert money_str(21400000) == "$214,000"
assert money_str(99995000) == "$999,950"
assert money_str(128000000) == "$1.28M"
assert money_str(0) == "$0"
assert money_str(-42000) == "-$420"
assert money_str(3197000, 6) == "$32.0K" # split rows keep the tight budget
assert money_str(1000000, 6) == "$10.0K"
assert len(money_str(999999999999)) <= 9 # sign never pushes past budget
# Task 2: sum_range
bk = {
"2026-07-25": {"stripe": 100, "paddle": 10},
"2026-07-26": {"stripe": 200},
"2026-07-27": {"paddle": 40},
}
d = dt.date
assert sum_range(bk, d(2026, 7, 25), d(2026, 7, 27)) == 350
assert sum_range(bk, d(2026, 7, 26), d(2026, 7, 26)) == 200
assert sum_range(bk, d(2026, 7, 25), d(2026, 7, 27), providers=["paddle"]) == 50
assert sum_range(bk, d(2026, 7, 1), d(2026, 7, 24)) == 0
checks += 14
# Currency config: non-usd drops the $ prefix (device fonts are ASCII only)
os.environ["CURRENCY"] = "gbp"
assert money_str(128400) == "1,284"
assert money_str(3197000) == "31,970"
gb = {}
stripe_apply([{"type": "charge", "amount": 500, "currency": "usd", "created": 1784500000},
{"type": "charge", "amount": 700, "currency": "gbp", "created": 1784500000}], gb)
assert list(gb.values()) == [{"stripe": 700}]
del os.environ["CURRENCY"]
assert money_str(128400) == "$1,284"
checks += 4
# Task 3: period math. Fixed clock: Wednesday 2026-07-15 15:00 local.
now = dt.datetime(2026, 7, 15, 15, 0, 0)
assert range_start("day", now.date(), "mon") == dt.date(2026, 7, 15)
assert range_start("week", now.date(), "mon") == dt.date(2026, 7, 13)
assert range_start("week", now.date(), "sun") == dt.date(2026, 7, 12)
assert range_start("month", now.date(), "mon") == dt.date(2026, 7, 1)
assert range_start("year", now.date(), "mon") == dt.date(2026, 1, 1)
assert add_months(dt.date(2026, 7, 31), -1) == dt.date(2026, 6, 30)
assert add_months(dt.date(2026, 1, 15), -1) == dt.date(2025, 12, 15)
assert prev_anchor("day", now) == dt.datetime(2026, 7, 14, 15, 0, 0)
assert prev_anchor("week", now) == dt.datetime(2026, 7, 8, 15, 0, 0)
assert prev_anchor("month", dt.datetime(2026, 7, 31, 9, 0)) == dt.datetime(2026, 6, 30, 9, 0)
assert prev_anchor("year", dt.datetime(2028, 2, 29, 9, 0)) == dt.datetime(2027, 2, 28, 9, 0)
# Buckets: $100/day for stripe on every day of 2025 and 2026 up to now.
flat = {}
dd = dt.date(2025, 1, 1)
while dd <= now.date():
flat[day_key(dd)] = {"stripe": 10000}
dd += dt.timedelta(days=1)
# DAY: today-so-far 100 vs yesterday scaled 100 * 15/24 = 62.5, delta +60%
assert to_date(flat, "day", now, "mon") == 10000
assert prev_same_point(flat, "day", now, "mon") == 6250
assert delta_pct(10000, 6250) == 60
# WEEK: Mon+Tue full + Wed-so-far = 300 vs 200 + 62.5
assert to_date(flat, "week", now, "mon") == 30000
assert prev_same_point(flat, "week", now, "mon") == 26250
# MONTH: 15 days vs 14 full + partial
assert to_date(flat, "month", now, "mon") == 150000
assert prev_same_point(flat, "month", now, "mon") == 146250
# YEAR to date vs same point last year
assert to_date(flat, "year", now, "mon") == 1960000
assert prev_same_point(flat, "year", now, "mon") == 1956250
# delta edge cases
assert delta_pct(5000, 0) is None
assert delta_pct(500000, 100) == 999
assert delta_pct(0, 10000) == -100
# sparklines
s = spark_series(flat, "day", now, "mon")
assert len(s) == 13 and s[-1] == 10000 and s[0] == 10000
s = spark_series(flat, "week", now, "mon")
assert len(s) == 13 and s[-1] == 30000 and s[-2] == 70000
s = spark_series(flat, "month", now, "mon")
assert len(s) == 13 and s[-1] == 150000 and s[-2] == 300000
s = spark_series(flat, "year", now, "mon")
assert len(s) == 7 and s[0] == 310000 and s[-1] == 150000
checks += 26
# Task 4: renderer, reusing `flat` buckets and `now` from above.
model = build_model(flat, now, "mon")
assert model["day"]["value"] == 10000
assert model["week"]["delta"] == 14
els = screen_elements("day", model, False, 5)
texts = [e for e in els if e["type"] == "text"]
rects = [e for e in els if e["type"] == "rectangle"]
assert [t["text"] for t in texts[:2]] == ["DAY", "+60%"]
assert any(t["text"] == "$100" for t in texts)
bars = [r for r in rects if r["id"] != "b"]
base = next(r for r in rects if r["id"] == "b")
assert len(bars) == 13 and all(r["fill"] == "solid" for r in rects)
assert base["fill_colors"] == [TRANSPARENT] # dense data hides the axis
assert all(0 <= e["x"] <= 71 and 0 <= e["y"] <= 16 for e in els)
assert all(e.get("display") == "front" and e.get("timeout") == 15 for e in els)
assert bars[-1]["fill_colors"] == [COLORS["spark_hot"]]
assert all(r["y"] + r["height"] <= 16 for r in rects)
# equal series scales to the capped height, sitting on the baseline
assert bars[0]["height"] == SPARK_MAX and bars[0]["y"] == 15 - SPARK_MAX
# stale variant dims everything visible (parked ids stay transparent)
els = screen_elements("day", model, True, 5)
assert all(e["color"] == COLORS["stale"] for e in els
if e["type"] == "text" and e["color"] != TRANSPARENT)
# zero previous period suppresses the delta element (parked transparent)
zmodel = build_model({day_key(now.date()): {"stripe": 5000}}, now, "mon")
zels = screen_elements("day", zmodel, False, 5)
ztexts = [e for e in zels if e["type"] == "text"]
assert all("%" not in t["text"] for t in ztexts)
assert any(e["color"] == TRANSPARENT for e in zels) # parking in effect
zbase = next(r for r in zels if r["id"] == "b")
assert zbase["fill_colors"] == [BASELINE_COLOR] and zbase["y"] == 15
checks += 16
# currency glyph: image element appears and the value shifts right
global GLYPH
GLYPH = True
gels = screen_elements("day", model, False, 5)
GLYPH = False
glyph_el = next(e for e in gels if e.get("path") == "pound.png")
assert glyph_el["y"] == H - LAYOUT["glyph_lift"]
assert next(e for e in gels if e["id"] == "2")["x"] == LAYOUT["value_x"] + GLYPH_W
p = pound_png()
assert p[:8] == b"\x89PNG\r\n\x1a\n"
assert struct.unpack(">II", p[16:24]) == (5, 7)
checks += 5
# Task 5: deterministic fake data
fb = fake_buckets(dt.date(2026, 7, 15))
fb2 = fake_buckets(dt.date(2026, 7, 15))
assert fb == fb2 and len(fb) == 500
assert all(set(v) == {"stripe", "paddle"} for v in fb.values())
assert all(v["stripe"] > 0 and v["paddle"] > 0 for v in fb.values())
checks += 3
# Task 6: stripe event parsing
ev = [
{"type": "charge", "amount": 5000, "currency": "usd", "created": 1784500000},
{"type": "refund", "amount": -1000, "currency": "usd", "created": 1784500000},
{"type": "payout", "amount": -99999, "currency": "usd", "created": 1784500000},
{"type": "charge", "amount": 7777, "currency": "eur", "created": 1784500000},
]
sb = {}
stripe_apply(ev, sb)
key = day_key(dt.datetime.fromtimestamp(1784500000).date())
assert sb == {key: {"stripe": 4000}}
checks += 1
# Task 7: paddle window rebuild
pb = {"2026-07-01": {"stripe": 111, "paddle": 99999},
"2026-06-01": {"paddle": 7777}}
txns = [
{"billed_at": "2026-07-01T10:00:00.000Z", "currency_code": "USD",
"details": {"totals": {"subtotal": "5000"}}},
{"billed_at": "2026-07-02T12:00:00.000Z", "currency_code": "USD",
"details": {"totals": {"subtotal": "2000"}}},
{"billed_at": "2026-07-02T12:00:00.000Z", "currency_code": "EUR",
"details": {"totals": {"subtotal": "4444"}}},
]
adjs = [
{"action": "refund", "status": "approved",
"created_at": "2026-07-02T12:00:00.000Z", "totals": {"subtotal": "500"}},
{"action": "refund", "status": "pending_approval",
"created_at": "2026-07-02T12:00:00.000Z", "totals": {"subtotal": "9999"}},
]
paddle_apply(txns, adjs, pb, dt.date(2026, 7, 1))
assert pb["2026-07-01"] == {"stripe": 111, "paddle": 5000}
assert pb["2026-07-02"] == {"paddle": 1500}
assert pb["2026-06-01"] == {"paddle": 7777} # outside window, untouched
checks += 3
# Task 8: cache round-trip and backfill start
assert backfill_start(dt.date(2026, 7, 27)) == dt.date(2025, 1, 1)
assert backfill_start(dt.date(2026, 1, 1)) == dt.date(2025, 1, 1)
import tempfile
global CACHE_PATH
old_path = CACHE_PATH
CACHE_PATH = os.path.join(tempfile.mkdtemp(), "cache.json")
assert load_cache() == {"buckets": {}, "stripe_last_sync": None,
"paddle_backfilled": False}
save_cache({"buckets": {"2026-07-27": {"stripe": 1}}, "stripe_last_sync": 123,
"paddle_backfilled": True})
assert load_cache() == {"buckets": {"2026-07-27": {"stripe": 1}},
"stripe_last_sync": 123, "paddle_backfilled": True}
CACHE_PATH = old_path
checks += 4
print(f"selftest: {checks} checks ok")
return 0
def main():
global GLYPH
args = parse_args()
if args.selftest:
sys.exit(selftest()) # hermetic: .env must not influence the checks
_load_dotenv()
set_host(args.host)
signal.signal(signal.SIGINT, lambda *a: (clear(), sys.exit(0)))
signal.signal(signal.SIGTERM, lambda *a: (clear(), sys.exit(0)))
if cur_code() == "gbp":
try:
upload_asset("pound.png", pound_png())
GLYPH = True
except OSError as e:
print(f"warning: currency glyph upload failed ({e}), "
f"showing bare numbers", file=sys.stderr)
if args.test:
run_loop(args, fake_buckets(dt.date.today()), None)
return
providers = [p for p, k in (("stripe", env("STRIPE_API_KEY")),
("paddle", env("PADDLE_API_KEY"))) if k]
if not providers:
print("No API keys found. Create a .env next to app.py containing\n"
"STRIPE_API_KEY=rk_live_... and/or PADDLE_API_KEY=pdl_live_...\n"
"(or run with --test for fake data)", file=sys.stderr)
sys.exit(1)
try:
if "stripe" in providers:
stripe_validate(env("STRIPE_API_KEY"))
if "paddle" in providers:
paddle_validate(env("PADDLE_API_KEY"))
except urllib.error.HTTPError as e:
print(f"API key rejected ({e.code}) during startup validation. "
f"Check your .env values.", file=sys.stderr)
sys.exit(1)
cache = load_cache()
print(f"revenue-bar: providers {providers}, backfilling/syncing...", file=sys.stderr)
run_loop(args, cache["buckets"], make_sync(cache, providers))
if __name__ == "__main__":
main()