Skip to content

Chart of Accounts Developer Guide

Ed Mozley edited this page Nov 12, 2026 · 2 revisions

πŸ“’ Chart of accounts β€” Developer Guide

Since 5.1.0 Β· #282 slice 1 of 5 Β· User guide: Chart of accounts Β· API: REST API: Chart of accounts Β· Design: Projects: a chart of accounts


πŸ”‘ The one-line version

It is Cost centres with a kind column. Every file was made from its cost-centre twin on purpose, so the two lists behave identically for the finance people who keep them - same code rules, same import, same sync, same REST shape. Read the cost-centre guide for the shared rules; this page covers what is different.

πŸ“ Files

File Twin of
βš™οΈ includes/services/accounts.php - AccountsService includes/services/cost_centres.php
πŸ”Œ api/system/accounts.php - the screen's endpoint (list, save, delete, import preview/apply, export) api/system/cost_centres.php
πŸ”Œ api/v1/resources/accounts.php - REST /accounts + /accounts/sync api/v1/resources/cost_centres.php
πŸ–₯️ system/accounts/index.php system/cost-centres/index.php (classes ac- for cc-)
πŸ“„ system/help/accounts.php + its _registry.php entry system/help/cost-centres.php
πŸ—„οΈ accounts in database/freeitsm.sql, includes/db_verify_schema.php, db_verify_indexes.php (uq_accounts_code, ix_accounts_parent), api/system/db_verify.php (fk_accounts_parent SET NULL, fk_accounts_tenant CASCADE), db_verify_modules.php cost_centres
🧩 system/includes/areas.php (tile + ledger icon), lang/en/system.php (system.accounts.*, system.landing.accounts_*), Feature Bingo system.accounts
πŸ”Œ api/v1/lib/routes.php, permissions.php (accounts: read/create/update/delete), modules.php (system), index.php, openapi_schemas.php (Account, AccountSyncResult)
βœ… tests/accounts.php (36, rolled back), tests/accounts-http.php (25, the real endpoints + the page in Chrome) tests/cost-centres.php

The table

CREATE TABLE accounts (
    id INT AUTO_INCREMENT, tenant_id INT NOT NULL,
    code VARCHAR(50) NOT NULL,            -- TEXT: "2.0.3.0.4", "0010"
    name VARCHAR(150) NOT NULL,
    kind VARCHAR(20) NOT NULL DEFAULT 'expense',   -- expense | fixed_asset | payroll | income
    description VARCHAR(500) NULL, parent_id INT NULL,
    is_active TINYINT(1) NOT NULL DEFAULT 1, created_datetime, updated_datetime, is_demo,
    UNIQUE KEY uq_accounts_code (tenant_id, code)   -- unicode_ci: ignores case
);

πŸ”‘ kind

  • AccountsService::KINDS = ['expense', 'fixed_asset', 'payroll', 'income'], in the order the breakdown will show them (slice 5). Stored as these keys.
  • cleanKind() maps words onto keys - Fixed asset, Capex, Salaries, Revenue, AnlagevermΓΆgen, ErlΓΆse... - lower-cased with everything but a-z removed, so the import takes a finance system's own words. An unknown word is invalid_field; blank is missing_field.
  • Required on create (screen, POST /accounts). It can be changed, never emptied.
  • In sync(): a new code with no kind takes the nearest ancestor's kind in the finished list - a parent earlier or later in the same file, or one already there. With no ancestor that has one, the row is an error ("needs a kind ... or a parent to take one from"), and the whole sync writes nothing, as for every error. A blank kind on an existing code leaves it as it is.
  • On screen, choosing a parent for a new account sets the Kind box to the parent's kind.

Traps

  • openapi_schemas.php has hand-written comments - the schemas were added by text insertion (copying the CostCentre blocks), never by round-tripping through var_export. Same rule as cost centres.
  • Delete has no usage check yet. AccountsService::delete() refuses only while an account has children. When budget lines and tasks start naming accounts (slices 2-3), add a usage check there as CostCentresService::delete() has - a TRAP comment marks the spot.
  • Keep the twins in step. A fix to the cost-centre import, sync or code rules almost certainly belongs in the accounts copy too, and the other way round.

Slice 2 - accounts on budget lines (5.1.0)

All the rules are in includes/projects/accounts.php; ProjectToolsService::saveBudgetLine() and setDefaultAccounts() call it, so the Budget tab, api/projects/tools.php and the REST API share them.

πŸ—„οΈ project_budget_line_accounts (line_id, account_id, percent DECIMAL(9,6), position), unique (line_id, account_id), FK line CASCADE, account RESTRICT
πŸ—„οΈ project_budget_lines.account_split percent / amount / NULL - only how the editor shows a split
πŸ—„οΈ projects.labour_account_id, other_account_id the project's own defaults (FK SET NULL)
βš™οΈ settings project_default_labour_account, project_default_other_account the install's defaults as codes (rule account_code)

πŸ”‘ One shape for every case. A line's accounts are always percentage rows: one account = one row at 100, a split = rows adding up to 100. A split by amount is turned into percentages of the line's base - planned, else actual, else forecast - by projectValidateLineAccounts(), so planned, actual and forecast are split alike and slice 5 only ever multiplies by a percentage. If the line's amount changes later, an amount split stays in proportion.

πŸ”‘ Leaving accounts out of a save keeps them. Only a present key ([] included) changes them - so older callers, the change-request line insert and a REST PATCH that only touches notes never wipe a split. A bad split is checked before anything is written.

πŸ”‘ Defaults resolve per project: its own column, else the install setting looked up by code in the project's company (projectDefaultAccounts()), each returned with source project / install. Codes because accounts are per company and the same chart is usually loaded into each.

Delete is refused while in use - AccountsService::delete() asks projectAccountUsage() (budget-line rows and project defaults). TRAP: slice 3 must add task splits there.

Deleting a project removes its lines' account rows first, by hand, like its other child rows (ProjectsService::deleteProject()).

Screen (assets/js/projects-budget.js, projects/view.php): the line dialog's Account part - a select, Split, rows with a percentage/amount switch that converts what is typed, and a live total (green when it adds up). Down to one row, it goes back to the select. The tab's Accounts line shows and changes the two defaults (budget_accounts action). Everything is hidden for a company with no accounts.

Test: tests/project-budget-accounts.php (30) - splits by percentage and amount, every refusal writing nothing, keep / clear, inactive kept, install defaults by code, the project's own winning, history, the tab's data, delete refused, the REST API, project delete cleaning up.

Slice 3 - accounts on tasks (5.1.0)

πŸ—„οΈ task_account_versions (task_id, effective_from DATE) unique, FK task CASCADE - one version per task per day
πŸ—„οΈ task_account_shares (version_id, account_id, percent), FK version CASCADE, account RESTRICT. A version with no shares = none of its own
βš™οΈ setting project_account_resplit all (default) / from_then
πŸ”Œ api/projects/task_accounts.php GET what the task window needs, POST to set - through ProjectToolsService::setTaskAccounts()

πŸ”‘ Dated versions. projectSaveTaskAccounts() writes today's version (replacing one made today, and writing nothing when the split is unchanged). projectTaskSharesOn($versions, $date, $mode) picks the shares for an hour: the latest version (all), or the version in force on that day (from_then) - none before the first version, so earlier hours count in the project's account for time, as they did when logged.

πŸ”‘ Subtasks follow the parent. A subtask has no project_id - ProjectToolsService::taskProject() resolves it through the parent. In projectLabourByAccount() a subtask's own shares win; with none (no version, or a cleared one) its parent's apply, by the same rule.

πŸ”‘ One set of split rules. projectSplitRows($accounts, $split, $base, $allowed) checks sums, duplicates, limits and amount splits for budget lines and tasks; each caller only supplies which accounts are allowed (active ones of the project's company, plus any the line or task has already used).

πŸ”‘ Priced like the labour total. projectEntryRate() (budget.php) is the one place an hour's rate is chosen, used by both projectLabour() and projectLabourByAccount(); projectTimeEntries() is the one query for a project's time. So Time by account always adds up to the labour figure.

🐞 TRAP fixed with it: projectLabour() read tasks.project_id only, so time on subtasks never reached a project's budget while the Plan (projects/read.php) counted it. projectTimeEntries() joins the parent: t.project_id IN (...) OR (t.project_id IS NULL AND parent.project_id IN (...)).

Screen: the task window's Account field (assets/js/tasks.js: loadTaskAccounts(), drawTaskAccounts(), editTaskAccounts(), saveTaskAccounts()), shown only for a task in a project whose company has accounts; the Budget tab's Time by account (labour_by_account in projectBudgetDetail()); History detail "Task title: 70% X, 30% Y" (task_account).

Test: tests/project-task-accounts.php (21) - subtask time in labour, nothing assigned / the account for time, splits refused or written once, a subtask's own and cleared, all against from_then on a dated history, cost by share adding up to the labour total, delete refused, the REST API.

Slice 4 - income lines (5.1.0)

project_income_lines (title, planned_amount, received_amount, planned_date, received_date, account_id, notes, position; FK project CASCADE, account RESTRICT) - ProjectToolsService::saveIncomeLine() / deleteIncomeLine(), projectIncome() and projectIncomeAccounts() (the company's active accounts of kind income) in includes/projects/accounts.php, income and income_accounts in projectBudgetDetail(), tools actions income_line_save / income_line_delete, REST /projects/{id}/income-lines.

πŸ”‘ Never in the budget. projectBudgetTotals() and the planned / actual / remaining of projectBudgetDetail() do not read income; the Budget tab's Income panel works out Income less costs itself (planned income - planned budget; received - actual). An income line's account must be of kind income - an expense account there would land money in the wrong section of slice 5's breakdown.

Counted in projectAccountUsage(), deleted with the project in ProjectsService::deleteProject(). Test: tests/project-income-lines.php (19).

Slice 5 - costs and income by account (5.1.0)

One function makes the numbers: projectAccountBreakdown($conn, $project) in includes/projects/accounts.php. Everything else - the Budget tab, the portfolio, the export, the Report Packs block, REST and the AI facts - reads it, so they cannot disagree.

$b = projectAccountBreakdown($conn, $project);   // a projects row (id, currency, tenant_id, labour/other_account_id)
// ['currency' => 'GBP',
//  'sections' => ['expense' => ['rows' => [['account' => [...], 'planned' => .., 'actual' => .., 'forecast' => .., 'minutes' => ..]], 'planned' => .., ...],
//                 'fixed_asset' => [...], 'payroll' => [...], 'income' => [...]],
//  'unassigned' => null | row, 'costs' => [planned, actual, forecast], 'income' => [...], 'net' => [...]]

πŸ”‘ It must equal the Budget tiles. Lines are split by their percent rows (else the project's account for other spending, else unassigned); labour comes from projectLabourByAccount() priced with projectEntryRate(), plus projectLabourToComeByAccount() for the forecast's hours still to come; and the "nothing counted twice" rule is applied the way projectBudgetDetail() applies it - a labour cover line's money is replaced by the time it covers, in proportion, when the time is larger. In hours mode labour adds minutes and no money - never an invented price. Totals are summed unrounded and rounded once at the end, so the pennies match the tiles. tests/project-account-breakdown.php pins each case (19).

Across projects: projectAccountPortfolio($conn, $projects) runs the breakdown per project and adds them up per currency (never across), matching accounts on lower-cased code, so the same account in two companies is one line; the same logic as combine() in assets/js/projects-accounts.js, which the portfolio's Charts view uses on the per-project breakdowns api/projects/portfolio_charts.php returns as accounts (so the panel follows the view's filters without another request).

Where What
assets/js/projects-accounts.js window.PrjAccounts - table(b, {esc, T, money, hours}) draws a breakdown, combine(list), any(b); used by the Budget tab and the portfolio
api/projects/export.php what=accounts&project_id= and what=accounts_portfolio&ids= (only projects projectListRows() lets the analyst see), rows from projectBreakdownSheetRows()
includes/report_packs/blocks_projects.php rpProjectsAccounts() - block projects.accounts, toolbox projects_accounts; projects chosen through rpProjectRows() so the pack's company and members-only rules apply
api/v1/resources/projects.php by_account on GET /projects/{id}/budget, apiProjectsByAccount() for GET /projects/by-account, apiSerializeBreakdown(); OpenAPI schema ProjectAccountBreakdown
includes/projects/ai.php the by-account lines in projectAiFacts() when the chart of accounts is in use

TRAP: a new cost source on the Budget tab must be added to projectAccountBreakdown() too, or the breakdown stops adding up to the tiles - the test's first checks compare the two.


See also: Cost centres β€” Developer Guide Β· REST API: Chart of accounts

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally