Skip to content

Notifications

delabrcd edited this page Jun 6, 2026 · 1 revision

Notifications

Get pinged when a new bill shows up. Off by default, exactly-once, and structurally unable to slow down or fail a scrape.

When it fires

Only on a SCHEDULED check that discovers a bill newer than the last one notified. Manual refreshes stay silent. Each new bill produces exactly one notification with the amount (current charges), the service period, the statement date, and a dashboard link if APP_BASE_URL is set.

Channels (lib/notify.ts)

Pick one explicitly with NOTIFY_CHANNEL, or leave it unset and the app infers from whichever block you filled in (precedence webhook > ntfy > smtp); off disables.

Channel Env
webhook NOTIFY_WEBHOOK_URL — HTTP POST a JSON body (no extra dependency).
ntfy NTFY_URL (default https://ntfy.sh), NTFY_TOPIC, optional NTFY_TOKEN.
smtp SMTP_HOST/SMTP_FROM/SMTP_TO required; SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASS optional (465 ⇒ implicit TLS unless overridden). Via nodemailer.

APP_BASE_URL is the public dashboard URL used to build the link.

Exactly-once dedupe (no schema change)

The dedupe watermark is an AppSetting row, key lastNotifiedStatementDate (an ISO YYYY-MM-DD). The app notifies for each bill strictly newer than the watermark, then advances it to the newest notified date — exactly-once across restarts and across multiple new bills in one scrape. On the first run after enabling (watermark unset), it seeds the watermark to the current max statement date without notifying, so turning notifications on never replays your whole bill history.

Design (why it can't break a scrape)

  • Pure helpers in lib/notifyFormat.tsformatBillNotification() (content), selectBillsToNotify() (watermark dedupe), resolveChannel() (env → channel). DB-/network-free so the unit suite runs without a Prisma client, and they ship hand-calculated tests.
  • Impure dispatcher notifyNewBills() reads env, sends, advances the watermark — and is wrapped in try/catch by its caller (run.ts). A misconfigured/unset channel is a no-op, and a send failure can never fail an otherwise-good scrape.

See Architecture for where this sits in the scrape orchestrator.

Clone this wiki locally