A small graphical budgeting app: put your spending into categories, see what each has cost you so far this year, and write the year out as a report you can keep, send or print. Written in Rust, with egui for the interface.
What changed in each release is in CHANGELOG.md.
Everything you read or type — the currency symbol, where it sits, how digits
are grouped, the order of the parts of a date — comes from your system locale.
In the United Kingdom that means £1,234.56 and 11/08/2026; in Germany the
same figures are 1.234,56 € and 11.08.2026.
The words are a separate question from the figures, and are chosen separately: the app ships in English and French, follows your system language by default, and can be switched to either on the Settings tab. Someone running the app in French in Canada gets French words and Canadian dollars. Adding a third language needs no programming and no rebuild — see Languages below.
cargo run --releaseThere is nothing to configure first: on the first run the app creates a SQLite file under your data directory and puts a starting set of categories in it, so the first thing you record has somewhere to go.
Or take a build from the releases
page: a .app for Apple
silicon macOS, a .zip for Windows, and a .deb for Debian, Ubuntu and their
derivatives. On macOS, drag Generic Accounting System out of the archive
and into Applications.
The first time you open it, macOS will say it cannot check the app for malicious software, and offer only Done. That is not a judgement about this app: it is what macOS says about everything that has not been through Apple's paid notarisation, which this project has not. To open it anyway, go to System Settings → Privacy & Security, scroll to the bottom, and press Open Anyway beside the app's name. You are asked once, not every time.
Seven tabs run down the left-hand side, and the pane on the right shows the one you have picked.
Categories lists every category with the total spent in it this calendar year — the name on the left, the amount right-aligned in your currency — followed by a grand total. The Add New Category button beneath the table opens a box asking for the new category's name. Names must be unique, and a name that only differs in capitalisation is not a new name.
A new database arrives with six categories already in it — Bills and Utilities, Entertainment, Social Events, Food and Drink, Web Services and Transport — in whatever language the app is running in when it is created. They are ordinary categories from that moment on: rename them, delete the ones you have no use for, add your own beside them. Deleting one is the end of it; nothing puts it back the next time you open the app.
Spending is the form for recording what you spent: the date (which starts on today), the category, the amount, and an optional description. Nothing is recorded until every field makes sense, and if something does not, the form says what and why.
Reports writes a year out as a file: pick the year, pick the format, choose whether to include the month-by-month table and the itemised entries, and save.
Entries lists a year's entries, oldest first, with Edit and Delete on each row. A search box narrows the list as you type, a picker narrows it to one category, and a stepper moves between years — see below.
Chart View draws a year as a column a month: the month along the bottom, the money up the side. It answers the question a table of figures is bad at — what shape the year is — and it is one series in one colour, with the biggest month carrying the only printed figure and the rest read off the axis or by pointing at a column. A month that was a net refund hangs below the zero line rather than being left out. In the year your history begins, the chart begins with it: the months before your first entry are not months you spent nothing in, so they are not drawn. Every year after that is drawn whole, empty months and all.
Database chooses where all of this is kept, backs it up, and imports a spreadsheet of spending into it — see below.
Settings holds the language, the light/dark choice, and whether the app looks for a new version at startup.
Every action either works or does not, and says so twice: a message along the bottom of the window, and one of two sounds.
The Entries tab searches the year it is showing. Typing narrows the list as
you go, a picker beside the box narrows it to a single category, and the two
narrow together — pick Travel and type hotel and you get hotels in Travel.
The line above the table says how many rows of how many are showing and what
they come to, so a search doubles as a way of totalling one supplier or one
trip.
The search matches the row as it is drawn, which is the whole rule: the
date, the category, the amount and the description, each in the form your own
screen shows it. In Britain 14/03 and 128.00 find that row; in Germany the
same row answers to 14.03 and 128,00. Nothing off-screen is searchable, and
nothing on-screen is not.
Several words all have to match, in any order, so hotel leeds finds a
description of "Leeds hotel". Case never matters, in any language.
The year stepper is the same control the Reports tab uses. The year the app records into is always the real one — stepping back to look something up does not move it — and the Entries tab is the only place that year is a choice.
| Format | What it is for |
|---|---|
| CSV | A spreadsheet. Amounts are plain numbers it can total, the delimiter follows your locale, and the file carries a byte order mark so Excel shows £ rather than £. |
| Word | A .docx with the tables laid out for reading, headers that repeat across pages, and A4 page setup. |
| Written directly, with its own font embedded, so it looks the same wherever it is opened — no browser or printer driver involved. | |
| JSON | The figures as data. Amounts appear as integer minor units as well as formatted strings, and dates are ISO whatever your locale shows. |
Every format is built from one reading of the year's entries, so two of them made a second apart cannot disagree. Text you typed is escaped on the way into the Word format, and anything a spreadsheet would treat as a formula is defused on the way into CSV.
At startup the app asks GitHub whether there is a newer release. If there is, it says so once and offers to open the release page. It downloads nothing, installs nothing and runs nothing — everything past that point is your doing.
Dismissing a version means never being asked about that one again, and the check can be turned off entirely from the same box. When it is off, or when there is no network, the app says nothing at all.
SQLite (the default) is a single file, by default at:
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/GenericAccountingSystem/accounts.sqlite |
| Linux | ~/.local/share/GenericAccountingSystem/accounts.sqlite |
| Windows | %APPDATA%\GenericAccountingSystem\accounts.sqlite |
You can point the app at a different file from the Database tab; it is created if it is not there yet.
MariaDB (or MySQL) is the other option, for when several machines — or
several people — should share one set of figures. Fill in the host, port,
database, user name and password, optionally turn on TLS, and use Test
Connection before committing to it. The tables are created on first
connection, so the login needs CREATE, SELECT, INSERT and REFERENCES
(REFERENCES is required separately for the foreign key between the two
tables, rather than folded into CREATE) — see
docs/mariadb-schema.sql for the exact tables and a
grant statement to start from.
The app carries its own list of certificate authorities rather than reading the
one the operating system keeps, so it behaves the same on all three platforms —
but a database server whose certificate your own authority issued is not on
that list. Point CA certificate file at the authority's .pem or .der to
have it trusted.
Every category and spending entry is stamped with the login that wrote it, and every read is filtered back down to just that login — so several people can point the app at the same server and each only ever sees their own data, as long as each has their own login. This reuses the database's own authentication rather than the app inventing one of its own: there is no separate sign-in, and nothing stops someone with direct access to the database (or a shared login) from seeing everything, the same as any other database. A database that already has data in it from before this existed gets upgraded the first time anyone connects: the existing rows are handed to whoever connects first, and the app carries on from there.
Connecting happens on a background thread, so a server that is asleep or misspelled costs you a message within five seconds rather than a frozen window, and the app keeps whatever database it already had. Reads and writes during a session are still made on the main thread: against a local file that is imperceptible, but a network that stalls mid-query can still pause the window — MariaDB bounds this to fifteen seconds.
The choice is remembered in config.json next to the default database file.
The MariaDB password is only written there if you tick the box that says so —
that file is plain text, kept readable by you alone, and no more secure than
that.
The SQLite file can be encrypted, from Lock This Database at the bottom of the Database tab. Choose a passphrase and the file is rewritten as an encrypted one; from then on a copy of it is no longer a spending history anyone can open.
Most of the time you will never see the passphrase again. The key is kept in whatever your operating system uses for secrets — DPAPI on Windows, the login keychain on macOS, gnome-keyring or KWallet on Linux — and the app takes it from there at startup without asking, because you are already signed in.
The passphrase is what opens the database when that does not happen: on a computer the file has been carried to, on a Linux desktop running no keyring at all, or after the keychain has been cleared. The app asks for it in a box in its own window. There is no case in which getting back into your own database means using a terminal — that is the point of keeping a passphrase at all rather than only the keychain copy.
If this machine has nowhere to keep a secret, the app says so beside the checkbox and asks for the passphrase each time it starts, which still works.
Change Passphrase is instant however large the database is: the file keeps the key it already had, and only the wrapped copy of that key is rewritten. Remove the Lock turns it back into a plain file. Both rewrite the database beside itself and rename the new file into place only once it has been closed, reopened and read from, so an interruption leaves the original exactly as it was.
It protects a copy of the file that has left this computer, or that another account on it can reach: a backup drive, a memory stick, a laptop somebody else is holding, a file attached to an email.
It does not protect this machine while you are signed in and using it. Anything running under your own login can ask the keychain for the key exactly as the app does. If that is the threat you have in mind, you want your disk encrypted — FileVault, BitLocker, LUKS — which is a different thing and worth having anyway.
Backups stay readable without the passphrase. That is deliberate: a backup encrypted with a key that lives only in this computer's keychain could not be restored onto a new one, which would defeat the reason for taking it. It does mean the JSON a backup writes is your whole spending history in the clear, so keep it somewhere you are happy with.
There is no way to reset a forgotten passphrase, and no copy of it anywhere. If the keychain copy is gone and the passphrase is forgotten, an encrypted database cannot be opened by anybody, including whoever wrote this app. A backup taken before that happens is the way back.
The vault file holding the wrapped key lives beside config.json, not beside
the database — a key that travelled with the file it locks would be a lock with
the key taped to it. The passphrase is put through Argon2id and the key is
wrapped with XChaCha20-Poly1305; the file itself is SQLCipher, which is AES-256
per page.
The lock is for the SQLite file only. A MariaDB database lives on the server, and encrypting it is something the server does.
The Database tab also writes the whole database out to one file, and reads one back. A backup is not a report: a report is one year in one currency, arranged for a reader, whereas a backup is every category and every entry — every year, every currency — exactly as stored.
Back Up Now writes spending-backup-2026-08-29.json into the folder you
name. The name carries the date, so taking a backup twice keeps both rather
than writing over the older one. Nothing in the file comes from your locale:
dates are ISO and amounts are whole minor units, so a backup taken on a machine
set to Berlin restores onto one set to London and means the same figures.
Restore This Backup replaces everything. The file is read and checked in full first — the marker that says it is a backup at all, the format number, every date, every amount, every currency code, and that each entry names a category the file lists — so a damaged or mistaken file is a sentence on screen rather than an emptied database. Only then are you asked, and the box says both what the file holds and what is about to be replaced, each as a count rather than as "everything". Handing a report to Restore by mistake is caught by name, which matters because both are JSON and both land in the same folder.
The restore itself is one transaction: either the database ends up holding exactly what the backup held, or it is left exactly as it was. There is no outcome where it holds half of each.
On a MariaDB server shared by several logins, both halves are scoped to yours, the same as every other read and write: a backup holds your figures alone, and restoring one replaces your figures alone.
The same tab reads figures that were never in this app: a bank's download, or the spreadsheet you kept before you had it. Importing adds — nothing already in the database is removed or changed, which is what makes it a different button from Restore rather than a gentler one.
Give it a CSV file with a heading row naming its columns: Date, Category and Amount, plus Description if you have one. The columns may be in any order, since no two banks agree on one, and anything above the heading row is skipped — so a CSV the Reports tab wrote, summary block and all, reads straight back in. Commas, semicolons and tabs are all understood, quoted fields work the way every spreadsheet writes them, and the heading may be in English or in the language the app is running in.
Dates and amounts are written the way you write them everywhere else in the
app — DD/MM/YYYY and 12.34 in London, DD.MM.YYYY and 12,34 in Berlin —
and YYYY-MM-DD is read anywhere, since that is what half the world's exports
produce and no region reads it as anything else. Everything is recorded in your
own currency, exactly as the Spending form records it.
The whole file is read and checked before anything is written, so a row that makes no sense on line 40 is a sentence naming line 40 rather than 39 rows in the database and no way to tell which. A category the file names that you have not got is created; one you already have is used as it stands, whatever the capitalisation. Then you are asked, and the box says how many entries are about to be added, how many categories will be created, and how many of the rows match something already recorded — because importing last month's download on top of this month's is the mistake this invites, and the overlap is counted and said out loud rather than silently dropped. Two identical purchases on one day are an ordinary Tuesday, so nothing is skipped for you.
The import itself is one transaction, the same as a restore: either all of it lands or none of it does.
The locale is read from the operating system. Around sixty regions are
described in src/locale.rs, covering currency, symbol placement, digit
grouping (including the Indian lakh/crore convention), decimal separator and
date order. A locale that is not in that table falls back to ISO dates and the
generic currency sign ¤ rather than guessing at a currency.
To see the app as another locale would:
GAS_LOCALE=de-DE cargo runAmounts are stored as whole minor units — pence, cents, whole yen — never as floating point, and each entry is stored with the currency it was recorded in. If you move a database between machines with different locales, entries in the other currency are counted and mentioned under the table rather than being silently added to a total they do not belong in.
The app is in English and French. Which one it uses follows your system language unless you choose otherwise on the Settings tab, and the change takes effect at once — there is no restart.
A language is a plain text file, so adding one needs no programming:
- Copy
assets/lang/en.toml. It is the reference file, and it explains its own format in its first twenty lines. - Change
code,nameandpluralat the top, and translate the text to the right of each=. Leave the keys alone, and keep the{braced}words — you may move them within a sentence, but not rename them. - Drop it in the languages folder, which Settings names and has a button to open. Press Re-read the Files and it appears in the picker.
You do not have to finish before you can use it: any key you have not
translated falls back to English, so the app works from your first line. A file
of the same code as a built-in language replaces it, which is how a shipped
translation gets improved rather than only added to. Whatever the parser could
not make sense of is listed on the Settings tab with line numbers, and the rest
of the file is still used.
Money and dates are deliberately not in the language file. How an amount and a date are written is decided by the region your computer is set to, not by the language you read.
The two shipped languages are held to that standard by tests: every key the app
asks for exists, every line in the file is asked for somewhere, both files use
the same placeholders, every placeholder a message uses is actually filled in
where the app asks for that message — otherwise a sentence reaches you with a
literal {year} in the middle of it, which comparing the two files against
each other cannot catch, since both are equally wrong — every counted message
has all the forms its plural rule needs, and the bundled font can actually draw
every character in both, a translation that arrived as rows of ? being
otherwise invisible to everyone whose own language renders fine.
The interface follows the system light or dark theme, or either one on demand
from the Settings tab — changing the whole desktop is not a reasonable
thing to ask of somebody who wants one window dimmer, and light sensitivity is
an ordinary reason to want a dark window on a light desktop. Both themes are
written down as explicit pairs with the contrast worked out, rather than
inherited: every text colour reaches at least 4.5:1 against the surface it is
drawn on. Colour is never the only signal — every message says in words what
happened. egui exposes the interface through AccessKit,
the window can be zoomed with the usual Ctrl/Cmd and +/-, and every
control can be reached from the keyboard.
Every field and picker is tied to the caption that names it, rather than merely drawn under it, so a screen reader announces what a box is for instead of reading out an unnamed edit box; the few controls whose face is a symbol — the year arrows, the button that clears a search — say what they do rather than naming the character they are drawn as. The Chart View is painted onto a canvas, and so has nothing on it that is text: it carries the year in words as well, the total and every month it draws in order, so that the pane holds the same figures however it is being read.
Most of what can go wrong the app says out loud, along the bottom of the window or beside the field it is about. A few things it carries on through rather than stopping for — a sound file that will not decode, a config file it could not read, a database file whose permissions it could not tighten — and those go to a log instead.
The log is a file, kept beside the config and the default database:
| Platform | Where |
|---|---|
| macOS | ~/Library/Application Support/GenericAccountingSystem/log.txt |
| Linux | ~/.local/share/GenericAccountingSystem/log.txt |
| Windows | %APPDATA%\GenericAccountingSystem\log.txt |
The Database tab prints that path at the bottom, so nobody has to be talked through finding it. If something goes wrong, that file is the thing to send.
Every run adds a header saying which version wrote it and on what, so a log
that recorded no problems at all still identifies the app that produced it. It
holds warnings and nothing else unless asked: set RUST_LOG=info for what the
app decided at startup, or RUST_LOG=debug for everything. It is written to
standard error at the same time, so running from a terminal shows it live.
It is readable only by you, the same as the config file and the database — it
names the paths this app touches and, on a shared server, the host and login it
connects with. It cannot grow without limit either: past a megabyte it is
rotated to log.previous.txt, one copy back, so the pair are bounded.
cargo testThe tests cover the places where being wrong would be expensive: locale
formatting and parsing (including that 1,50 means different amounts in
different places, and that a date is never quietly reinterpreted), the SQLite
queries behind the totals, the MariaDB settings checked before any connection
is attempted, every report format (that the Word file is a valid package, that
user text cannot become markup or a spreadsheet formula — including a negative
amount, which is a number and not a formula — and that the totals in one table
match the totals in the next), the version
comparison behind the update prompt, the entry search (that every column on
screen can be searched for and the row's id cannot, that all terms match in
any order, and that dates and amounts are matched the way the region writes
them), backups (that a backup restores to
exactly what was backed up, that a report handed to Restore is refused, that a
damaged file is refused before the database is touched at all, and that a
restore which fails partway leaves everything as it was), the database lock
(that a passphrase alone opens a database with no keychain involved anywhere,
that the same passphrase wrapped twice does not produce the same file, that
changing the passphrase leaves the database key alone, that a locked database
really is unreadable without the key and comes back intact when unlocked, that
converting under the wrong key leaves the original untouched with no
half-finished copy beside it, and that locking a database does not widen its
permissions), importing (that a
CSV this app wrote reads back into it, that the columns may be named and
ordered any way round, that a quoted description spanning two lines does not
make every later message point at the wrong line, that entries already
recorded are counted rather than dropped, and that an import which fails
partway leaves everything as it was), the language files
(see Languages), that each appearance setting selects the theme
it names, and that both sound files still decode.
Every push is built and tested on Ubuntu, Apple silicon macOS and Windows by the CI workflow, which leaves a release binary for each as a downloadable artifact.
A v* tag runs the release workflow, which
publishes a macOS archive, a Windows archive and a .deb for Debian, Ubuntu
and their derivatives, with a SHA256SUMS covering all three. The macOS
archive holds a .app bundle rather than the bare binary, which is what makes
it an application Finder will open rather than a command line tool that brings
a terminal up with it; packaging/macos builds it, and can
be run by hand against any local cargo build --release. Every platform
has to build before the release is created, so a tag produces either a
complete set of downloads or none at all.
Other Linux distributions build from source, where cargo build --release is
all it takes: a single dynamically linked binary cannot suit the distributions
people actually run, which is a poorer answer than building it yourself. The
.deb is the exception because it declares the libraries it needs and lets
apt or dpkg -i resolve them.
On Linux the window and the sound need a few development packages that the other two platforms already have:
sudo apt-get install libasound2-dev libwayland-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libxkbcommon-x11-devTLS is not among them: the app carries its own (rustls), rather than linking
the system OpenSSL, so the Linux binary does not depend on which version of it
the machine happens to have. Neither is libdbus, which the keychain is
reached through, nor the OpenSSL that SQLCipher wants for the database lock —
both are compiled in for the same reason. That last one means the build needs
perl and make present, since OpenSSL is built from source rather than
found:
sudo apt-get install perl makeGNU General Public License, version 3 or later. The full text is in
LICENSE; the short of it is that you may use, study, change and
share this program, and anything you distribute that is built from it has to
come with the same freedoms and its source.
The two asset licences below are separate from that and unaffected by it.
assets/fonts/Ubuntu-Bold.ttf— the interface typeface, under the Ubuntu Font Licence 1.0 (assets/fonts/UBUNTU-FONT-LICENCE-1.0.txt).assets/sounds/success.wav,assets/sounds/error.wav— the two cues, both CC0. Their origins are credited inassets/sounds/CREDITS.txt.
Both are compiled into the binary, so it has no runtime dependency on the
assets folder. If no audio device is available the app carries on in silence.