Skip to main content

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). Its id and order are null.
  • Key groups on id or code (default is "Details", "" is "Related to" and the installation's own), never on name, which is stored as typed.
  • A field's code is 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: false is 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_fields names the body keys to send; a dimension selector is member_ids.
  • required is the desktop's. Independently, the API always requires date, and amount from a caller who may see costs.
  • property_groups is null without advanced_core: draw the built-in form from dimensions and the write contract instead.
  • dimensions[].in_form: false means 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 is 422 task_without_project; a folder of the projects dimension is 422 folder_not_allowed.
  • A dimension /expenses/config marks required must end up with a member — sent or brought — or the answer is 422 member_ids: <code>_required (for instance expense_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:

DesktopAPI
Fills the classification, the type, billable and the task from the budgetexpense_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 POST per line, each with its own uuid, 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 (and actuals) 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.