Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Generic Accounting System

CI

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.

Running it

cargo run --release

There 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.

The window

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.

Searching

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.

Reports

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.
PDF 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.

Update checks

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.

Where the data is kept

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.

Locking the database

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.

What it protects, and what it does not

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.

Backups

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.

Importing a spreadsheet

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.

Currencies and dates

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 run

Amounts 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.

Languages

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:

  1. Copy assets/lang/en.toml. It is the reference file, and it explains its own format in its first twenty lines.
  2. Change code, name and plural at 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.
  3. 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.

Accessibility

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.

When something goes wrong

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.

Tests

cargo test

The 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.

Building elsewhere

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-dev

TLS 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 make

Licence

GNU 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

  • 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 in assets/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.

About

A graphical budgetting tool written in Rustt. It can be set to use SQLlite or MariaDB as a backend

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages