Repository navigation
Releases
v0.2.2
Added
OpenAI's app portal can verify this deployment's MCP host. Set
SB_OPENAI_APPS_CHALLENGE to the token the portal shows when you connect the
MCP server, and GET /.well-known/openai-apps-challenge answers with exactly
that token as plain text; the portal will not scan the tools until it does.
Unset, the path answers 404 as it always did. Both compose files and the cloud
programs pass the setting through.
The consent screen says where approving sends you, and whose ledger it
grants. Registration is open, so the client's name on that screen is only
the client's own claim; the screen now names the host its code goes back to,
and says so plainly when that host is this computer, which means a program on
your own machine. It names the signed-in account's email too, because a
browser still signed in to one account approves for that account's ledger,
whoever started the connection.
A whole ledger, or some of its accounts, can be exported as one Simple Balance
file. A CSV carries transactions and nothing else, so moving between our cloud
app and a copy you run yourself meant rebuilding every account, budget, template
and recurring transaction by hand, and picking the other account of every
transfer again. The menu's Import CSV is now Import/Export, a page headed
Import and export at the same address, and below the CSV import it makes one
JSON file in the simple-balance-ledger-1 format: accounts with their opening
balances and the day each was archived, categories and groups, every transaction
with its splits and both sides of every transfer, deleted ones too unless you
untick them, templates with their reminders, recurring transactions, budgets,
rows still waiting in Staged transactions, and your regional defaults and theme
once you have chosen them. Records name each other by name rather than by id, so
the file means the same thing in another copy, and a kind you leave out still
travels by name wherever something refers to it, so unticking one breaks
nothing. Some accounts takes up to a hundred of them, with their details
alone, all their transactions, or a date range, and a transfer between two of
them is in the file once. A ranged file carries what each account held the day
before it starts, and a file with transactions gives every account a balance
check worked out from its postings. The activity history can come along,
unticked to begin with, with the owner's id taken out of it, and is never read
back.
Agents get the same file from export_ledger_file, and
GET /api/v1/ledger-file serves it as a download. One download holds up to
100,000 rows and 16 MiB of what rows do not bound — notes and the rest of the
entries' free text, split legs, staged rows and the activity history — and one
agent's call 10,000 rows and 4 MiB, because a tool's reply cannot stream, in a
file of at most one import part's 16 MiB, so what an agent is handed is a file
it can add as it is. A larger export is refused with both numbers and the way
out that works for what was asked, usually a date range at a time; staged rows
and budget amounts have no date to narrow them by, so past an agent's limit they
leave through the browser's download. Exporting writes nothing, not even a line
in the activity log. The page's two empty states, no account yet and every
account frozen, now cover only the CSV import, so the export is there for
somebody with either. A file it makes can be added to another copy or moved into
one, as the next two entries say. The CSV import and export are exactly as they
were.
A Simple Balance file can be added to a ledger, including one that already
holds things of its own. Import a Simple Balance file, between the CSV
import and the export, reads the file, checks it against this ledger and says
what adding it would do before anything is written. Accounts, categories and
groups, templates with their reminders, recurring transactions and budgets that
nothing here is like are created at once, without the review queue: a new
account has nothing to review except whether it is already here, and that is
the preview's question. Where something here has the same name and differs, the
preview asks — use the one here, leave the file's out, or bring it in under
another name — and an account in another currency is never taken for the one
here. One the same in every field is used with nothing asked, and nothing here
is overwritten. The file's transactions go to Staged like any import's, complete
with both sides of every transfer, so bringing accounts across one file at a
time no longer means choosing the other account of every transfer by hand.
Every transaction carries the id it was first written under, kept in a new
import_origin column, and one already here by that id — as an entry, deleted
or not, or as a row in Staged, waiting or since committed — is skipped and
counted rather than flagged as a duplicate. A transfer in two accounts' files
lands once, the same file added twice adds nothing the second time, and two
look-alike transactions from a file are never taken for each other at stage, at
commit or in a bulk edit; one with no origin is compared as it always was.
Deleted transactions are not added, and an account archived where the file came
from arrives archived, closed today rather than on its original day; moving a
file, in the next entry, brings both as they were. A file of
more than 10,000 rows is applied in parts, each all or nothing, and choosing it
again after a part stopped picks up where it left off. On a free plan with
fewer places than the file brings accounts in use, the person chooses which
of the new ones arrive archived, with none chosen for them, and their
transactions wait in Staged until a place is free. The file's regional settings
apply only where this copy never chose its own, or where the person says to. A
reminder already sent is not sent again, and a recurring transaction does not
propose a day the other copy already did.
Agents have preview_ledger_file, a read, and add_ledger_file, which needs
ledger:write because it creates records rather than proposing them, and is
marked destructive because it can replace the settings chosen here. Over HTTP
they are POST /api/v1/ledger-file:preview and POST /api/v1/ledger-file:add,
and the add streams its progress when asked. Every record and row the add
creates is audited as create_from_ledger_file, and Staged's batch filter
marks a batch that came from a file. An agent handed a whole download larger
than one part is told the rules the page cuts a file by, so it can send the
parts itself. A file can be added only to a copy running this release or later.
A Simple Balance file can be moved straight into the books of a new ledger.
Adding sends a file's transactions to Staged, which is right for a ledger with
entries of its own, and it was the only way in: moving from a copy you run
yourself to our cloud app, or back, meant committing every transaction a second
time, leaving deleted ones behind, and finding each archived account put away on
the day of the import rather than the day it was. Import a Simple Balance
file now asks How should this file come in?, with neither answer chosen,
and into a ledger where everything came from a Simple Balance file — a new
sign-up, a fresh install, or one partway through a move — the file can be
moved. Every transaction is written straight into the books through the same
services a person's own entries go through, so its postings are worked out here
rather than copied. Deleted transactions arrive deleted, an account archived
where the file came from arrives archived on its own day holding its
transactions, a reminder already sent stays sent, and a recurring transaction
does not propose a day the other copy already did. Once one transaction is
typed here or committed from a CSV, moving is refused with the reason, and the
file can still be added. Rows still waiting in Staged stop a move only until
they are discarded, and the refusal says so rather than sending anybody to add
instead, since committing them would end the move for good. A ledger moved a
date range at a time should carry its recurring transactions in the latest
range only, and the export says so once a range is chosen: one an earlier range
brings can propose a row into Staged before the next range arrives.
Every transaction is written, or nothing of its part is. A move offers fewer
answers than an add: an account or a category a transaction is filed under has
to come in, and an account here whose balance could never come out as the file
says cannot stand in for the file's. It is refused before anything is written
while some of the file's transactions wait in Staged from an earlier add of
it, while one would land on an account frozen here, while an account it brings
in new already has some of its transactions here on another account, or while a
category here would make one both income and spending. The part carrying the
file's balances is held to them once it is written, and an account that does
not hold what the file says on its last day refuses the part, naming the
account, both figures and what to do. On a free plan the person chooses which
new accounts arrive archived, as in an add, and those take their transactions
with them, closed on the day of the move; restoring one posts its balance back.
A refund filed under a category whose kind was widened afterwards counts on the
side the category says here, and the move reports how many and how much, by
currency, rather than overriding it; every balance is the same either way. A
file of more than 10,000 rows moves in parts, each all or nothing and resumable,
with recurring transactions created by the last, and a part of 10,000
transactions took about fifty seconds on a laptop. A move is a one-time trip
rather than a sync: a transaction already here is passed over, never updated.
Agents have move_ledger_file, which needs ledger:write and is marked
...
v0.2.1
Everything below except the oci-single fix came out of a full smoke test of a
0.2.0 deployment — the browser app, the HTTP API, the MCP surface and billing
against a Stripe sandbox.
Changed
Moving from monthly to annual asks first. It is the one plan change that
charges a card the moment it is pressed, and it did so on a single click. The
plan tab now says the annual plan starts today, that what is left of the month
is credited and the difference is charged now, and charges nothing until that is
confirmed. Nothing else asks: a move to monthly waits for the renewal, canceling
runs to the end of the period, and both are undone on the same tab, so canceling
stays exactly as easy as subscribing.
A frozen account can be archived or deleted. After a downgrade, every
account past the free plan's three refused every change, including being put
away, so somebody with thirty accounts who wanted to clear out the ones they no
longer use had to upgrade first or swap each one into use and out again. Archive
and delete now work on a frozen account, from the Accounts page and from an
agent alike. Neither gives anything a place, because a frozen account never
held one, and an archived account still needs a free place to come back, so the
limit is exactly where it was. Its entries and details stay closed to change.
Oracle's small database node has 8 GB. Oracle halved its Always Free
Ampere allowance on June 15, 2026, to 2 OCPUs and 12 GB, so a small pair's
four cores are about two past it, about $14 a month, whatever this release does.
The memory the allowance still covers now goes to the database node, which takes
the ledger PostgreSQL holds entirely in cache from 2.8M transactions to 6.3M at
no cost, and its PostgreSQL settings move with it. AWS is unchanged. pulumi up
resizes a running stack's database machine in place, which restarts it, and
docs/upgrades.md has the settings for a machine built before this.
Fixed
Five security advisories are closed by this release — two rated critical,
two high and one medium. GitHub counts six alerts, because proxy-addr sits in
both application lockfiles. proxy-addr moves to 2.0.8 in the application and
its runtime image, where it arrives under the MCP SDK's Express and is never
loaded, because the server runs on Hono. Under the Pulumi programs,
shell-quote moves to 1.12.0 and postcss-selector-parser to 7.1.6. sharp
moves to 0.35.5 and source-map-js to 1.2.2; both are used only to build and
test, and neither is in an image.
Older activity no longer skips the rest of an import. Every change one
import or one mass edit makes is recorded at the same instant, and paging back
through the activity history asked for entries earlier than that instant, so a
page boundary inside an import skipped every entry of it after that page. Each
page now picks up exactly where the last one stopped.
Opening a staged refund or a recurring refund to edit it keeps it a refund.
A row or a recurrence naming a category that did not exist yet, with the answer
that it was a refund, lost that answer when it was opened in the form and
saved, and committed as income. The answer is shown and kept, on a split for
each part separately.
An entry made from a template that has since been deleted can be edited
again. Every save of it, a restore after deleting it, and any mass edit that
included it were refused because the template was gone.
The Oracle cluster's volumes are encrypted on the way to the disk. Its
StorageClass spelled the attachment key the way Oracle's CSI driver does not
read, and the driver ignored it, so every volume was attached over iSCSI, which
Oracle does not encrypt in transit, and the node pool never asked for in-transit
encryption either. New volumes are attached paravirtualized on nodes that
encrypt the hop. Volumes and nodes that already exist keep what they were made
with, and at rest every Oracle and Google volume was encrypted all along, by the
provider's own key.
Saving something without changing it no longer makes every other copy of it
stale. An unchanged save of a transaction, account, category, group, budget,
template, recurrence or staged row rewrote the row and bumped its version, so a
second tab or an agent holding the same record was refused its next save over a
change nobody made. Such a save now writes nothing — no new version, no audit
entry, no posting — and so does archiving something already archived or
deleting something already deleted. A mass edit writes only the rows its change
actually changes, and its count says how many that was.
MCP connections no longer hold a server open for nothing. The endpoint is
stateless, and it answered every client's GET with an event stream that could
never carry a message and stayed open, one connection and one server instance
per connected agent. It now answers 405, which MCP clients read as "no stream
here".
The session cookie's secret stays out of page script. The cookie is
HttpOnly, and the sign-in, sign-up, session and session-list answers handed
the same value back in JSON anyway, readable by any script running on the page
— and while advertising is on, the page allows scripts from any HTTPS origin.
Those answers no longer carry it.
Forms say what is wrong next to the field that is wrong. A refusal from the
server listed its sentences at the top of the account, transaction, template
and recurrence forms and nowhere else. Each sentence now also appears beside the
field it is about, which is marked invalid, and each line of the summary at the
top is a link that takes you to that field.
Focus stays where you are working. A button that was busy dropped focus, so
the next Tab started from the top of the page; the result of a bulk edit, or of
anything confirmed in a dialog, never received focus; deleting or restoring a
single transaction said nothing at all; opening the menu on a phone left focus
behind it, and following a link from it pulled focus back out of the page it
opened. All of these now land focus on the button that is still there or on the
sentence saying what happened. The menu button says whether the menu is open,
and a deleted transaction is labeled "Deleted" in words as well as struck
through.
A refused delete says so where you can see it. Deleting a category or an
account that is still in use put the refusal at the top of the page, often far
above the row, beside an earlier success message that was no longer true. The
refusal now names what was not deleted, takes focus, and replaces the old
message.
A CSV import no longer picks an account for you. With several accounts, the
first one alphabetically was already chosen, one press from staging a whole file
into an account nobody had named. It now asks, unless there is only one.
Two accounts can no longer be told apart only by capital letters. Account
names were the one kind of name compared exactly, so "Checking" and "CHECKING"
could both exist. They are now compared the way categories and payees are.
Accounts that already differ only that way are kept.
A recurring transfer between an account and itself is refused when it is
made, rather than accepted and then filling the staged queue, every time it
came due, with a row nobody could commit.
Pages that were hard to read on a phone or a screen reader. A very large
balance pushed the overview and accounts pages sideways at phone widths, and
now wraps. The budgets forecast showed one table per currency with nothing on
screen saying which currency each was. Transfers read "Uncategorized" on the
transactions list, which looked like work left undone, and now show a dash as
the staged queue already did. A split's remaining amount read "10 left to
assign" with no currency and "-65.5 left" when over-assigned; it is written as
money, and over-assigning says so. The activity history read like code —
"Create from stage transaction" — and never said which record changed; each
line now says what happened, in words, and names the record. And the
add-category form called a kind "Both" that the rest of the app calls "Income
or expense", while the question about what kind a new category is had no
visible wording at all.
A link that fails says so. A failed sign-in or MCP authorization link
landed on the overview with no word of what had gone wrong, and a report name
nobody knows showed net worth under the wrong address. The first now gets a page
that says what happened, and the second goes to Reports.
Pages fit their window, and numbers start at their currency's decimals.
Staged, with a duplicate waiting, was wider than an 820px window and broke its
duplicates button over two lines on a phone; header actions now drop to a row of
their own instead. Dates in the staged queue, the import preview and Recurring's
next column broke onto three lines in a narrow table, and now stay whole. An
account opened for editing showed its starting amount to eighteen decimal
places, and a transaction or budget dropped its cents to "12.5"; every amount
field now starts at the currency's own decimals. When the accounts list failed
to load, the add buttons said to create an account first; they now say the
accounts did not load. The duplicate review no longer opens with the cursor in
its second form, a badge's icon no longer touches its words, and a staged row
missing a field names the field to fill in rather than repeating a type error.
Two edits at once no longer deadlock. Editing an entry or a staged row so it
no longer names a category, while another write named a category on a
different account, could stop both and fail one of them with a server error.
The two locks they take are now always taken in the same order. A bulk edit
already did this; the single edits and the staged edits now do too. So do
restoring a deleted entry, which took its locks the other way round from a bulk
edit or a payee merge, and committing staged rows, w...
v0.2.0
This release upgrades cleanly from 0.1.6. Every new setting is additive and
every one of them defaults to absent, so a deployment that changes nothing sells
nothing, limits nobody, shows no advertising, and opens no connection it did not
open before. No route, tool or CSV column is removed. The one change to
schedule is in the aws Pulumi program, whose next pulumi up turns proxy
protocol on in front of ingress-nginx and interrupts the site for seconds to
about a minute while it does.
Added
An ha deployment profile: PostgreSQL sharded with Citus, run by Patroni, in
two shapes. deploy/helm/ now provisions the database as well as the
application — a StatefulSet per Citus group, the coordinator and its workers,
each a primary with streaming standbys. Citus supplies no high availability of
its own, so Patroni is what promotes, and it registers each node with the
cluster unaided. Off unless database.enabled is set, and it stays false in
values.yaml: every deployment that does not ask for it keeps the
DATABASE_URL it already had, and a 0.1.6 values file renders byte for byte
what it rendered before. Two values files turn it on and they are the same
profile, not two — values-node-per-service.yaml is one of everything, five
pods with no redundancy anywhere; values-ha.yaml is three Citus groups at two
replicas with the web tier autoscaling. Everything in the two that is not a
replica count is identical, which is the point: growing from one to the other is
a rollout rather than a migration from one arrangement of the data to another.
That is why there is no separate small-cluster profile, and the Kubernetes
overhead of five pods is what buys never having to do that move.
docs/citus.md is what distributing the ledger costs and
docs/citus-runbook.md is how to run it — adding a worker, rebalancing, what a
failover does, and how to move an existing database onto a cluster.
A third cloud for ha: Oracle Container Engine for Kubernetes.
deploy/pulumi/oci/ builds what the aws and gcp programs build — a network,
a cluster, a node pool, ingress-nginx behind a load balancer, and the chart on
top — so the three differ in what their clouds call things rather than in what
ends up deployed. It is the shortest of the three, and for reasons rather than
by omission: OKE ships the block-volume CSI driver and the load balancer
controller inside the cluster, which the other two have to install, and an OCI
network load balancer preserves the client address without proxy protocol, so
the frontend needs no second way of learning who is asking. The node pool runs
on Ampere, and the image a node boots is looked up from the Kubernetes version
rather than pinned, because a hard-coded image OCID stops existing when Oracle
retires it and the failure is a pool that never produces a node.
simple-balance:controlPlaneCidrs reaches it like the other two, though not in
the same place: EKS and GKE take the allowed sources as a property of the
cluster, while OKE's endpoint sits in a subnet of the deployment's own, so there
it is a rule on that subnet's security list — and that same list is what decides
whether the workers may reach the API at all.
Not exercised with pulumi up. The program plans against a real tenancy and
no cluster has been built from it, which docs/acceptance.md carries as
outstanding rather than leaving to be discovered. That is also why
tests/cluster-control-plane.test.ts exists: a preview plans the security rules
and the cluster cleanly whether or not the control plane can be reached, so the
one failure a preview cannot show — a cluster that reports itself ACTIVE while
every kubelet's registration is dropped at the subnet — is asserted in the
repository instead.
Synchronous replication is derived rather than defaulted. It was true with
nothing tying it to the replica count, and Patroni's synchronous_mode_strict
is off, so a group with no standby degraded silently to asynchronous — a setting
claiming a guarantee it could not give. It is now
synchronousReplication and replicasPerGroup > 1, and asking for it at one
replica fails the render with the reason named. At two replicas it is on for the
reason it always was: a promotion that loses an acknowledged transaction is the
books disagreeing with what somebody was shown.
Patroni's REST API now takes a password, and the database has a
NetworkPolicy. restapi.listen was 0.0.0.0:8008 with no authentication
block and nothing in front of it, so any pod in the cluster could POST
/switchover, /failover, /restart or /reinitialize. Both halves are
closed: a fourth generated password in the database Secret, and a fourth policy
admitting 5432 from the API and the scheduler only and the REST port from
database pods only. The frontend is not a peer of either. Egress from the
database pods stays open on purpose — Patroni keeps cluster state in the
Kubernetes API, whose address the chart cannot know, and a restricted cluster
that loses that rule stops failing over.
A PostgreSQL image this project builds, for the profile that needs one.
deploy/docker/citus.Dockerfile is PostgreSQL 18 pinned by digest with Citus
14.2.0 compiled from a checksummed source tarball, published for both
architectures. It exists because the one arm64 image Citus publishes is musl, and
musl compares text byte by byte whatever collation is declared — which is what
category and payee uniqueness in this application rests on. Its tag names Citus
and PostgreSQL rather than this release, and there is deliberately no latest: a
major version cannot read the previous major's data directory, so a floating tag
on a database turns an image pull into an outage.
The application waits for the database instead of crash-looping at it. On a
first install of the ha profile the API and the scheduler came up before
PostgreSQL was accepting connections and restarted once, or twice at the
redundant shape, before settling — which looks exactly like a broken deployment
to somebody meeting the chart for the first time. Both now carry a
wait-for-database init container, gated on database.enabled, so a
bring-your-own-database deployment is unaffected and renders what it rendered
before.
helm uninstall now takes Patroni's own state with it, so a reinstall under
the same release name works. Without it, helm uninstall followed by
helm install left the database permanently unable to start. Patroni keeps its
cluster state in Kubernetes objects it creates itself at runtime — endpoints
named <release>-simple-balance-db-<group>, and -config, -sync and
-failover beside them. Helm deletes what it created and did not create those,
so the -config endpoint survived carrying an initialize annotation with an
empty value. Patroni reads the presence of that annotation as "this cluster
already exists, do not bootstrap, wait for a leader", and the empty value means
no member can claim leadership from it either. Every database pod logged
INFO: waiting for leader to bootstrap every ten seconds forever, Running and
0/1, with nothing crashing, nothing backing off and no event saying anything
was wrong — and deleting the PersistentVolumeClaims, the obvious thing to
reach for, is what produces it, because the state then describes a cluster
whose data is gone. Deleting the namespace was the only recovery.
A Job now deletes those objects on uninstall. Because a hook that deletes
database cluster state is dangerous by construction, the decisions that make it
safe are written into the template beside the annotations rather than left to be
inferred:
- It is a
post-deletehook, not thepre-deleteone this obviously wants to
be. Helm runspre-deletebefore deleting anything, so every database pod
is still up and Patroni's leader rewritesinitializeand the config object
within one ten-secondloop_wait. Apre-deletehook would delete four
objects and be handed them straight back — it would have looked right and
changed nothing. - A hook that cannot run never strands the release. Helm returns a
pre-deletefailure before the uninstall proceeds, so a broken one would
refuse the uninstall and leave a release nobody could remove. Apost-delete
failure is collected instead: the resources are deleted, the history is
purged, andhelm uninstallexits non-zero. The residue is the four
endpoints, which is exactly the old behaviour, recovered the old way. It does
still fail apulumi destroy, which the runbook anddocs/upgrades.mdboth
cover: the Release is left in Pulumi state with nothing behind it, a second
destroycannot clear it because the history is already purged, and
pulumi state deleteon that URN can. On a teardown that takes the namespace
anyway,database.patroniCleanup.enabled: falsedeclines the hook and the
question with it. - It waits for the database pods to be gone before deleting anything. Helm
does not wait for pods on uninstall and they have a sixty-second grace period,
so a Job that deleted immediately would lose the same race thepre-delete
hook loses. Its wait selector is deliberately broader than its delete
selector: waiting for too much only costs seconds, while deleting too little
reproduces the defect on the next install. - It matches this release and no other. Selection is by the labels Patroni
itself writes —app.kubernetes.io/instance, the release name, and
cluster-name, which contains it — never a name prefix or a wildcard that
could reach a sibling release in the same namespace. It skips Helm-owned
objects, deletes by exact name, and treats a 404 as success, so it is harmless
when the objects are already gone. It fires on uninstall and from nowhere
else: no install, upgrade or rollback path reachespost-delete. - Its RBAC is a hook resource too, not a release resource, since Helm
deletes the release's own...
v0.1.6
This release upgrades cleanly from 0.1.5. A deployment starts on the
configuration it already has, every path that answered still answers, and no
client loses a capability it had. Five changes in an earlier draft broke that
and were reverted to warnings, kept precedences and deprecated aliases;
AGENTS.md now carries the rule so the next release does not have to
rediscover it.
Twenty-seven security advisories are closed by this release — eleven rated
high, sixteen medium — across fast-uri, js-yaml, nodemailer, hono and
qs. Every one of them is a dependency this project pulls in rather than code
it wrote, and every one is now at or past the version that patches it. The
container's Node base image also moves to the current 24-alpine build, which
carries the Alpine and Node patches released since the digest this project had
pinned.
One change is a judgement call rather than a clean pass, and it is named here
rather than left to be discovered. Every MCP tool now declares a closed argument
object, where 57 of the 71 were open. An agent sending an argument nobody
declared used to have it dropped in silence and now gets an error naming it.
Nothing an agent could successfully do before is impossible now — the dropped
argument never had any effect — but a call that returned success will return a
failure, and that is worth knowing before upgrading. It is the whole point of
the change: an open object accepts a hallucinated argument, answers success, and
teaches the model that the argument works.
Added
A category can be put in a group from the categories list. The control
existed, in a modal behind an unlabelled pencil, and no row ever said which group
a category was already in — so a page with a Groups panel showing "0 categories"
and no way to change it read as a feature that does not work. Every row now
carries its group and changes it in place, the add form files a new category
straight away, and a failed read of the groups says so instead of claiming there
are none.
The forecast can project from what a ledger actually does. "What happens
next" counted dated recurrences and nothing else, so a household with months of
real spending and no recurrences saw $0.00 in both money columns with nothing
saying why. A third basis — "Recurring plus what you usually spend" — adds the
average of recent finished periods per category, less whatever a recurrence
already covers, and reports that part separately as typicalSpending and
typicalIncome so a reader can always tell an inferred figure from a scheduled
one. The current period is never part of its own average, matching the rule
trailing_average follows on the budgets side. The browser defaults to it; the
wire default is unchanged, so no existing client's answer moves.
The overview says where the budget stands over the range it is showing, under
Accounts and Spending by category, one row per period the report covers rather
than a total the server never computes — and under it, what the period is made
of: every group and every category you have budgeted, each with what it spent
against what it may actually spend, a bar, and a word for the state it is in. A
budget with nothing spent against it yet is shown like any other, because a
budget is what you set rather than what you spent. A category with no budget is
left out; the panel above already reports that spending, and "£100.00 of —" is
not a budget. Groups are badged the way the budgets page badges them, so a group
budgeted as the sum of its categories is not read as a figure to add to them.
Only the period the range ends in is broken down, which is what keeps a
year-long range from putting twelve expanded periods on a page whose job is a
glance. And the categories in Spending by category are links to the
category, carrying the date range with them.
A long commit and a long import say how far along they are. Committing fifty
staged rows or more, or staging a CSV of fifty rows or more, now draws a
determinate bar counting the rows as the server works through them — validated,
compared, posted for a commit; staged for an import — with the phase and its
true figures in the sentence beside it. Several thousand rows is a minute or
more, and until now that minute looked identical to nothing happening.
There is nowhere else that progress could have come from. Both writes happen
inside one transaction, so a row written to a progress table would be invisible
to every other connection until the whole thing committed, and a count held in
one process cannot be polled by a deployment running two. So the response
reports its own progress: the browser asks with Accept: text/event-stream and
gets Server-Sent Events, ending in a terminal frame carrying byte for byte what
the JSON reply would have. A caller that does not ask sees no change at all —
same status, same body, same headers — which includes every MCP client, since
the agent transport answers in a single JSON object and has no channel a
progress notification could travel on. The transaction is untouched: no query
was added, moved or removed, and a refusal partway through still rolls
everything back and now says so on screen.
Operators running their own reverse proxy in front of a single container should
add proxy_buffering off; and proxy_read_timeout 600s; to it — the snippet in
docs/deployment.md now has both, and the images this repository ships already
did. Without them a long commit was already being cut off at sixty seconds; with
them the progress arrives as it happens rather than in one jump at the end.
Clone transaction, on the row menu of both the transactions list and the
staged queue. The copy opens the staging form prefilled and lands on Staged for
review rather than in the books, minus the original's bank reference — a copy
carrying it would be swallowed by the next import as already-seen.
The staged queue edits in place. A row's date, payee, category and amount
turn into editors where they are — the full form's own editors, with the same
server checks and the same optimistic versioning — so repairing an import is a
click and a keystroke instead of a trip through the modal. A split's category
and amount keep the modal, where the legs they depend on are visible, and a
transfer's category cell stays read-only because a transfer carries none.
A category can be excluded from the categories report, from its row's
menu, because one outsized category flattens every other line in the chart.
The footer re-adds from what is on screen, exactly; pills name what is left
out and put it back; nothing stored changes, and agents reading over MCP see
every category either way.
The application says what it is doing, in Prometheus' text format, at
GET /metrics. It is off until METRICS_ENABLED=true, and then it is
registered rather than refusing, so a deployment that never asked has no such
route at all. Both entrypoints answer: the API reports requests by route and
status, MCP tool calls by tool, ledger writes by kind, idempotent replays, its
connection pool and how long a transaction holds a connection; the scheduler
reports ticks, proposals, reminder sweeps and mail. component="api" or
component="scheduler" sits on every series, so a split deployment scrapes both
and the two never collide. Node's own heap, event-loop lag and garbage
collection come with it.
Nothing in a metric names a person. No label carries a user, an email, an
account or an amount, and a path with an id in it is counted under its route
pattern, so a ledger with ten thousand transactions is one time series rather
than ten thousand. What a scrape does say is how much a deployment is doing,
which is what METRICS_TOKEN is for: set it and the endpoint answers only a
request carrying Authorization: Bearer, leave it unset behind a private
network and a scraper needs no configuration at all. The bundled frontend does
not proxy /metrics, so the browser's own hostname never exposes it, and
turning it on in production without a token says so once in the log.
The Helm chart carries config.metrics.enabled and secret.metricsToken, and
docs/deployment.md has a scrape config for the two containers.
LOG_LEVEL now governs this product's own log lines. It reached exactly one
consumer before — Better Auth's logger — while the thirty-one console calls in
src/server ignored it, so a deployment asking for error still got the
startup banner, the mail notice and the scheduler's warnings. error is quiet
now, and is never itself silenced. The three places that warn about
configuration keep writing directly, because the gate has to read the
configuration to know the level and a warning about a setting cannot be gated by
one. Two more sat outside it in a shape nothing was looking for: the graceful
shutdown and the recurrence scheduler took logger = console as a default
parameter, so "SIGTERM received, shutting down" printed at every level including
the one chosen to silence it.
The log now says what the product is doing, and not only that it started.
debug adds a line per HTTP request, per MCP tool call and per message handed
to the relay; info gains a line per scheduler tick that proposed a row or sent
a reminder, and a tick that found nothing due drops to debug. The gap that
closes is a deployment with /metrics off — the default — where a scheduler
that stopped ticking a week ago produced exactly the log of one that was ticking
every five minutes.
None of those lines carries somebody's ledger. A request names its path and
never its query string, a tool call names the tool and never its arguments, a
message names what it was and never who it went to, and a failing query names
the statement and never the values bound into it. That last one was already true
of the HTTP path and was not true of the MCP path, which logged the error whole
— including, on a database hiccup during token exchange, a live access token....
v0.1.5
Added
A dark theme, and a light one, either chosen or left to the machine.
The default is to follow the machine, and that is a standing instruction rather
than a value somebody was assigned: it keeps following, so a screen that goes dark
in the evening takes the app with it. Nothing detects the setting once and stores
the answer, which sounds like the same thing and is not. A stored answer cannot be
told apart from a decision, so the app could either follow the machine or remember
a choice, never both — and the mechanism that would have written it only fires for
an account that has never chosen anything, which no existing account qualifies as,
because saving a timezone counts. Every one of them would have upgraded into a
light app on a dark machine.
So there are three states, not two. Light and Dark stay where you put them.
Follow my system is the default and is the media query, which means it is right on
the first paint with no JavaScript at all. The choice is on the account rather than
in the browser, so it travels to another device, and a theme column defaulting to
system lands every existing account on the honest answer with no backfill to
guess at.
The moon in the sidebar switches it, next to Sign out — the only part of the shell
that already holds controls about this session rather than about the ledger. It is
a plain button that says what it will do, not a switch reporting what is on: a
two-state control cannot honestly report a three-valued setting. Pressing it while
the setting is Follow my system resolves the machine and sets the opposite
explicitly. Settings has the three-way choice, applying as you pick it rather than
on a Save button, because it is the one preference whose result is visible while
you are choosing it and two controls for one value must not be able to disagree.
Painting it before the page paints took a file rather than the usual inline script,
because the Content-Security-Policy here is script-src 'self' with no nonce and
no hash. An inline script would have been refused, reported nowhere — nothing
declares a report endpoint — and would have looked perfect locally, since neither
dev server applies the policy and one of them never serves the shell at all. It
would have reached a release as a flash of the wrong theme on every load.
The stylesheet had one palette written into it in 189 places. It now has two, in
one place each: 57 tokens, every one declared in both themes, with a test that
fails on a colour written anywhere else and on a token given a value in only one
theme — which is the bug that makes half an app unreadable while looking fine to
whoever wrote it. Four literals turned out to be two colours sharing a spelling:
white is both a card and the text on a green button, and only one of those is
still white in the dark. The same split runs through the accent, where the green
that reads as a link is not the green a button is filled with, and in dark the
first has to lift while the second stays dark enough to carry white.
Three repairs to the light theme came out of writing the second one down. Six
greys carrying real text were under the contrast a person needs, the input
placeholder worst at 2.65:1. An input's border was 1.39:1 against the field it
edges, which is not a boundary — and an input here is white on a white card, so
that border is the only thing saying where the field is. It holds 3:1 now, and a
focused field is darker again rather than only greener, which is what it had
briefly become once the resting edge moved up to meet it. And the focus ring took
its contrast from whatever happened to be behind it, because it was
semi-transparent; it is opaque now.
The report palette was worse than it looked. Under simulated deuteranopia its
green and its pink were 1.78 apart as CIEDE2000 measures it, which is to say they
were the same colour, and that shipped in 0.1.4 when six colours became ten. The
two palettes now reach 5.6 and 4.7 by keeping each slot's hue family across both
themes and varying lightness, which is the channel that survives. An honest limit
on that: ten categorical colours cannot all be told apart by somebody with
dichromatic vision, and a search that held hue identity and the contrast a line
needs could not beat about 7 and 4. The remedy is a second channel that is not
colour, which is a change to the charts rather than to the palette. Until then the
legend and the table under every chart carry identity, and both are always there.
Emailed notifications, on a schedule, in two kinds.
A recurrence can say to write when it proposes. One message per proposal however
many rows it holds, naming the dates and pointing at Staged transactions, sent to
the address on the account. It says nothing on a tick that proposes nothing, so a
schedule that has caught up goes quiet rather than arriving every five minutes.
The Recurring list says which recurrences are set to send one.
A template can carry a reminder, which is the other half of the same idea: a
template is filled in by hand, so nothing can make it for you, but something can
tell you it is the day. Once on a date, or repeating on the same schedules a
recurrence offers, and either way at a time of day — the first thing here that
needs more than a date, and it is read on the person's own clock rather than the
server's. The Templates list says which templates have one and whether it
repeats.
A reminder that happens once is a first-class answer rather than a yearly rule
nobody means: its frequency is null, and it refuses the interval and the policies
a repeating rule needs rather than storing leftovers of them. Once sent it says
so and the scheduler stops looking at it.
Both ride the recurrence scheduler's existing tick rather than a loop of their
own, and a backlog collapses into one message: coming back from a week of
downtime brings one reminder, not seven. Neither can be delivered without
SMTP_HOST and MAIL_FROM, and the form says so rather than accepting a setting
that would quietly never fire.
Reports. Six of them, over one date range, per currency and never added across
currencies: net worth and a balance sheet for what the accounts hold, income
against expense and categories for what moved, a cash flow statement for where
the money that can be spent came from and went to, and a trial balance that
totals zero when the books are whole. Each is a preset over one query that
differs by which accounts it reads, whether it reports a period's movement or
the balance it ends on, and how time is bucketed — weekly, monthly, quarterly,
yearly, or not at all. Reachable at /reports and over MCP as get_report.
The category report covers income as well as expense. Spending by category on
the dashboard answers only where money went, and the same question about money
arriving had no answer.
A per-account register: every posting in date order with the balance before and
after it, and the balance the window opens and closes on. It is for finding
mistakes rather than for analysis — where a balance goes wrong, this is the row
it went wrong on. An archived account ends at zero with the postings that closed
it out to equity still in the list. On the account page behind Show register,
fetched only when asked for so the ordinary visit costs nothing, and
get_account_register over MCP.
whoami says whether this deployment can send mail at all. A reminder is stored
whether or not it can, so an agent could set one up and had no way to tell
somebody it would never arrive.
The Reminder and Notifies columns sort, ranked by what they are going to tell
you rather than alphabetically by the badge text: a reminder still to come above
one already sent above none at all.
The reminder section of the template form is laid out like the schedule section
of the recurrence form, which is the same kind of thing and did not look like it.
It was a bare fieldset, so it took the browser's own padding-inline and its
heading sat indented from every other label on the screen, with eleven controls
stacked against each other for want of a gap. It is the same card now, with the
same small-caps heading, and it ends the way schedule ends: with the next five
dates it will actually send on, and the time each goes at. The note explaining
what a reminder does moved up under the checkbox it explains, from the bottom
where it read as a footnote to the weekend policy.
The notifications section of the recurrence form had the same bare fieldset, one
section below the schedule it was supposed to match. And the template form's
description field was half a row wide, alone inside a two-column grid.
Axes on the report charts, which shipped without any. A line that ended higher
than it started was all either chart actually said: there was no value scale
beside it and no date under it, only a caption naming the range. Both now carry
gridlines on round numbers — worked out in scaled integers, so a tick sits
exactly where its own label says it does — and the dates of the periods they
cover, written as that period is named rather than as a full date, so a year of
months fits.
The axis text is HTML rather than drawn into the SVG. The drawing scales to its
panel, so a label inside it would read as twelve pixels wide on a desktop and
four on a phone, which is an axis nobody can read on the device most likely to
need one. How many dates fit comes from measuring the chart rather than from a
breakpoint, because the same chart is wide on the reports page and narrow in a
card at the same viewport.
Reports sits after Recurring in the sidebar rather than after Transactions. The
sidebar reads in order: where the money is and what moved it, then the work
waiting on you, then the things that file and repeat it, then what it all adds up
to. A test holds the order, because an ordering nothing asserts is one a later
edit reorders by accident.
Each account on the overview opens that account. The whole row rather than the
name, because the balance is...
v0.1.4
Added
Recurring transactions. Rent, a salary, a subscription: anything that arrives on
a schedule can be set up once and left. Daily, weekly, monthly or yearly, every
N of those, on a day of the month or on a relative day such as the second
Tuesday or the last Friday. You choose what a schedule anchored to the 31st does
in February, and what happens when a date lands on a weekend.
On each due date it puts an ordinary row in the review queue, dated its own
occurrence rather than the day the scheduler ran, and posts nothing. Leave the
amount out and each proposal waits in the queue for a number, which is what the
electricity bill wants. A recurrence naming an account that has since been
deleted still proposes its row, flagged and saying which field, rather than
failing where nobody would see it. Deleting a recurrence leaves every row it
proposed alone.
Open the menu on any row, on the transactions list or the review queue, and save
it as a recurring transaction, beside where you would save it as a template. The
row fills the form in, and the date it fell on becomes the day of the month the
schedule keeps to, rather than whatever today happens to be. What it does not
carry is the reference it was imported under, for the reason a template does not
carry one: on every proposal, it would make the next real import of that
statement row look like one already seen. Two accounts in different currencies
say so before you save, since the rate belongs to the day it was got and each
proposal has to wait in the queue for it.
The scheduler runs inside the server process and is on by default, so the
documented single container keeps working with nothing added to it. Set
RECURRENCE_SCHEDULER=false to switch it off on replicas that serve the API.
Running several with it on is safe: each recurrence is claimed with for update skip locked, so replicas divide the due list rather than wait on one another,
and a per-occurrence unique key refuses a duplicate proposal even if a claim
were bypassed. Public holidays are not modelled; a business day means Monday to
Friday.
Four settings control it: RECURRENCE_SCHEDULER, RECURRENCE_TICK_SECONDS,
RECURRENCE_CATCH_UP_LIMIT and RECURRENCE_CLAIM_LIMIT. Only the first is
likely to matter to you, and only if you split the deployment up.
A Helm chart under deploy/helm/, a compose file that runs the same split on
one machine under deploy/compose/, and Pulumi programs for EKS and GKE under
deploy/pulumi/. All three tiers autoscale. The chart provisions no database:
raising a PostgreSQL StatefulSet's replica count gives you empty databases
rather than capacity, so it takes a DATABASE_URL and leaves the database to
you.
Set DIRECT_DATABASE_URL when PgBouncer or another transaction pooler sits in
front of PostgreSQL. Every lock the ledger takes is transaction-scoped and suits
transaction pooling exactly; the two that are not, the migration lock and the
first-account claim, use this string to go past the pooler.
Three Dockerfiles under deploy/docker/ split the single container into a
server, an nginx frontend, and a scheduler, for running this under Kubernetes.
Each publishes alongside the single container, on the same tags, as
simple-balance-server, simple-balance-frontend and
simple-balance-scheduler. The single container remains the supported way to
run this. See deployment.
One transaction can now be split across several categories. The grocery receipt
that is partly food, partly household and partly something for the dog is one
entry with three legs, and each leg is attributed to its own category in
spending reports, on category pages, and over the MCP.
A split is the counter-account side of the entry cut into pieces rather than a
second record of the money, so nothing is counted twice: the categories add up
to exactly the cash that left the account. Every existing rule holds unchanged.
The entry still settles to zero in each currency it touches, postings are still
append-only, a correction still costs the difference and nothing more, and
deleting still voids the entry a leg at a time.
Relabelling a leg writes no postings at all, because the label lives on the leg
and the leg's identity does not change when you rename what it is for. Changing
what a leg is worth writes two, which is right: the money was divided
differently.
Splits work on committed transactions, on rows waiting in the staging queue, and
on templates, where the categories are stored and the amounts left for you to
fill in. A CSV export carries a split by category name and reads back as the
same split in a different ledger or a fresh install. Mass editing category or
type is refused on a split rather than flattening it, and the panel says why.
Upgrading runs three new migrations: 0005 for splits, 0006 for recurrences and
0007 for the shared sign-in attempt table. All three are schema changes rather
than data migrations: nothing existing is rewritten, no row is backfilled, and
every figure reads the same the moment they finish.
Changed
The licence is now the GNU Affero General Public License v3.0 only
(AGPL-3.0-only), where it was the LGPL. What changes for somebody running this
is nothing: self-hosting it for yourself, your household or your company was
free before and is free now. What section 13 adds is that offering a modified
version to people over a network entitles those people to that version's source.
Every release up to and including 0.1.3 was published under the LGPL and remains
available under it. This applies from 0.1.4 onward.
The AGPL is a complete licence rather than a set of permissions layered on the
GPL, so the images carry one licence file where they used to carry two, and
COPYING is gone.
Security
NODE_ENV was compared against one string, so any other spelling, and leaving
it unset, read as development: no first-run setup code, no sign-in rate
limiting, no secure cookies, and nothing to say it had happened. It is parsed
against a closed set now, and a process outside production that has been given a
real APP_BASE_URL refuses to start rather than warning about it.
AUTH_SECRET clearing 32 characters said nothing about whether it was secret.
The value this project falls back to outside production and the one
.env.example carried both cleared it, so a deployment could sign every session
with a published string. Production names and refuses them, and the example file
now ships no placeholder to leave in place. If you copied that line, generate a
real one: openssl rand -base64 32.
The agent consent screen read the client name and the scopes it displayed out of
the query string, which is written by whoever wants the grant. A link could show
a familiar client and read-only access while approving a stored request for
something else. Both now come from the record the consent code names.
Guessing the first-run setup code cost nothing: a wrong one was refused before
the rate limiter ever saw the attempt. Attempts are counted per caller now, five
to a fifteen-minute window.
Adding a sign-in password to an account that has only ever used Google took a
session and nothing else, so a borrowed cookie could mint a second permanent
credential in silence. It now needs a session created in the last fifteen
minutes.
Changing or resetting your password revokes every agent's access along with the
approvals behind it. Before, an MCP token an agent already held kept working for
its hour and its refresh token kept minting replacements for a week.
Sign-in attempts were counted in the process, which bounds nothing once more
than one is serving: each replica kept its own tally, so the allowance was
multiplied by the replica count and a guesser only had to spread their attempts.
Both counters, the sign-in one and the first-run setup code, are counted in
PostgreSQL now. A tally in the process still refuses a caller already over the
allowance without asking the database, so a flood does not become a write storm.
Dynamic MCP client registration is unauthenticated and was minting a client
secret for whoever asked, then storing it in the clear. The
storeClientSecret: "hashed" setting never reached it: that one belongs to the
OIDC provider's own register endpoint, and discovery advertises a different one.
Clients register as public now. PKCE was already required and plain challenges
already refused, which is what binds a code to its caller.
stage_csv created categories, brought archived ones back, and widened what
kind of entry a category may carry, all under ledger:stage — the scope for an
agent that may propose and never decide. Those three need ledger:write now;
with only staging authority the row is staged under the category's name and
committing it is what makes the category.
The MCP access token carried the opaque grant token as a readable claim. A JWT
is signed and not encrypted, so a proxy or a log holding one held a credential
good for seven days. It names the grant by row id now, which opens nothing on
its own, and a revoked grant stops working immediately rather than at expiry.
The frontend container served the application shell with no content security
policy, no nosniff and no framing policy, because those headers are set by the
API process the static files never reach. It also appended to X-Forwarded-For
rather than replacing it, against the trust model its own deployment guide
documents, so with TRUST_PROXY=true sign-in attempts counted against an
address the caller chose.
Signing in returned to the path in the address bar unfiltered, and
//elsewhere.example is a legal path that a browser sends to another origin.
Fixed
A staged split can now be found under a category one of its legs names. The
queue's filter read only the ...
v0.1.3
Simple Balance 0.1.3
Templates became a screen of their own, above Import CSV: create, edit, mass
edit, mass delete, and a record of which transactions came from each one. A
template now holds any subset of a transaction, only its name required, and
applying one fills in what it carries and leaves the rest alone, so it works on
an entry that already exists as well as a new one.
An export can be imported anywhere. The account is the one chosen on the import
screen and is never read out of the file, so a file from one ledger loads into
another, into somebody else's, or into a fresh install.
Agents keep pace with the browser: two template bulk tools, a templateId filter,
and a test that fails when a tool advertises a schema its service refuses.
Fixed along the way: a filtered staged mass edit that reached rows outside its
filter, an edit that recorded no template, a loading shimmer animating behind
every card and table row, and two tests that were swallowing the errors they
caused.
Migration 0004 adds a nullable column and an index. A database built at v0.1.2
migrates to it cleanly.
v0.1.2
No schema change and no migration — pull the new image and restart.
Added
Deleting your own account, from Settings. Everything in it goes: accounts, transactions and the postings under them, categories, payees, staged rows, import history, preferences, audit history, sessions, sign-in methods, and every agent you had connected. Nothing is kept and there is no undo.
What will be destroyed is counted and shown before the confirmation — "3 transactions across 1 account, along with 1 category and 3 payees" — and the address on the account has to be typed, because it is the one thing on the screen a stray click cannot produce.
It works by a single delete: every table holding a person's data references auth_user with on delete cascade, so nothing enumerates tables and a table added later cannot be left out of a list nobody remembers to update. The test reads the tables out of information_schema and asserts not one row of the deleted account survives in any of them.
An agent cannot do it. Deleting resolves a session cookie, which an MCP token never becomes. An agent with ledger:write can change and remove entries and all of those are recoverable; deleting the person is not, so it is not a tool.
Fixed
Signing up with Google. Better Auth declares its social callback as the route pattern /callback/:id and hands database hooks that pattern rather than the URL requested, so the policy deciding who may open an account matched nothing and fell through to refusing. First-time Google sign-up failed with unable_to_create_user while linking Google to an existing account kept working, because linking creates no user.
The icon. It was in the built bundle and nothing routed to it: only /assets/* was served as files, so /favicon.svg fell through to the single-page shell and a browser was handed HTML for an image request. The root of the bundle is served now, and the Apple touch icon is a PNG rather than an SVG, which iOS does not accept.
Changed
A new account starts where its owner is. UTC and USD for everybody is wrong in a way that misdates entries: something recorded on a California evening lands on tomorrow in UTC. The timezone comes from the browser, which knows its own exactly, and the currency from the region of its language tag, falling back to USD when the tag names only a language. Nothing is adopted once anybody has chosen.
Nothing user-facing says "local" any more. It is a name for a deployment mode; to somebody signing in it either means nothing or suggests their ledger lives on their own machine.
Settings lays its cards out in columns, so a short panel no longer leaves a stretch of nothing beneath it to line up with the tall one beside it.
docker pull ghcr.io/thtmnisamnstr/simple-balance:0.1.2
Full notes in the changelog.
v0.1.1
Fixes the two things a first real deployment ran into. No schema change and no migration — pull the new image and restart.
Reaching a database on another host over TLS
The deployment guide said to append ?sslmode=require. In libpq that means "encrypt and do not check the certificate"; node-postgres does check it. A self-hosted PostgreSQL almost always presents a certificate it signed itself, so the setting the guide recommended was the one that could not work — it failed with DEPTH_ZERO_SELF_SIGNED_CERT, and Node's advice was to install a root CA that does not exist.
Use ?sslmode=no-verify for a self-signed server. Confirmed against the server's own pg_stat_ssl: that gives TLSv1.3, while omitting sslmode gives plaintext. It encrypts, it just stops checking who signed the certificate.
The guide now lays out all three modes and what each does and does not check, and the startup failure names the setting to use rather than leaving an operator with a certificate error and nowhere to go.
The first-run setup code
The code is read only after ALLOWED_EMAILS has turned an address away, so a rule admitting everyone (*) makes it unreachable. The log printed one anyway and said to use it, sending operators looking for a code the sign-up form never asks for.
It is no longer printed where it cannot be used. Where it is still live the log now says which case applies: the only way in when the rule admits nobody, or the way to claim the instance with an address the rule would turn away. That middle case is real — with a named allowlist, an address the list turns away is refused without the code and admitted with it.
Upgrading
docker pull ghcr.io/thtmnisamnstr/simple-balance:0.1.1
Stop and remove the container, start it again with the same settings. Full notes in the changelog.
v0.1.0
The first release of Simple Balance: self-hosted personal accounting, built so an AI agent can do the tedious parts without being able to do the dangerous ones.
What it is
Double-entry, append-only books. Every transaction settles to zero in each currency it touches, corrections are posted rather than typed over, and deleting reverses instead of erasing. Balances, cash flow, and spending all read the same posting table, so no two figures can disagree about what happened.
Accounts for assets and liabilities in any currency, including crypto at native precision. Deposits, withdrawals, and transfers, with cross-currency conversions that keep the sent and received amounts apart. Bank CSVs land in a review queue before anything counts.
An MCP server over OAuth with separate read, stage, and write scopes, calling the same code the browser does. Tools outside a token's scope are not discoverable. Settings lists every agent you have approved, and revoking one deletes its tokens rather than waiting for them to lapse.
One deployment holds as many people as you let it, each with their own books, and ALLOWED_EMAILS decides who may join.
Running it
docker pull ghcr.io/thtmnisamnstr/simple-balance:0.1.0
PostgreSQL 15 or newer is the only dependency. Point DATABASE_URL at a server and it creates the database, builds the schema, and does nothing if it is already current. See deployment.
Full notes in the changelog.