Public
Track subscriptions, forecast spend, decide what to cancel
dashboardemailfinancesqlitesubscriptions
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

Subscription tracker

A Val Town app for deciding, month by month, whether each subscription is still worth paying for. Three parts: a schema that keeps enough history to answer "what did cancelling save me", an email assistant you forward receipts to, and a dashboard.

What it answers

QuestionWhere
What do my subscriptions cost me a month?Run rate — every yearly plan divided by twelve
What leaves my account in March?12-month calendar forecast, yearly renewals marked separately
Which ones should I drop?Two grades a month (quality, usage) → a keep/watch/cancel verdict
What has cancelling saved me?Monthly equivalent of each cancelled plan, counted from the day it stopped
What do I need to decide right now?The decision inbox — renewals you can still stop, ratings due, cancel candidates

Run rate and the calendar forecast are deliberately separate numbers. Averaging a yearly plan into a monthly figure makes it comparable; it also hides the month the money actually leaves, which is the surprise worth avoiding.

Files

index.ts                    HTTP: the dashboard and its JSON API      (fileType: http)
email.ts                    inbound mail: forward receipts here       (fileType: email)
cron.ts                     daily: FX, decision inbox, digest         (fileType: interval, 0 6 * * *)

shared/       types, money and date maths, verdicts, forecasting — pure, runs on both sides
backend/      db schema, FX, queries, analytics, review inbox, LLM, email handling
frontend/     the React dashboard (no build step; Val Town transpiles per request)
tests/        the pure logic, run with `deno test --allow-import tests/`

The schema

services — the vendor. subscriptions — one billing arrangement for a service; a service can own several over time as plans and prices change, but only one is live. charges — payments that really happened. Nothing in the future is stored: it is projected from next_charge_on, so a price change never leaves stale rows behind.

services         id, name, name_key(unique), vendor_domain, category, notes
subscriptions    id, service_id, plan_name, amount_minor, currency, billing_cycle,
                 started_on, next_charge_on, status, cancelled_on, cancel_reason,
                 payment_method, auto_renew, source, notes
charges          id, subscription_id, charged_on, amount_minor, currency,
                 base_minor, fx_rate, source, inbound_email_id
ratings          id, subscription_id, period(YYYY-MM), quality 1-5, usage 1-5, note
                 unique(subscription_id, period)
review_items     id, subscription_id, kind, status, title, detail, due_on, dedupe_key
inbound_emails   id, message_id(unique), from_addr, subject, body, intent, handled, result
fx_rates         quote, base, rate, as_of
settings         key, value

Money is stored as integer minor units (grosze, cents) in the currency it is actually billed in — never a float, never pre-converted. Conversion to your base currency happens at read time, except on charges, which pin the rate that applied on the day so history is not re-priced when the zloty moves.

status is active | trial | paused | cancelled. Cancelling keeps the row: the price and the stop date are exactly what the savings figure is computed from.

The verdict

Each month, two grades out of five: quality (how good is it) and usage (how often do you reach for it). They are combined with a harmonic mean, which collapses toward whichever is worse — 5 and 1 give 1.7, not 3. A brilliant tool you opened twice is still money leaving.

Ratings are recency-weighted over six months (each month back counts 0.75 of the one after it), so one quiet month nudges the score without flipping it. Bands: >= 3.5 keep, >= 2.5 watch, below that a cancel candidate. A watch that costs more than your median subscription is promoted to a cancel candidate — an above-median price only has to be middling to deserve a decision.

The email assistant

Forward mail to the address Val Town assigns email.ts (read it from links.email on the file listing — never construct it).

Who may email it

ALLOWED_SENDERS is checked before anything else happens: no database write, no model call, no reply. Mail from anyone else is logged and dropped.

This matters more than it does for a typical webhook. The assistant answers questions about your spending, so a stranger emailing "how much am I spending a month?" would otherwise get an answer. Rejected mail is dropped rather than replied to, because a reply would confirm the address is live and let anyone bounce mail off this val at a forged sender.

Unset means nobody is allowed. A mail handler that can leak data should fail closed; a misconfigured val quietly answering strangers is the worse failure. Both full addresses and whole domains are accepted, and me+tag@ matches me@ since they are the same mailbox.

One honest limitation: this reads the From header, which is not authenticated. It reliably stops accidental, curious and opportunistic senders. It is not proof against someone deliberately forging a sender address. The dashboard's own /api/email/simulate route bypasses it, but sits behind the dashboard login gate.

  • Forward a receipt with nothing on top → it is extracted and tracked, and a confirm item appears in the decision inbox. Nothing is ever written silently.
  • Forward it with a question on top → both: the charge is recorded and the question is answered from your data.
  • Send a command as the first line:
list                        active subscriptions and run rate
forecast [months]           calendar forecast, yearly renewals marked
upcoming [days]             what is due soon
savings                     what cancelling has saved you
review                      the decisions waiting on you
rate <service> 4 2          quality 4, usage 2, for this month
cancel <service> [because …]
keep <service>              clear its open decisions
add <service> 49.99 PLN monthly
help

Anything else is treated as a question and answered by the model from your subscription data.

Extraction uses a strict tool rather than a forced one: a receipt the model cannot read comes back as "no result" instead of a confidently wrong row. Without an API key the app still works — receipts fall back to a text scan that takes the largest money amount, and commands are unaffected.

Environment variables

VariableRequiredWhat it does
ANTHROPIC_API_KEYfor the assistantReceipt extraction and email Q&A. Without it, commands and the heuristic scan still work.
APP_URLnoOverrides the dashboard link in emails. Leave unset — the val asks the platform for its own endpoint, so a remixed copy links to itself with no setup. Set it only for a custom domain or a proxy.
BASE_CURRENCYnoPLN (default), USD or EUR. Everything rolls up into this.
INBOUND_EMAILrecommendedThis val's own inbound address, set as Reply-To on outgoing mail. Without it, replying to the app's emails goes to notifications@val.town and is lost. Read it from links.email on the file listing — never construct it.
ALLOWED_SENDERSrequiredWho may email this val: full addresses (me@example.com) or whole domains (@example.com), comma-separated. Unset means nobody — see below.
ALLOWED_USERSrecommendedThe complete guest list: Val Town handles or account emails, comma-separated. While set, org membership admits nobody on its own. Leave unset and members of the owning org get in.

FX rates come from Frankfurter (ECB reference rates, no key), cached daily, falling back to the last cached value.

Who can see it

The dashboard is spending data, so the endpoint is not open. Val Town's platform-level restricted access needs a paid business org, so the gate lives in the val instead: std/oauth wraps the whole app, and a visitor gets through only if they are signed in to Val Town and appear on this val's guest list. Anyone else gets a 401 (API) or a 403 page (browser) — never the data.

ALLOWED_USERS is that guest list — handles or account emails, comma-separated — and while it is set, org membership grants nothing by itself. That matters because this app has no multi-tenancy: each person runs their own copy, and adding someone to the org so they can reach theirs would otherwise hand them everyone else's too. Naming the list explicitly keeps each instance to its own person. Leaving it unset falls back to org membership, so a fresh deploy can't lock its owner out of itself.

Note what this gate does not cover: anyone with edit access to the val — which org membership does grant — can read its SQLite database directly through Val Town, regardless of this code. Keep the org membership list to people you would trust with the data itself.

If this val ever moves to a business-tier org, set_http_privacy: restricted is the better mechanism — it gates at the platform edge, before any code runs — and the oauthMiddleware wrapper should come off at the same time so nobody has to authenticate twice.

Deploying

The val needs three file types: index.ts as http, email.ts as email, cron.ts as interval with 0 6 * * * (UTC — that is 07:00 or 08:00 Warsaw depending on the season; cron does not follow daylight saving).

No post-deploy URL configuration is needed: backend/links.ts resolves this val's own endpoint at runtime via httpEndpoint() from std/utils, so a fresh remix links to itself. APP_URL remains available as an override.

Not built yet

Google Calendar entries for upcoming renewals, and bank feeds via bkpr to reconcile charges against what actually left the account.