Expenses
The expenses module (expenses2 plugin): actual expenses, budgeted expenses, product rates.
Available since 4.3.1.
Read /expenses/config first
GET /expenses/config always answers 200. With available: false the plugin is not active
and nothing else is in the payload: hide the module. Every other route answers
404 not_found with message: "expenses" in that state.
Everything the form needs is in that one answer: the caller's permissions (which decide whether
costs and prices come back as numbers or as null), the expense types, the default currency,
the decimals, the dimensions an actual expense is filed in, and the form layout.
Build the form from property_groups
The desktop draws the actual and the budgeted expense from property groups: named boxes
("Details", "Related to", or an installation's own) holding the expense's own fields, dimension
selectors and custom properties, each group left, right or full width. Installations move fields
between groups, rename them and disable fields, so a client reads the layout instead of assuming
it: property_groups.actual and property_groups.budgeted, already filtered for the caller.
- The list starts with the fixed block the desktop draws above the groups — "Basic data" on
an actual expense (product, quantity, date, currency, amounts), the "Items" lines of a budget
(
repeating: true). Itsidandorderarenull. - Key groups on
idorcode(defaultis "Details",""is "Related to" and the installation's own), never onname, which is stored as typed. - A field's
codeis the key of the write contract (due_date,task_id, …), the dimension's code, or the custom property's code (cp_<id>when it has none). writable: falseis what the desktop shows but the API does not write:document_id(it has its own route,/expenses/receipts/{id}),payment_term_id,is_paid, the custom properties — and everything of a budget.api_fieldsnames the body keys to send; a dimension selector ismember_ids.requiredis the desktop's. Independently, the API always requiresdate, andamountfrom a caller who may see costs.property_groupsisnullwithout advanced_core: draw the built-in form fromdimensionsand the write contract instead.dimensions[].in_form: falsemeans the desktop does not draw that selector (Clients, Job phases), because it is filled from the project. The server still takes it; a form should not ask for it.
A linked budget does not change the layout; it fills values.
Where an actual expense is filed
member_ids is where the expense goes. Members sent and members brought by a budget or
a task combine by dimension:
- A member sent replaces only what is brought in its own dimension; every other dimension
keeps what the budget (with its task) or the task brings.
{"task_id": 3315, "member_ids": [<category>]}is filed in that category plus the task's project, client, phase and the rest. - A budget decides what it defines. In every dimension the budget has a member, a member
sent must be one of the budget's, otherwise
422 member_ids: budget_mismatch. Sending none there takes the budget's. - The task decides the project. An expense on a task goes to the task's project (its own,
or the nearest ancestor's for a subtask). Another project is
422 task_project_mismatch; a task in no project is422 task_without_project; a folder of the projects dimension is422 folder_not_allowed. - A dimension
/expenses/configmarksrequiredmust end up with a member — sent or brought — or the answer is422 member_ids: <code>_required(for instanceexpense_categories_required). Checked on create and whenever the classification changes.
On an edit, member_ids sent wins in the dimensions it covers and the others keep the expense's
current members; a dimension cannot be emptied from here.
Recording an actual expense from a budget
The desktop starts a new actual expense by asking where it comes from: a budgeted expense, or
manual entry. An actual expense belongs to a budget, never to one of its lines: only
expense_id is stored, and nothing relates an actual expense to a budget line. What the API
does with expense_id mirrors the desktop:
| Desktop | API |
|---|---|
| Fills the classification, the type, billable and the task from the budget | expense_id brings the budget's members, task_id and is_billable when those are not sent |
| Preselects the line's product and offers its unit cost and price (or lets the user pick a line) | The client shows the lines (GET /expenses/budgeted/{id} → items) and sends the chosen line's product_rate_id, quantity and amount (or unit_cost); all of it can be changed before saving |
| — | Without unit_price, the server takes the unit price of the budget's line with the same product, else the rate's |
- A line is never used up. Nothing records which line an actual expense came from; every line is offered again, and a partial execution is just an actual expense with a smaller quantity.
- Several lines are several actual expenses: one
POSTper line, each with its ownuuid, which is what an offline queue needs anyway. - The budget's status does not move when actual expenses are recorded; it is set by whoever
edits the budget. What it has spent is
actuals_summary(andactuals) on the budget.
# Budget 3321 has three lines; the user records 2 of the 8 meals of the "Travel - Meal" line (rate 52)
curl -s -X POST "$BASE/expenses/actual" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: 6f1c…" \
-d '{"uuid":"3b8e…","date":"2026-10-19","expense_id":3321,"product_rate_id":52,"quantity":2,"amount":50}'
# 201 … "expense_id": 3321, "task_id": 3315, "is_billable": true,
# "quantity": 2, "unit_cost": 25, "amount": 50, "unit_price": 25, "total_price": 50
Money
An amount the caller may not see is null — never 0, and the key is never omitted: costs
follow can_see_cost_information, prices follow can_see_billing_information. Render null as
hidden and never add it into a total. There are no exchange rates: totals are per currency.
📄️ Module bootstrap
What a client needs before it shows a single expense: whether the module is available,
📄️ Budgeted expenses
The budgets the caller can open (EVX Software's sharing table, and the visibility of the
📄️ One budgeted expense
Always with `items` and `actuals`. `404 expense` when missing or trashed, `403` when the caller may not see it.
📄️ Actual expenses
Newest first (by date, then id). The receipts EVX Software's own list shows the caller; without
📄️ Record an actual expense
Goes through the plugin's own save, after field-level checks. `201` with the new
📄️ Totals today, this week, this month
Count and per-currency total of the receipts in each window, over the same receipts the list would return. The week follows the user's *start week on Monday* preference.
📄️ One actual expense
`404 payment_receipt` when missing or trashed; `403` when the caller may not see it (someone else's, without `can_see_expenses_of_others`).
📄️ Update an actual expense
Partial; `PATCH` behaves the same. A key absent keeps its value; `expense_id`,
📄️ Update an actual expense (alias of PUT)
Update an actual expense (alias of PUT)
📄️ Trash an actual expense
Checks in order `404`, `409 invoiced`, `403`.
📄️ Product rates for the actual-expense form
From the first source given: `expense_id` — the rates on that budget's lines, each once;
📄️ Members an actual expense can be filed in
The members of one of `GET /expenses/config` → `dimensions` the caller can file an actual
📄️ Upload or replace an actual expense's receipt image
Stores the image as the receipt of actual expense `{id}` and answers the expense with its
📄️ Remove an actual expense's receipt image
Unlinks the receipt and answers the expense with `document_id` `null`.