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.
| Question | Where |
|---|---|
| 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.
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/`
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.
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.
Forward mail to the address Val Town assigns email.ts (read it from links.email
on the file listing — never construct 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.
| Variable | Required | What it does |
|---|---|---|
ANTHROPIC_API_KEY | for the assistant | Receipt extraction and email Q&A. Without it, commands and the heuristic scan still work. |
APP_URL | no | Overrides 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_CURRENCY | no | PLN (default), USD or EUR. Everything rolls up into this. |
INBOUND_EMAIL | recommended | This 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_SENDERS | required | Who may email this val: full addresses (me@example.com) or whole domains (@example.com), comma-separated. Unset means nobody — see below. |
ALLOWED_USERS | recommended | The 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.
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.
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.
Google Calendar entries for upcoming renewals, and bank feeds via bkpr to reconcile charges against what actually left the account.