Skip to content

v0.1.4

Choose a tag to compare

@thtmnisamnstr thtmnisamnstr released this 14 Aug 23:52
· 309 commits to main since this release

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 entry's own category, so a split showed in the
count on the category page and then was missing from the list that count links
to.

Merging a category carries recurring transactions through with everything else,
and deleting one now counts them as a use rather than destroying the category
underneath them. Deleting your whole account also names the recurring
transactions it is about to take.

A stored timezone that has stopped being recognisable, after an ICU update or a
hand-edited row, no longer throws when the dashboard works out what day it is.
It falls back to UTC. The value is free text checked only when it was written,
and the scheduler now reads it in a loop that serves everybody, where one bad
row must not be able to stop the rest.

Deleting a category a template refers to, or an account a recurring transaction
names, is refused rather than leaving a template that cannot be saved or a
schedule that proposes a flagged row every month with nothing saying why.
Neither reference has a foreign key, because both live inside JSON.

The audit trail records a split's legs. Relabelling one is a single update to
the leg: it writes no posting and changes no column on the transaction, so
Activity showed a before and after that were identical for the change most worth
looking up later.

A mass edit no longer drops the record of which template a transaction was
started from.

A search containing % or _ searches for those characters instead of treating
them as wildcards, and the search box waits for you to stop typing rather than
querying the ledger on every keystroke.

The review queue shows a staged row's category even when that category has since
been archived, instead of rendering it as Uncategorized with a blank field in
its editor.

One import stages at most what one action can then commit, edit or delete. The
CSV row limit was 25,000 by default and up to a million, while every mass action
caps at 10,000, so a large import produced a queue nothing could clear in one
go. CSV_MAX_ROWS now defaults to 10,000 and cannot be set above it. Lowering
it still works.

"Select all filtered" on a mass edit or delete is refused past 10,000 rows in
the database rather than after every matching row has been read and
fingerprinted in memory, and the CSV preview enforces the same size limit the
import does.

The dashboard runs its three aggregates together instead of one after another,
the CSV export walks the ledger once instead of re-counting it for every page,
and a transaction write asks about the one payee it names rather than grouping
every payee in the ledger.

A CSV export now carries the bank's own reference for each row, and an import
no longer invents one. The file had no column for it, and restoring one wrote
the source ledger's internal id into that field instead, so the check that stops
a statement being imported twice was keying on an identity that means nothing in
the ledger it was read into. Files written by 0.1.3 and earlier still import; their
rows simply carry no reference, which is the honest answer for a file that has
none. One consequence worth knowing: a row with no bank reference, re-imported a
second time under a different account, is no longer refused as a duplicate,
because the invented id that used to catch it is gone.

A transfer keeps its category when an export is restored into another ledger,
and a category whose name begins with =, +, - or @ no longer gains an
apostrophe on every round trip and becomes a second category each time. The
visible column stays safe to open in a spreadsheet; the value the import trusts
travels beside it where nothing rewrites it.

Archiving an account now closes it at zero on the day you archive it, not on the
date of the last transaction it happens to hold. A single closing entry dated
that last transaction left the account holding a balance on every day in
between, while every total had already stopped counting it: a report for a date
in that window showed money in an account you had retired, and restoring it in
that window put the balance back on top of one that had never left. Accounts
archived before this are re-closed once, the first time the server starts, which
is logged. Nothing is deleted and no total changes for an account whose
transactions are all in the past.

A negative number in a mapped Debit or Credit column no longer has its sign
quietly removed. Where the file has both columns the other one is what a
reversal goes in, so the column decides and a sign changes nothing, which is how
nearly every two-column bank export is written. Where it has only one, the sign
is the only way that file could say the other direction and nothing says which
was meant, so the row is refused with the reason rather than staged in whichever
direction the column implied.

A recurring transaction set to a relative day, such as the second Tuesday, no
longer proposes a date that has already passed, and the form's preview shows the
date the scheduler will actually propose first. Typing a negative interval no
longer freezes the tab.

Editing the amount of a transfer between two accounts in the same currency now
works. The form was sending a received amount from a field it never showed, and
the ledger refused every save. A transfer also keeps its category when you edit
it, rather than losing it to a form that has nowhere to display one.

A staged row filed under a category by name keeps that name when you open it to
review it, instead of committing uncategorised.

Two settings changes made at the same time no longer overwrite one another.

The timezone and currency the browser detects are offered only while nobody has
chosen, and that is now decided by the server at the moment of writing rather
than by the page against the session it loaded with. Choosing a timezone in
Settings on one tab, or on another device, while a Simple Balance page is open
elsewhere could previously have that choice replaced by the other page's guess.