- Go 73.9%
- templ 9.6%
- JavaScript 7.2%
- CSS 4.8%
- Shell 3.7%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| data | ||
| dist | ||
| internal | ||
| scripts | ||
| third_party/pkgconfig | ||
| web | ||
| .gitignore | ||
| browser.go | ||
| budget.service | ||
| budget.svg | ||
| build-appimage.sh | ||
| go.mod | ||
| go.sum | ||
| icon_test.go | ||
| install-debian.sh | ||
| install-desktop.sh | ||
| INSTALL_DEBIAN.md | ||
| INSTALL_DESKTOP.md | ||
| main.go | ||
| main_test.go | ||
| Makefile | ||
| network.go | ||
| README.md | ||
| tls_test.go | ||
| uninstall-debian.sh | ||
| VERSION | ||
| window_browser.go | ||
| window_cookies_linux.go | ||
| window_desktop.go | ||
| window_icon_linux.go | ||
| window_menu_other.go | ||
| xdg.go | ||
Budget
A personal budgeting app written in Go: envelope budgeting with accounts, transactions, categories, rules and reports. It reimplements the core of Actual Budget and adds one feature Actual does not have — a budget you set once to four times a month.
Everything lives in a single SQLite file. There is no cloud account, and by default no server to run either — though you can run one copy as a server and point others at it (see Standalone, or a client of a server below).
Running it
make run # http://<your-machine-ip>:5006
It builds the binary, starts the server, and creates the database on first launch. Open one of the addresses it prints. The first visit asks how this copy should run — keeping the budget here, or showing one from a server elsewhere (see Standalone, or a client of a server below). You can answer it from any device that can reach the machine, which is how a headless server is set up: there is no tunnel and no setup code. Until the account exists the install is unclaimed, so anyone who can reach it could claim it — keep it on a network you trust and finish setting it up promptly. A standalone copy then asks you to create a budget (username + password, stored locally with bcrypt), and you land on the budget page with a starter set of category groups and categories. You can change that password later from Settings → Password; changing it signs out any other signed-in devices. From Settings → Two-factor authentication you can also add a second step: scan a QR code with an authenticator app, and every later sign-in asks for a six-digit code. Setup shows a set of one-use recovery codes in case the app is lost.
The sign-in form has a Remember me on this device box. Ticked, that browser keeps the session for a month and stays signed in across restarts; left unticked, the session ends when the browser closes, which is the safer choice on a machine you share. When two-factor authentication is on, the box is on the password step and its answer carries through the code prompt.
The app opens in a dark theme. To switch to light, use Settings → General → Theme.
make run binds to 0.0.0.0, so other machines on your network can reach
it. To keep it to this machine only:
make local # binds to 127.0.0.1
make run ADDR=0.0.0.0:8080 # different port
make run DATA_DIR=~/budget # different data directory
The database is a single file at DATA_DIR/budget.sqlite; back it up by
copying that one file.
Before you expose it beyond localhost
-
Finish the first run before exposing it. Until the budget has an account, anybody who could reach it could take it: say "Server", name themselves and own it from then on. You can do the first run from any device on the network, which is what makes a headless server practical, but keep an unconfigured install on a network you trust and create the account promptly. Once the account exists,
/welcomeis closed; changing the server-or-client choice is done from Settings. -
It is plain HTTP. Your password and your financial data travel unencrypted. That is acceptable on a home network you trust, and not on public wifi or the internet. For anything beyond a trusted LAN, either put a reverse proxy with TLS in front and start the app with
BUDGET_SECURE=1, or let the app do the TLS itself:budget -tls-cert /etc/letsencrypt/live/budget.example.com/fullchain.pem \ -tls-key /etc/letsencrypt/live/budget.example.com/privkey.pemWith a certificate and its key the app answers over HTTPS itself, marks its session cookie secure and tells the browser to stay on HTTPS. The pair can also be given as
BUDGET_TLS_CERTandBUDGET_TLS_KEY. -
Requests are CSRF-protected. Each session carries a random token that must appear in the form or the
X-CSRF-Tokenheader, so another website you visit cannot submit forms to the app while you are signed in. -
Password guessing is throttled. After 10 failed sign-ins an address is refused for 15 minutes; one correct sign-in clears the count. Failures are counted per connecting address, and a client-supplied
X-Forwarded-Foris ignored unless you run behind a proxy you trust (seeBUDGET_TRUST_PROXY). -
Every response is hardened. Pages are sent with a deny-by-default
Content-Security-Policy,X-Content-Type-Options: nosniff,X-Frame-Options: DENYandReferrer-Policy: same-origin, so the app cannot be framed or made to load someone else's code. The policy does not allow scripts to evaluate strings, so an injected script cannot turn text into code. -
Anyone who can reach it can sign in and sees the entire budget. There is a single user account and no read-only mode.
-
Second-factor authentication is available but off by default. Turning it on in Settings → Two-factor authentication makes a password alone insufficient, which is worth doing before the app is reachable from anywhere you do not fully control.
-
A paired copy can be cut off. Every copy that pairs with this budget holds a token that works until it is taken back. Settings → Paired copies lists them and can revoke one, which is what to do when a copy is lost or its folder is read by somebody else.
-
No firewall is configured for you. If the rest of your network should not reach it, use
make localor restrict the port yourself.
Standalone, or a client of a server
Budget runs in one of two modes. On first launch, before anything has been decided, it shows a single page with two choices:
- Run it on this computer — the original behaviour: the budget lives in
DATA_DIR/budget.sqliteand this process serves it, reachable from your network as above. - Connect to a server — this installation keeps a copy of a budget that
lives on another machine. You give the server's address (for example
http://192.168.1.10:5006) and sign in to the budget on it once, with the same username and password used there; that pairs the two, and the budget is copied here to be worked on. There is no second account to create.
A client always keeps a copy, so an address on its own is not an answer: without the sign-in there would be nothing here to work from the moment the server was switched off. If that budget is protected by two-factor authentication — so the password alone cannot pair with it — use Approve from a signed-in browser instead, which opens a page on the server to confirm and only then takes the copy. Either way the copy goes on working while the server is unreachable and catches up with it when it is back. A client copy shows the budget but leaves Settings → Password and Settings → Two-factor authentication to the server, because the copy follows the server's account on every sync.
The choice is remembered in DATA_DIR/client.json. It is asked of a fresh
install, and asked again of an older one that was pointed at a server without
ever pairing with it — such a copy holds no budget and would have nothing to
show once that server went away. An install that already has a budget beside it
is asked too, with that budget named on the page and Server offered as the
answer that keeps it, so nothing is changed by the question appearing. To change
the answer after setup, the signed-in account uses Settings → Server or client, which
asks for the sign-in itself when you choose Client. The /welcome page is only
for the first run: once the account exists it is closed, so the choice
cannot be changed by anyone who merely has access to the machine. While the
question is first open, any device on the network may answer it; the answer
given then is what claims the install.
A folder that already held a budget of its own can still become a client. The
box under the sign-in fields, on either page, moves that budget aside — it is
renamed to budget.sqlite.replaced-<time>, never deleted — and the server's copy
takes its place. Nothing is moved without ticking that box, because replacing a
budget is not a choice to make on somebody's behalf.
The flags say how a headless install runs, and offer the answer on a machine that has a browser:
budget -mode standalone # keep the budget here
budget -server http://192.168.1.10:5006 # pair with another one
BUDGET_NO_BROWSER=1 budget -mode standalone # answer for good, nobody to ask
or set BUDGET_MODE and BUDGET_SERVER. Unless BUDGET_NO_BROWSER says there
is nobody there, -mode only says which answer the setup page offers, because
the choice is the person in front of the machine's to make; nothing is saved
until they say so. -server likewise opens the setup page with that address
already in it, ready for the sign-in, rather than starting a copy with nowhere to
get its budget from. A client listens on loopback only unless you pass an -addr
explicitly, since it is there to serve the person in front of it rather than the
network.
Setting up a headless server
The first question and the first account can be answered from any device on the network, so a server with no screen is set up by opening its address in the browser on your desk:
http://server:5006/ # or whichever address the app printed
There is no tunnel to set up. Until the first account exists, anyone who can
reach that address could claim the budget, so keep the server on a trusted
network and create the account as soon as it starts. An install that nobody can
visit yet can have the first question answered up front with
BUDGET_NO_BROWSER=1 budget -mode standalone; the first account is still
created in a browser from anywhere on the network.
A window of its own
make build && ./budget opens your browser. (The make run, make local and
make dev targets deliberately do not: they set BUDGET_NO_BROWSER=1 and are
meant for a server.) For a client that looks like an application — a window of
its own, with no browser chrome — build the desktop version:
make desktop # or: go build -tags desktop -o budget-desktop .
That build draws the interface with WebKitGTK, so it needs CGO and the webview
development packages (on Debian or Ubuntu, sudo apt install libwebkit2gtk-4.1-dev), and it
pulls in one dependency the plain build keeps out. The plain build stays pure-Go
and headless for exactly that reason — it is what runs on a server with no
desktop — so the two are built separately.
To have it in your applications menu, run one command:
./install-desktop.sh # or: make install-desktop
That builds the windowed client and installs it for you alone — the program as
~/.local/bin/budget-desktop, a Budget entry in the menu, and the icon beside
it — with nothing to do as root. It opens the app exactly as the web version
does, so the first thing it asks is whether this machine keeps the budget itself
or keeps a copy of one on another machine. The budget it keeps lives in
~/.local/share/budget (there being no sensible working directory to put it in
when the menu starts it), and it can be removed again with
./install-desktop.sh --uninstall, which leaves the budget alone.
./install-desktop.sh --system installs it for every user instead, and is the
only form that wants sudo — run as root, the per-user form would install it
for root, which is not the menu anyone is looking at. Remove that shared copy
with sudo ./install-desktop.sh --system --uninstall; the uninstaller checks
for the other forms and says what is still installed.
To carry the windowed client to another machine as one file, build an AppImage:
make appimage # or: ./build-appimage.sh
./dist/Budget-*-x86_64.AppImage
It needs no installation and no root. The image carries the GTK and WebKit
libraries the window draws with, including WebKit's own helper processes — which
is the part that is easy to get wrong, because WebKitGTK looks for them at a
path compiled into itself rather than one the loader can find. It keeps the
budget in ~/.local/share/budget like the installed app. The first build
downloads the AppImage build tools into ~/.cache/budget-appimage; later builds
reuse them. INSTALL_DESKTOP.md has the details.
Other commands
make dev # same as run but via `go run` (rebuilds on next start, not on save)
make desktop # build the windowed client (needs CGO + webview libraries)
make install-desktop # build it and put it in the menu, for this user
make appimage # build a single-file AppImage of the windowed client, no install needed
make test # run the Go test suite
make test-layout check button alignment in a real browser (optional)
make test-color check accent colour (purple → blue) in a real browser (optional)
make check # gofmt + go vet + go test + browser tests
make generate # only after editing web/templates/*.templ
Without make:
go build -o budget . && ./budget
./build-appimage.sh # AppImage, from this Linux machine
Flags: -addr (default 0.0.0.0:5006), -data (default data, or
~/.local/share/budget in the windowed build), -mode
(standalone or client), and -server (a client's server). Where a browser is
about to open, -mode says which answer the setup page offers instead of
answering for the person there; with BUDGET_NO_BROWSER=1 it is the answer.
Env vars work too:
BUDGET_ADDR, BUDGET_DATA, BUDGET_MODE, BUDGET_SERVER, BUDGET_SECURE,
BUDGET_NO_BROWSER. Set BUDGET_TRUST_PROXY=1 only when a reverse proxy you
control sits in front and sets X-Forwarded-For; it is what lets the sign-in
throttle see the real client address instead of the proxy's. The app reads the
last address in the header, which is the hop the proxy appended, so the proxy
must not pass a caller's own chain through unchanged. Without the variable those
headers are ignored. Requires Go 1.26.10+, and no CGO for the default build — the
SQLite driver is pure Go (the optional -tags desktop window does need CGO). The
stylesheets and scripts are embedded in the binary, so it runs with no files
beside it (when a web/static directory is present, as in a checkout, it is
served from disk instead so CSS edits take effect without a rebuild).
Running as a service on Debian
To install Budget as a systemd service that starts on boot and runs as an
unprivileged budget user:
sudo ./install-debian.sh
The installer leaves the server-or-client question open, so the service serves
that page until somebody answers it — on the server itself, or from any device
on its network (see Setting up a headless server).
An
install that nobody can visit yet can have the answer written
into its unit instead with ./install-debian.sh --mode server, or
--mode client --server http://host:5006. See
INSTALL_DEBIAN.md for the rest of the options, the service
layout, updating, uninstalling and troubleshooting.
One to four budgets a month
Go to Settings → Budget periods and pick how many periods a month you want. The page shows only the start-day boxes those periods need: none for a monthly budget, two for a twice-monthly one, and so on. Each budget keeps its own choice, so a second budget can split the month differently; the card names the budget it is editing.
| Frequency | Cutoffs | Periods in October 2026 |
|---|---|---|
| Once a month | — | Oct 1 – Oct 31 |
| Twice a month, 1st/15th | days 1, 15 | Oct 1 – 14, Oct 15 – 31 |
| Three times a month, 1st/11th/21st | days 1, 11, 21 | Oct 1 – 10, Oct 11 – 20, Oct 21 – 31 |
| Four times a month, 1st/8th/15th/22nd | days 1, 8, 15, 22 | Oct 1 – 7, Oct 8 – 14, Oct 15 – 21, Oct 22 – 31 |
| Twice a month, 5th/20th | days 5, 20 | Oct 5 – 19, Oct 20 – Nov 4 |
The cutoff days are configurable and must be strictly increasing within 1–31. A cutoff that a short month does not have (the 31st in February) is clamped, and the period is trimmed so the periods still tile the month without overlapping or leaving a gap.
The budget page shows a whole month, with every period side by side. The
month's periods are one table: the expense categories are listed once, and each
period brings its own Budgeted, Spent and Balance columns under a
heading naming that period (October 15 – 21, say). Spending reads as a
negative amount. A rule separates the groups, and the period in progress is
marked. Below the expense groups, income has its own section with a Received
column for each period, because income categories are not envelopes and have no
budget or balance. One Save budget button stores every period at once. The
‹ › arrows step a month at a time, and the activity list covers every period on
screen.
The month is anchored on the first period's start day, so with the first cutoff set to the 15th and the second to the 25th, the two columns are always the 15th–24th and the 25th–14th — never a straddling slice of the calendar month. A date in the first days of a month belongs to the previous month's last period, so opening the page with no period chosen lands on the month holding today.
Why this is not just "several budgets". Actual's financial engine assumes one period per calendar month: a single monthly balance, the "To Budget" pool and goal progress are all keyed to months. Supporting more periods meant reworking each of those rather than duplicating a page:
- Period keys. A budget row is keyed
YYYY-MMwhen a month has one period andYYYY-MM-1…YYYY-MM-4when it is split. Keys sort chronologically in both forms, so history and navigation need no special cases. Budgets written by older versions with-a/-bkeys are renamed to-1/-2when the database is opened, so their amounts stay put. - Every period's balance stands alone. A row's Balance is what you assigned in that period minus what you spent in it. It never includes an earlier period's leftover or overspending, whether the earlier period is in the same month or the month before. The total at the foot of each period's Balance column is therefore that period's own total. Savings goals are the exception: a goal is a long-term target, so its progress still counts contributions across the whole history.
- Left to budget, one figure per period. Each figure is strictly within its own period: what arrived during it, less what was assigned during it. It is deliberately not a running pool — a running total counted a deposit made on the 2nd as still available in the 16th-31st frame, so a budget with $100 paid in the first period showed "$100 left to budget" in the second as well. The money had not left the account, but it was not that period's to spend, and each period's balance stands on its own, so the figure beside each period has to agree with that. A split month shows one figure per period in the page header, each labelled with the date range it covers; a monthly budget has one period and so shows one. Amounts assigned to an income category do not consume the figure. Account opening balances count once, in the first period on screen. The figures refresh as each amount is saved, without a page reload.
- Cross-month periods. With the 5th/20th layout the second period runs into the next month (Oct 20 – Nov 4). A date always belongs to exactly one period — this is covered by a test that walks every day of a decade.
- Copy previous month. A month is often empty at first, because you budget on payday. Copy takes the most recent earlier month that actually has amounts and fills every period here from the same position there, so a month's whole layout comes across at once. It tells you which month it used and how many periods it copied.
- Changing the count migrates your data by position. Period 1 keeps period 1's amounts, period 2 keeps period 2's, and so on, whatever the month. Going from two periods to four leaves the new third and fourth empty. Going down folds the amounts from periods that no longer exist into the last one kept, keeping the earlier period where both define a category, and reports any amounts that had nowhere to go instead of dropping them silently.
More than one budget
One installation can keep several budgets. Settings → Budgets adds, renames, switches and deletes them. Each budget has its own categories, payees, transactions, budget amounts, rules, schedules and period layout, so it behaves as a separate budget. Accounts are shared by every budget, including their opening balances, because the same money sits behind all of them. A transaction is filed in one budget (or off budget), and can be moved between them from the ledger. The budget switcher in the sidebar changes which one the pages show, and the last budget cannot be deleted: there always has to be one to open.
Phones and small screens
The budget table has three numeric columns per period — seven in total for a twice-a-month budget, and more for three or four — which cannot fit a phone. Below 860px:
- the table card scrolls sideways while the Category column stays pinned, so a number is never orphaned from the name it belongs to while you scroll across to the second period's Balance;
- the table keeps a minimum width so amounts stay legible and the budget inputs stay tappable instead of collapsing to slivers;
- the column header rows give up their vertical pin. This is the one real trade:
making the card scroll sideways is what lets it scroll at all, and a scroll
container becomes the containing block for
position: stickydescendants, so a header still asking to stick to the viewport would cling to the card and scroll away with it. Losing the vertical pin costs little on a phone, and the horizontal pin is what matters there. Above the breakpoint nothing changes — the card is deliberately not a scroll container, which is what keeps the sticky headers working on a desktop; - the page header wraps its actions (the left-to-budget figures, the month arrows) onto their own line rather than pushing the document wider than the screen;
- the navigation stops being a column and becomes a drawer over the page: a
phone has no room for 232px beside the content, so the sidebar is closed on
arrival and slides out over the page when the ☰ button opens it. Tapping the
page, the drawer's own ×, or
Escapecloses it again, and the page behind cannot scroll while it is open. It never opens itself — not on a reload, not after a link, and not when a wide window is narrowed.
TestStickyHeadersHaveNoScrollContainerAbove enforces the pairing: it still
forbids a scroll container in the base .table-card, and if the narrow-screen
rules add one it fails unless they also drop the headers' vertical stick.
Collapsing the navigation
There are two navigations behind the one button, because a desktop and a phone want opposite things from it.
On a desktop the sidebar is a 232px column, and hiding it hands that width back
to the page — which matters most on the budget page, where a split-month table
has seven numeric columns and already scrolls sideways. The choice is remembered
across pages and reloads, and Escape brings the column back while it is
hidden, since its links cannot otherwise be tabbed to.
On a phone the sidebar is a drawer over the page instead: closed on arrival,
opened from the same ☰ button, and closed again by tapping the page behind it,
by the × inside it, or by Escape. It starts closed however the desktop was
left, and that is not remembered — a drawer left open over the content on every
page load, and after every link, would be worse than not having it.
Both need JavaScript, and that is deliberate: the button carries the hidden
attribute in the markup and only sidebar-toggle.js reveals it, so with no
JavaScript there is no dead control on screen. The stylesheet then keeps the
navigation as a bar across the top of the page, which cannot be closed and does
not need to be. Nothing on any page depends on the sidebar being collapsible.
The two layouts cannot both apply: the drawer is matched on data-sidebar,
which the script always sets, and the no-JavaScript bar on its absence.
The toggle is styled separately from the .btn family on purpose. Buttons that
share a line must have the same top edge and height
(scripts/button-align-test.js enforces it), and a page header mixes .btn and
.btn-sm depending on what the page offers, so a fixed size would sit wrong on
half of them.
scripts/sidebar-toggle-test.js drives it in a real DOM — matchMedia is
stubbed, so both widths are covered: hiding, remembering, reloading, Escape,
the drawer opening and closing three ways, crossing the breakpoint, a browser
with no matchMedia, storage that throws, and a page with no shell.
What is implemented
- Budget page — grouped category table with budgeted / spent / balance,
per-category inline editing, period-only balances and overspending, category
goals (
needed= a fixed amount per period,savings= an overall goal spread over the periods left), copy-previous, period navigation and a period preview in settings. - Accounts — checking, savings, credit card, cash, investment, mortgage, loan; starting balances; on/off budget; closed accounts; balances as of any date; year-to-date income and spending.
- Payees — create them by hand or let them appear as you type payee names; rename them (transactions follow, since they point at the payee by id); set a default category that new transactions inherit, or a default transfer account; delete them without touching their transactions.
- Transactions — one Amount box with a Deposit / Withdrawal direction selector rather than a signed number, so a deposit cannot be booked as a withdrawal by missing a minus sign. The feed itself reads like a bank statement: a deposit is positive, a withdrawal negative, and the rows add up to the balance above them. Each row's category and cleared/reconciled status are dropdowns in the feed, so one transaction can be changed without opening the edit form. Also notes, transfers between accounts (both legs linked, deleting one removes both), split transactions, search and filters, and a review queue for uncategorised spending.
- Rules — payee / notes / amount / category / account / date / cleared with
is,contains,one of, regex, comparison and range operators. Rules run when a transaction is saved. A rule's action picks a category by name from a list grouped by section, and the table shows the name rather than the stored id. Test rules previews each rule against the transactions already in the budget — how many it matches and what they would become — before anything is applied, because a rule that matches nothing looks exactly like one that works until you run it. - Reports — net worth over time, assets vs debts, cash flow, spending by category group and top payees.
- Scheduled transactions — rent on the 5th, pay on the third Wednesday, council tax on the last day, a weekly shop every Friday. Three repeat kinds: a day of the month, a weekday occurrence within the month (first through fourth, or last), and every week on a weekday. When a date arrives the transaction is created for real — it counts towards the balance, spends from its category and goes through your rules. Before then it appears as upcoming on the account page without affecting any total.
- Bank sync — connect a bank through SimpleFIN Bridge, choose which bank account feeds which budget account, and let new transactions arrive with the account balance reconciled to the bank's. Imported transactions are marked, run through your rules, and kept in step in the background.
- Money — integer cents end to end; no floats touch an amount.
Bank sync with SimpleFIN
Bank sync uses SimpleFIN Bridge,
a read-only service that holds the bank connection. This app never sees a bank
password; it receives accounts and transactions from the bridge through a token
you can revoke there at any time. An account on the production
bridge.simplefin.org works the same way.
- Open Bank sync in the sidebar (also linked from Settings), choose Connect a bank, and follow the link to create a setup token.
- Paste the token into the app. The token is single-use and is exchanged for an access URL, which is stored only in this budget's database and never shown on a page.
- The first sync creates a budget account for each bank account (or uses an existing account with the same name) and imports the last 90 days. On the Bank sync page each bank account can be pointed at a different budget account, or left unsynced.
- Sync now asks the bridge for new transactions. A standalone server also asks on its own every six hours. Each account's starting balance is moved so that its current balance matches the bank's; transactions themselves are never rewritten. Re-imports are prevented by the bridge's transaction ids.
A client copy does not run bank sync: the server owns the connection, and the imported transactions reach the client through the normal budget sync. The bridge allows 24 requests a day, which the six-hour timer stays well inside; a manual button press is held to one a minute. A first sync can import at most the 90 days one SimpleFIN request can return.
Design notes
internal/money—money.Cents(int64). Two conventions meet here, and mixing them up is how a balance stops matching the rows above it. The ledger stores a transaction with Actual's signs — positive leaves, negative arrives — because the budget engine's income and "to budget" arithmetic depend on them. An account balance is the running total flipped, so money arriving raises it (repo.AccountBalancedoes that). And a transaction is shown flipped again, so a deposit reads positive and a withdrawal negative and the rows add up to the balance, the way a bank statement reads. Only amounts that are signed flows may be shown flipped; a category's spending or a budgeted figure is a magnitude, and flipping one would make it say the opposite.internal/budget— the domain.period.gois the period engine and has no dependencies beyond the date library;calc.gocomputes a budget table through a smallDataSourceinterface, which is why the engine is testable without a database.internal/repo— all SQL lives here. SQLite runs in WAL mode with a single connection; writes that touch several rows go through a transaction.internal/schedule— when a recurring transaction falls due. Pure date arithmetic with no database, so the awkward cases are testable on their own: a 31st in a short month clamps to the 28th rather than skipping the month, and "last" is stored as its own choice because a literal fifth Wednesday does not exist in most months.internal/web— chi router, cookie sessions, one handler per page.internal/front— decides what the listener answers with: the first-run page, the budget itself (standalone), or the copy a client keeps in step with a server. It is what lets one process offer all three and switch between them without a restart, which is what makes "choose on first run" work.internal/configstores that choice beside the database.web/templates— templ components.web/static/css— Actual's own theme files (palette.css,light.css,dark.css, MIT licensed) provide the colour tokens;app.cssbuilds the layout on top of them. The accent scale that Actual ships in purple is renamed--palette-accent*and recoloured to blue, keeping each step's brightness so every contrast ratio is unchanged (checked byscripts/accent-color-test.js). A variableapp.cssuses has to be defined in both theme files: a missing one is not an error the browser reports, it just makes the declaration vanish, soTestEveryCSSVariableIsDefinedchecks everyvar()inapp.css.- htmx drives inline budget editing so a typed amount saves on blur without a full page reload.
Testing what actually renders
Checking the HTML is not enough here. Several bugs in this app were correct
markup that rendered in the wrong place, and an assertion on the markup passed
every time. The worst was the browser's own form { margin-block-end: 1em }:
inside an align-items: center row, the form wrapping a single button centred
its margin box, so its button floated 7px above the ones beside it. So there
is a real browser in the loop:
cd scripts && npm install && npx playwright-core install chromium
cd .. && make test-layout
make test-color
scripts/accent-color-test.js resolves computed styles rather than reading
the stylesheet, because a custom property that fails to resolve looks fine in
the source and wrong on screen: it fails if any element is still painted purple
or if the primary button stops being a readable blue, on every page in both
themes.
scripts/button-align-test.js loads every page and checks that any two buttons
sharing a line have the same top edge and height. It also checks that Settings
and Sign out stay on screen in a short window — the sidebar used to scroll as a
whole, so they disappeared with a long account list. The helpers around it are
for diagnosing the next one of these: layout-probe.js prints the geometry of a
selector, boxtree.js prints the whole box tree (display, alignment, margins)
of a subtree, and shot.js / zoom.js take screenshots, optionally of a single
element at 4x.
The Go suite also asserts the markup half of the fix
(TestButtonFormsAreMarked), so the class cannot silently go missing even where
no browser is available.
Security
The full write-up is in SECURITY.md: what the app protects and how, what it does not protect, how the code is reviewed and how to report a problem. In short:
- The password is hashed with bcrypt and never stored in the clear. Sessions
are random server-side identifiers in an
HttpOnly,SameSite=Laxcookie. - The installation has one account. Its password is what keeps other devices on the network out, and it can change the server-or-client mode from Settings.
- Signed-in forms carry a CSRF token, the first-run forms reject submissions
from another site by checking
Origin, and every response is sent with a deny-by-defaultContent-Security-Policy(withoutunsafe-eval),nosniff,DENYframing and a restrictivePermissions-Policy. - Sign-in and second-factor codes are throttled per address. The first-run pages are open to any device that can reach an unconfigured install, so it can be set up without a tunnel; create the first account promptly, because that account is what closes the window.
- The data directory is created 0700, with the database and its logs at 0600.
- A paired copy holds a token that works until it is revoked, and Settings → Paired copies can take it back. A client keeps a copy of the account row so it can sign the same person in offline; treat its data directory like the server's.
- The app can serve HTTPS itself (
-tls-certand-tls-key), so it does not have to sit behind a proxy to keep a password off the wire.
The honest limits: it is plain HTTP unless you give it a certificate, the single account can change the installation mode, and anybody who can read the data directory has the budget and the account credentials.
Not implemented
This is a focused reimplementation, not a drop-in replacement for Actual. Missing today: CSV/OFX import, the plugin/extension system, native mobile apps, and encryption of the budget file. A client is a copy of one person's budget from a server, kept in step with it row by row; it is not a second, separate budget. The rules engine covers the common operators but not Actual's full expression language. Scheduled transactions cover a day of the month, a weekday within the month and every week; there is no "every N days", yearly or end-of-month-relative-until-date rule yet. Bank sync is SimpleFIN only; Actual's other bank-sync providers are not implemented.
Licence
The theme files under web/static/css come from
actualbudget/actual (MIT). The accent
scale in palette.css has been recoloured from purple to blue and renamed
--palette-accent*; apart from that they are unchanged.