Chart of Accounts Developer Guide - edmozley/freeitsm GitHub Wiki
π 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 buta-zremoved, so the import takes a finance system's own words. An unknown word isinvalid_field; blank ismissing_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.phphas hand-written comments - the schemas were added by text insertion (copying theCostCentreblocks), never by round-tripping throughvar_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 asCostCentresService::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()readtasks.project_idonly, 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' => ..](/edmozley/freeitsm/wiki/'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