Skip to main content
Budget Tool
← All guides

Budget Tool — Administrator Guide

For: whoever installs Budget Tool, configures it, manages its people, and fixes it when it misbehaves. Covers: version 1.20. Last reviewed 5 September 2026.

Day-to-day use is in the User Guide. Coding conventions are in CLAUDE.md. The API contract is openapi.yaml.


Contents

  1. Installing it
  2. Configuration you must set
  3. Getting the first account in
  4. Demonstration mode
  5. Organisations
  6. Roles, and what each one can do
  7. Managing people
  8. When somebody leaves
  9. Configuring email
  10. Master data
  11. Application settings
  12. Microsoft sign-in (SSO)
  13. API tokens
  14. The audit log
  15. Backups and restore
  16. Troubleshooting

Installing it

Prerequisites

  • PHP 8.3+ with pdo_mysql, intl, mbstring, zip, gd, ctype, xml, sodium, bcmath
  • MySQL 8.0+ or MariaDB
  • Composer

sodium and bcmath are not optional. sodium encrypts two-factor secrets at rest; bcmath does every monetary calculation. Without them the application will not run correctly.

First install

composer install

cp .env .env.local
# Edit .env.local - see "Configuration you must set" below.

php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate

Point your web server's document root at public/.

Upgrading

git pull
composer install --no-dev --optimize-autoloader
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear

Read the migrations before running them on production, and take a database backup first. See Backups and restore.


Configuration you must set

Everything below goes in .env.local, which is not committed. Never edit .env itself for real values — it is the documented template, and it is in the repository.

Setting What it is Consequence of getting it wrong
APP_ENV prod on a live server dev exposes the debug toolbar and profiler to anyone
APP_SECRET A long random string Two-factor secrets are encrypted with a key derived from this. Changing it makes every enrolled user's MFA unverifiable
DATABASE_URL Connection string —
MAILER_DSN Outbound mail transport Left at null://null, password reset silently does nothing
MAILER_FROM The From address on everything sent An address your domain is not authorised to send as will be rejected or spam-filed

APP_SECRET deserves its own warning

Generate it once, back it up with your other secrets, and do not rotate it casually. It is not only a session key here — it derives the encryption key for stored TOTP secrets. If it changes, every user with two-factor enabled must have their MFA reset by an administrator and re-enrol.


Getting the first account in

There are two different "first accounts", and they are created in different ways.

The platform administrator

Whoever operates the installation itself — the person who can see every organisation on it, suspend one, or impersonate a user to help them. There is exactly one to begin with, and a fresh install has none until you create it:

php bin/console app:platform:provision-admin [email protected] "Your" "Name"

A one-time password is emailed to that address. It is not printed, on purpose: a deploy runs in CI or over SSH, and both keep scrollback, so a password on the console is a password in a log everybody can read.

Run it as part of every release if you like. It checks whether the installation already has a platform administrator and does nothing if it does, so it will not create a second one or send a second email.

Signing in with that password takes you straight to a form to choose your own, and nothing else is reachable until you have. If the email never arrives, fix the mail configuration, delete the account, and run the command again — the password cannot be recovered afterwards.

Mail has to work before you run this. Configure MAILER_DSN first, and confirm it with php bin/console app:mail:test.

An organisation's first administrator

Created by whoever registers the organisation at /register. You do not need to do anything: the person who registers becomes their organisation's administrator, and invites their colleagues from there.

The development fixture account

On a development machine, doctrine:fixtures:load seeds accounts with a known shared password so the same login works across projects. Those credentials are development-only, come from a destructive command that wipes the database, and must never exist on a production install.

When the only administrator has left

There is no self-service route back in — deliberately. Someone with database access must promote an existing user:

UPDATE user SET role = 'admin' WHERE email = '[email protected]';

Then clear the cache. This is worth avoiding by always having at least two administrator accounts.

Budget Tool blocks you from deactivating your own account or demoting yourself out of the Administrator role — enforced on the server, not just hidden in the interface — so a single administrator cannot lock everybody out by accident. It cannot stop them from leaving the organisation.


Demonstration mode

Lets somebody try Budget Tool without an account: a View demo button on the sign-in page opens a picker offering Administrator, Finance user and Viewer, each signing straight in with no password.

Off by default. Set APP_DEMO_MODE=1 to turn it on. With it off, the button does not render and /demo/sign-in returns 404 — not 403 — so an instance that never sets the flag gives no sign an endpoint like this exists.

Every demo account can see everything its role would genuinely see, and change nothing. Rather than stripping permissions to nothing — which makes the product look thin — the three seeded accounts ([email protected], [email protected], [email protected]) keep their real permissions, and every write from one of them is refused centrally and explained with a flash message. Reads, and signing out, both still work. Two-factor authentication is not asked of them either, even though it is otherwise required for the Administrator role - a shared account nobody is personally handed cannot sensibly be made to enrol.

Publishing a demo publicly

A demo exposed on the internet needs its seeded password locked, in addition to demo mode being on. The three accounts above are created by doctrine:fixtures:load with the same password every other seeded account gets (see The development fixture account) - fine for local development, but that password is sitting in this repository, so treat it as public the moment a demo is. Locking it closes the gap where somebody signs in at the ordinary /login form with that password instead of through the demo picker, bypassing the read-only enforcement entirely because, as far as the firewall is concerned, that is just an ordinary session.

Run, once fixtures are loaded and before the demo goes live:

php bin/console app:demo:publish

This hashes a random value into each of the three accounts' passwords and discards it - nothing can sign into them at /login afterwards, only /demo/sign-in's own programmatic login, which never checks a password at all.

Run it again after every scheduled reset. A demo needs rebuilding on a schedule regardless of the read-only enforcement — sessions and notification-style state accumulate even when nothing can be written — and doctrine:fixtures:load re-seeds the known password every time it runs. A cron entry for a public demo should run the fixture reload and this command in sequence, not the reload alone.


Organisations

Budget Tool is multi-tenant. An organisation is the isolation boundary: its people see its data and nothing else, ever.

An organisation sits above Company, so one customer can hold several legal entities and still get a group-wide Master Budget rollup per company.

What an organisation owns

Everything. Companies, departments, cost centres, nominal codes, suppliers, budget categories, budget years, budgets, actuals, the audit log, API tokens, users, and the RAG thresholds and Cost Centre label. Two organisations can each have a company called "Head Office" and a 2027 budget year without colliding.

Organisation status

Status Effect
Pending Registered, first administrator has not confirmed their email. Nobody can sign in
Active Normal operation
Suspended Nobody can sign in, and anyone already signed in is signed out on their next request

Suspending is the lever to reach for when an organisation must stop using the product immediately. It takes effect at once, not at the end of their session.

How isolation is enforced

Three layers, deliberately, because they fail differently:

  1. A database column on every table. Each tenant-owned row carries its own organisation, rather than the relationship being derived through a parent.
  2. An automatic query filter, applied per request from the signed-in user and from nothing else — never a URL parameter or a form field.
  3. A check at the controller boundary, on every record resolved from a URL.

Requesting another organisation's record by id returns 404, not 403 — confirming a record exists but is not yours is itself a disclosure, and lets ids be enumerated.

The platform administrator and the console

platform_admin is not "an administrator with more permissions". It belongs to no organisation and is the only role allowed past the filter. It is deliberately granted none of the ordinary budget permissions - it runs the platform, it does not run anybody's budgets.

Signing in as one lands on /platform, not the dashboard: with no organisation of their own there is nothing for the ordinary application to scope to, so it would show every customer's figures added together. The console lists every organisation with its people, budgets, pending invitations and sign-in status.

Suspending an organisation from its page stops everyone in it signing in, and signs out anyone already using it on their next request. Their data is untouched, and reactivating restores access.

Supporting a customer: "View as"

From an organisation's page you can view the application as one of its administrators. This is how to reproduce a problem a customer reports.

  • You see exactly what they see - their permissions, their organisation's data, and nothing wider. The platform console itself becomes unavailable while you are in a support session; that is the mechanism working, not a fault.
  • A banner stays on screen the whole time, naming who you are viewing as. It cannot be dismissed.
  • Entering and leaving are both written to that organisation's audit log, with your name. A customer asking "who looked at our data, and when" can be answered from their own audit log.
  • Only an organisation's administrators can be viewed as, only while the organisation is active, and never another platform administrator.

Use Stop viewing as in the banner to return to the console.

Limitations to know about

  • Organisations are created by registration, not from the console.
  • A platform administrator manages their own password at /platform/account, with a 16-character minimum - longer than an ordinary account, because this one can reach every organisation. Two-factor authentication is set up from the same page, and is required, not optional: every other page redirects back here until it is enabled.

How organisations are created

Anyone can register one at /register. They become its administrator.

Nothing is activated by the form: the organisation is created Pending and the account unverified, and only opening the emailed confirmation link activates either. That is what stops somebody provisioning a usable tenant for an address they do not control — so outbound mail is now load-bearing. With MAILER_DSN unset, nobody can complete registration. Verify it with php bin/console app:mail:test.

Abuse controls on that form, since it is the only unauthenticated route that creates records:

  • Five attempts per hour per IP address.
  • Disposable and temporary email domains refused.
  • A registration attempt with an address that already exists behaves identically to a new one, so the form cannot be used to enumerate accounts. It creates nothing, and logs the attempt at info level.
  • The first administrator needs a 12-character password.

To suspend an organisation — abuse, non-payment, or a customer leaving — set its status to Suspended. Everyone in it is signed out on their next request.


Roles, and what each one can do

Every user has exactly one role.

Roles below are all scoped to one organisation. The separate platform_admin role is described above and is not assignable from within an organisation.

Administrator Finance User Budget Owner Read Only
View dashboards and reports ✅ ✅ ✅ ✅
Export reports to CSV ✅ ✅ ✅ ✅
Create and edit budgets and lines ✅ ✅ ✅ —
Import budget lines ✅ ✅ ✅ —
Create and manage budget years ✅ ✅ — —
Import actuals from Xero ✅ ✅ — —
Approve or reject budgets ✅ ✅ — —
Manage master data ✅ — — —
Manage users, reset MFA ✅ — — —
Issue and revoke API tokens ✅ — — —
View the audit log ✅ — — —
Change application settings ✅ — — —

A role change takes effect the next time the person loads a page.

Nobody approves their own work. Whoever created a budget or master budget cannot be named as one of its approvers, whatever role they hold. Plan for at least two people who can approve, or a budget prepared by your only approver cannot be signed off by anybody.

Approving is not the same as being an approver. The role decides whether someone can approve; being named on a budget's Approvers page decides whether they are asked to. Both are required.


Managing people

Adding someone

Invitations is usually the right route: enter their address and role, and they set their own password from an emailed link. Their account is created only when they use it, so an invitation nobody opens leaves no dormant account behind.

Invitations → Bulk invite from CSV does a whole team at once, and shows you exactly who would and would not be invited before anything is sent.

Users → New User still exists for creating an account directly, where you need to set the password yourself.

An address that already has a Budget Tool account anywhere cannot be invited: one person holds one account, in one organisation. This is refused when the invitation is sent rather than when it is accepted, so nobody is left with a link that cannot work.

Resend issues a fresh link and stops the previous one working immediately — that is the way to cut off an invitation sent to the wrong address. Revoke cancels it outright.

When someone leaves

Deactivate rather than delete. Deactivating blocks sign-in immediately — it is checked during authentication, not merely hidden in the interface — while keeping their name against everything they did in the audit trail and the approval history. Deleting would orphan that history.

Deactivating a user also invalidates their API tokens immediately. If they had set up an integration, it stops working at that moment. Check the API Tokens page before deactivating someone who owned one, and reissue it under an account that is staying.

Resetting somebody's two-factor authentication

Users → find them → Reset MFA. They will be prompted to enrol again on next sign-in, and their old recovery codes are discarded at the same time. If they hold the Administrator role, "prompted" is not optional - see below.

Verify who you are talking to before doing this. Resetting MFA on request from an email is exactly how an account takeover starts.

Two-factor authentication is required for Administrators

Every Administrator and Platform Administrator account must have two-factor authentication enabled. It is not merely encouraged: signing in without it lands on Settings (or, for a platform administrator, My account) with every other page redirected there until it is set up. The banner on that page explains why, so it does not read as the application being broken.

This applies from the moment an account is promoted to Administrator, or from first sign-in for a freshly provisioned platform administrator - there is no grace period. Set up is the same "Set Up Two-Factor Authentication" flow described under Settings in the User Guide, and finishes by showing ten recovery codes exactly once.

Every other role can enable two-factor authentication from Settings if they choose to, but is never forced to.


When somebody leaves

Three different actions, and choosing the wrong one is the mistake worth avoiding. They are deliberately separate controls.

Action Use it when Reversible Who
Deactivate Somebody is away, or you want to suspend access briefly Yes Administrator
Remove Somebody has left Yes, from Removed users Administrator, or the person themselves
Erase Somebody has asked for their personal data to be deleted No Administrator

Removing somebody

Users -> Remove, next to their name. They are signed out immediately - including any session they already have open - and cannot sign in again.

Everything they created stays exactly as it was.

Restoring somebody

Users -> Removed users lists everyone removed, who removed them and when. Select Restore and their access comes back.

Erasing somebody

Only from Removed users, so it cannot be reached by accident, and only after they have been removed. You are asked to type their email address in full.

What is removed: their name, email address, password and two-factor setup; every invitation ever sent to that address; and their name on every entry in the audit log.

What stays, and why: budgets, forecasts, imports and approvals, shown against a removed user. These are your organisation's financial records rather than the individual's, and you are likely required to keep them accurate. An approval with nobody attached, or a budget whose authorship vanished, is not an accurate record. The audit entries survive too, with the name replaced - otherwise closing an account would be a way of erasing what was done with it.

This is a lawful position rather than a limitation to apologise for, and the privacy notice explains it to the person asking.

The last administrator cannot be removed

Budget Tool refuses to remove or erase the only remaining administrator, because that would leave the organisation with live data and nobody able to manage it. Appoint another administrator first. The check runs on the server, so it cannot be worked around.

People can close their own accounts

Anyone can close their own account from Settings, without asking you. It behaves exactly as removal does, and appears on your Removed users screen with "Themselves" as who removed it - so you can restore them if it was a mistake.


Configuring email

Password reset is the only thing Budget Tool sends today, but it is the difference between a self-service reset and a support call every time.

Setting the transport

Set MAILER_DSN in .env.local. Common forms:

# A local mail catcher, for testing
MAILER_DSN=smtp://127.0.0.1:1025

# Ordinary SMTP
MAILER_DSN=smtp://USER:[email protected]:587

# Microsoft 365, using a dedicated sending account
MAILER_DSN=smtp://USER:[email protected]:587

# SendGrid
MAILER_DSN=sendgrid+smtp://APIKEY@default

Set MAILER_FROM to the address messages come from, for example MAILER_FROM="Budget Tool <[email protected]>".

Verifying it works

php bin/console app:mail:test [email protected]

It prints the transport it is using (with any password redacted), sends a test message, and tells you plainly if the transport refused it. It fails rather than reporting success when MAILER_DSN is still null://null.

The transport accepting a message is not proof of delivery. Confirm the message actually arrived, and check the spam folder before concluding it did not.

Domain authentication

Mail sent as your domain needs SPF and DKIM records published for whatever service is doing the sending, and ideally a DMARC policy. Without them, mail from a new sending service is routinely rejected or spam-filed even when the transport is configured perfectly. This is a DNS change, not an application setting.

In development

MAILER_DSN defaults to null://null, so nothing leaves the machine. So the reset flow can still be tested, the reset link is written to var/log/dev.log in the dev and test environments only.

It is never logged in production. The link contains a working credential — anyone with log access could otherwise take over any account.


Sending mail from your own domain

Settings -> Email delivery. By default Budget Tool sends invitations and password resets on your behalf from the platform's own address. Point it at your own provider and they arrive from your domain instead - which materially affects whether an invitation gets opened rather than reported as phishing.

Setting it up

  1. Create a send-only API key or SMTP credential at your provider. Not one that can read contacts or change account settings.
  2. Paste the connection string, set the from-address, sender name and optionally a reply-to.
  3. Save, then send a test message.

The connection string is encrypted before storage and never shown again. Leave the field blank when editing anything else and it keeps what you saved.

Authenticate your domain first

Sending as your own domain only works if receiving mail servers believe you. Three DNS records decide it, and they are yours to add:

  • SPF - authorises your provider to send for the domain.
  • DKIM - signs messages so they cannot be altered or forged.
  • DMARC - tells receivers what to do when the other two fail.

The commonest cause of "it goes to spam" is a from-address that does not match the authenticated domain. Do this before relying on it.

Saving is not enabling

Your settings are not used until a test message has actually been delivered. A saved-but-broken setting would mean the first real email - an invitation, a password reset - vanishing, with the person who needed it never knowing.

Changing the provider details puts it back to unverified.

If your provider stops working

Budget Tool will not quietly send from its own address instead. That would put your mail through a provider you did not choose, from a domain that is not yours. The send fails, the provider's own reason appears at the top of the settings page, and your configuration drops back to unverified until a test succeeds again.

Client keys get rotated and revoked without anyone telling the application, so re-test after any change at your provider.


Master data

Master Data holds the reference records everything else points at: Companies, Departments, Cost Centres, Nominal Codes, Budget Categories and Suppliers.

Two rules worth knowing:

  • Imports never create master data. A budget or actuals import that references an unknown nominal code or supplier flags the row and leaves it out. This is deliberate — auto-creating would turn a reference list into a pile of near-duplicates within one import.
  • Deactivate rather than delete anything already referenced. Deactivating removes it from new entry while leaving history intact.

Nominal codes decide the P&L

Whether a nominal code is revenue or cost determines which side of the Profit & Loss report a line lands on. Settle this before the first big import; correcting it later means re-checking every line that used the code.

Supplier names must match Xero

Actuals import matches suppliers by name. A supplier recorded here under a different name than Xero uses will leave its transactions unmatched, and unmatched spend is excluded from variance figures. This is the most common cause of "the report is missing spend".


Application settings

Settings, for administrators, carries two app-wide options.

RAG variance thresholds

The percentages that decide Green, Amber and Red. Set them to what your organisation actually treats as material — the defaults are a starting point, not a recommendation.

The Cost Centre label

"Cost Centre" can be renamed throughout the application — to "Clinic", "Site", "Practice" or anything else — without changing the underlying data.

This changes the wording in the interface, filters and in-app help. It does not change the API: /api/v1/cost-centres and the costCentre field keep their names, so an integration does not break when you rename a label.


Microsoft sign-in (SSO)

Admin -> Microsoft Sign-in. Off by default. Your organisation registers its own app in your own Azure tenant, so no credential is shared with anyone else and you control access and revocation from your side.

Setting it up

  1. Azure portal -> Microsoft Entra ID -> App registrations -> New registration.
  2. Accounts in this organizational directory only - it does not need to be multi-tenant, because the registration is yours.
  3. Redirect URI type Web, set to the exact address shown on the settings page. Getting this wrong is the most common failure, and Microsoft's error does not say so.
  4. Copy the Directory (tenant) ID and Application (client) ID.
  5. Certificates & secrets -> new client secret -> copy its Value immediately. Azure shows it once.
  6. Paste all three into Budget Tool, add your email domains, save.
  7. Run the test sign-in.

Saving is not enabling

A saved configuration does nothing until a real test sign-in through it has succeeded. This is deliberate: a mistyped secret that silently disabled everybody's sign-in would be far worse than an obviously unfinished setup.

Any change to the tenant ID, client ID or secret puts it back to unverified and you must test again.

Requiring Microsoft sign-in

You can turn password sign-in off, so people must use Microsoft. That setting is ignored while SSO is not verified and working - a broken or half-finished configuration can never lock your organisation out of its own application.

What it does not do

  • It does not create accounts. Somebody signing in with Microsoft must already have been invited. Otherwise every person in your directory would silently become a Budget Tool user.
  • Budget Tool's own two-factor does not apply to them. They are subject to whatever MFA your Microsoft tenant enforces, which is the point of federating.

When it stops working

Client secrets expire - Azure defaults to 6 or 24 months. When one does, Microsoft sign-in fails, the reason appears at the top of the settings page, the configuration is marked unverified, and everybody falls back to passwords. Put a rotation reminder in your calendar.

Any failure is recorded with its reason on the settings page and logged at error level. Nothing fails silently.


API tokens

Other applications read from Budget Tool over its API. Admin → API Tokens issues and revokes the credentials.

Issuing one

  1. Name it after the calling application, not a person — "Planner nightly sync", not "Michael's token".
  2. Grant only the scopes it needs:
    • budgets:read — budget years, budgets, budget lines
    • reports:read — the variance report
    • master-data:read — companies, departments, cost centres, nominal codes, suppliers
  3. Set an expiry. Every token expires; the maximum is two years.
  4. Copy the token immediately. Only a hash is stored, so it cannot be shown again. A lost token must be revoked and replaced.

Rules worth holding to

  • One token per integration. A shared token cannot be rotated or revoked without an outage everywhere at once.
  • Requests run as the token's owner, so that user's own permissions still apply on top of the scopes. A scope can only narrow what the owner can see, never widen it.
  • Deactivating the owner kills the token. Issue tokens from an account that is not going to be deactivated — a service account rather than an individual, where you can.
  • Rotate on a schedule you have actually tested. An untested rotation plan is not a plan.

Revoking takes effect immediately: anything using that token starts receiving 401 on its next call.

The full contract, including the error shape and rate limits, is in openapi.yaml.


The audit log

Admin → Audit Log records every sensitive action — creates, updates, deletes, approvals, imports, MFA changes, token issue and revocation — with a before/after record of what changed, who did it, and when. It is filterable by user, action, entity and date.

Passwords, TOTP secrets and API tokens are never written into it. An audit trail that records credentials is a credential store.

The audit log is your first stop for "who changed this?" and for any access review an auditor asks for.


Backups and restore

Budget Tool holds the budget, the approval history and the audit trail. Losing the database loses all three.

  • Back up the database on whatever schedule your data-retention policy requires, and keep at least one copy off the machine that runs the application.
  • Back up .env.local separately and securely. It holds APP_SECRET, and a database restored without the matching secret has unusable two-factor secrets for every enrolled user.
  • Test a restore. A backup nobody has restored from is a hypothesis. Restore into a scratch database, run doctrine:schema:validate, and sign in.

Troubleshooting

Symptom Where to look
Password reset emails never arrive Run php bin/console app:mail:test. If it reports null://null, MAILER_DSN is unset. If the transport refuses it, the error is printed. If it succeeds but nothing arrives, check SPF/DKIM and the spam folder.
Two-factor codes stopped working for everyone APP_SECRET has changed. Restore the original from your secrets backup, or reset MFA for every affected user.
A user cannot sign in Check they are still Active on the Users page. Deactivation is enforced at authentication. Then check their organisation's status — a Pending or Suspended organisation blocks everybody in it.
Everybody in one organisation is locked out That organisation is Pending or Suspended.
Reports are missing spend The unmatched queue on the Actuals page. Unmatched transactions are excluded from variance until resolved.
Mail stopped being delivered for one organisation Their Email delivery settings page shows the provider's own reason at the top. Most often an expired or revoked key. Nothing falls back to the platform address, by design.
Microsoft sign-in stopped working Check the settings page - the reason is at the top. The most likely cause is an expired client secret. Everyone can still sign in with a password meanwhile.
An integration started getting 401s Check the API Tokens page: the token may be revoked, expired, or its owning user deactivated.
A page errors after an upgrade Did migrations run? php bin/console doctrine:migrations:migrate, then doctrine:schema:validate. Then clear the cache.
Changes are not appearing php bin/console cache:clear. In prod the container is compiled and cached.

Where the logs are

var/log/ — prod.log on a live server, dev.log locally. Mail failures are logged at error level, so they will reach alerting if you have any. Rate-limit refusals are logged at warning.

An API error response carries a correlationId. That id appears in the server log alongside the detail the response deliberately does not include — ask for it when someone reports an API problem.

Guides Get help Accessibility statement Privacy notice