SIGNALS Documentation
API Reference

Reporting

Build, save, share, export, and schedule aggregated reports over rentals, invoices, tax snapshots, accounts, and catalogue items.

Overview

A report is a saved aggregation: pick a source (the kind of record you are counting), one or more dimensions (what to group by), one or more measures (what to total), a date window, and optional filters. Running it returns a grid of rows, optional subtotals, and a grand total.

Reports are the sibling of saved list views. A list view describes a layout over individual records; a report describes an aggregation over many of them. Both use the same system / personal / shared audience model, so sharing behaves identically in both places.

Reports never invent numbers. Every figure is read from the records you already have, under the permissions of the person running it — two people running the same report can legitimately see different totals if they can see different warehouses.

Where it lives

Route: /reports (reports.index) — Reports in the main navigation.

Permissions:

Permission Grants
reports.access Reach the Reports area at all
reports.view Open and run reports
reports.export Export a result to CSV, XLSX, or PDF
reports.create Save a new report
reports.update Edit a report and manage its schedules
reports.delete Delete a report

On top of these, running a report also requires the source's own view permission (for example invoices.view for an invoice report), so reporting can never become a side door around record permissions.

Sources, dimensions, and measures

A source is a registered starting point — the core framework ships ten:

Source Records Requires Default date field
rentals Rentals rentals.view Created date
invoices Invoices invoices.view Issue date
invoice_taxes Tax snapshot lines on invoices invoices.view The invoice's issue date
credit_notes Credits raised against invoices invoices.view Issue date
purchase_orders Supplier orders and the spend they commit purchase_orders.view Created date
accounts Accounts accounts.view Created date
catalogue_items Catalogue items catalogue-items.view Created date
assets The equipment fleet — what is held, allocated, unavailable assets.view Created date
equipment_test_results Compliance test results equipment-test-results.view Performed date
repairs Equipment out of service repairs.view Created date

Two of these carry no money measure, and that is deliberate rather than an omission. An asset has no cost column of its own — its purchase price, servicing, and disposal figures live on asset_costs — and a repair records the same way, so a repair-cost report is a question for a future asset_costs source rather than something these two can answer today. Credit note money is stored negated, mirrored from the invoice each note reverses, so summing total gives what came off revenue and adding it to invoiced revenue gives net revenue without anyone having to remember a sign.

Plugins can register further sources, and they appear in the builder automatically.

Dimensions group the rows — warehouse, status, account, currency, or a date field bucketed by day, week, month, quarter, or year. Measures aggregate them: count, sum, average, minimum, and maximum, plus any named aggregation a source publishes. Money measures come back as decimal strings in the company base currency; a report that spans currencies should group by currency code rather than mix them.

The whitelist rule

You can only group by, aggregate, or filter on fields the source itself publishes as reportable — and only with the aggregate functions that field allows. This is not merely a UI restriction: the engine re-checks every field name against the source schema before it builds the query, and uses the resolved field rather than anything the request supplied.

Asking for something outside the whitelist is a validation error rather than an empty result, so the reason is always visible:

  • Not groupable — the field is not published as a dimension on this source.
  • Not aggregatable — the field cannot be totalled.
  • Invalid aggregate function — the field does not allow that function; you cannot sum a status.
  • Cost visibility denied — the field is a cost figure and your role cannot see costs. Costs are refused outright rather than silently blanked, so a total is never quietly understated.

Related records are deliberately limited: a field reached through a relationship may be used to window or filter a report — a tax line's period is its invoice's issue date — but never as a dimension. Nothing reached through a relationship appears in the grouped output.

Relative dates in filters

A report's window has always been stored as a relative token, so a saved report keeps meaning "the last twelve months" rather than freezing the months it was created in. Filters can do the same. Any filter on a date field may take a token instead of a date, and it is resolved when the report runs:

Token Means
{now} The current instant
{today} Midnight this morning
{<preset>} The start of that preset's window — any preset the date picker offers
{<preset>.start} / {<preset>.end} That window's inclusive start or exclusive end

So "everything past its due date" is written due_at_lt = {today}, which is exactly what the seeded Aged Receivables report does. A preset names a range and a filter compares against an instant, so the bare form picks the start — the overdue question is what the vocabulary exists for.

Three things are worth knowing:

  • Tokens resolve in the report's timezone (the company timezone unless the report overrides it), so "before today" means the company's midnight, not the viewer's.
  • The vocabulary is closed. A filter value shaped like a token that is not one of these is a validation error, not a literal — so a typo says so instead of silently matching nothing. A value that merely contains braces (INV-{2026}-01) is not a token attempt and passes through untouched.
  • The resolved instant is part of the cache identity, so an aged-receivables report run either side of midnight never serves yesterday's overdue list.

Date windows and comparison periods

Every report has a date field and a window. Choose a preset (this month, last quarter, this year, last 12 months, all time, and so on) or set explicit from and to dates, which override the preset. Windows are half-open — the start is included, the end is not — so consecutive periods never double-count a record on the boundary. Windows are resolved in the report's timezone, defaulting to the company timezone.

Setting a comparison adds a second run of the same report over an earlier window:

  • Previous period — the immediately preceding window of the same length.
  • Same period last year — the same window shifted back a year.

Each measure then carries the comparison value, the absolute change, and the percentage change. Two behaviours are worth knowing:

  • A group that exists now but not in the comparison window compares against zero, and its percentage change is left empty rather than shown as infinite growth.
  • A group that existed only in the comparison window is not added as a phantom row. The grand total still compares the whole of both windows, so it remains the honest figure.

Comparison shifts are calendar-aware, so month lengths and daylight-saving changes do not skew the earlier window.

The builder

The Reports page is both the library and the builder. The sidebar lists reports by category, with your pinned reports first; the main panel builds and runs one.

  • Pick a source, then add dimensions and measures from the fields that source publishes. Only valid choices are offered.
  • Add filters using the same predicate vocabulary as list views and the API — is, is not, greater than, contains, and so on.
  • Bucket by date to turn a date dimension into days, weeks, months, quarters, or years.
  • Run to see the grid, with optional subtotals per grouping level and a grand total, plus a chart of the leading measure.
  • Save the definition as a report, choosing who can see it.
  • Pin a report to keep it at the top of your sidebar. Pinning is personal and needs only view access.

Results are capped: a report returns at most 5,000 rows (500 by default), and the result flags itself as truncated when the cap is reached, so a partial answer is never mistaken for a complete one.

Visibility and sharing

Visibility Who sees it
System Everyone. Shipped with the framework or by a plugin, and cannot be deleted — a system report can be re-seeded, but the saved widgets and schedules pointing at it cannot.
Personal Only its owner.
Shared The specific users and roles it is shared with.

Sharing uses the same assignment primitive as shared list views, so the same people and roles are offered in both places.

Sharing a report shares the definition, not the data. Everyone who opens it runs it under their own permissions: warehouse scoping is reapplied per viewer, and a viewer with no accessible warehouses gets an empty result rather than someone else's totals.

Exports

Any result can be exported as CSV, XLSX, or PDF with reports.export. Exports run through the standard export-job pipeline, so a large export is queued and appears under Export jobs with progress and a download link; small ones return a link immediately. PDFs render landscape A4 through the configured PDF driver.

An export re-runs the report through the same execution path as the screen, which means the same permission gates and the same figures — an export can never contain a row the exporter could not see on screen. Exporting an unsaved builder result is supported; the file is then labelled as ad-hoc rather than carrying a report name.

Scheduled delivery

A saved report can carry one or more schedules that run it unattended and email the result to subscribers.

A schedule sets:

  • Frequency — daily, weekly (with a day of the week), monthly, or quarterly (with a day of the month). A day of the month beyond the length of a short month still fires, clamped to the last day.
  • Send time and timezone — local to the schedule, defaulting to the company timezone.
  • Format — the attachment format, PDF by default.
  • Active — schedules can be disarmed without being deleted.

Due schedules are picked up every few minutes by the scheduled task signals:dispatch-report-schedules, which advances the schedule before the run so a slow report never causes a double send. Delivery runs on the exports queue and will not overlap itself.

Subscribers

A subscriber is either an internal user or an external email address.

  • Internal subscribers each get their own run, executed under their own permissions. A subscriber who has since lost access to the report or its source is skipped and recorded, rather than being sent data they can no longer see, and rather than failing the whole delivery.
  • External subscribers receive the report as rendered under the schedule owner's permissions — worth remembering before adding an address outside the business.

Each delivery sends the report.scheduled_delivery notification with the rendered file attached, and emits the report.generated webhook recording what was delivered, who was skipped, and which thresholds tripped. See Webhooks for the payload.

Thresholds

A schedule can carry thresholds — simple rules of the form measure, comparator, value, using a fixed set of comparators: equals, does not equal, greater than, greater than or equal to, less than, and less than or equal to. Thresholds are configuration, never expressions, so nothing entered here is evaluated as code.

When a threshold trips, the recipients get an additional report.threshold_alert notification naming the measure, the rule, and the actual figure. A breach adds an alert; it never suppresses or gates the normal delivery.

Thresholds are evaluated once, on the schedule owner's run. Evaluating them per subscriber would make the same rule fire or not fire depending on who happened to be on the distribution list.

VAT-return reports

The invoice_taxes source reports tax snapshots, never a tax calculation. Every figure it exposes was frozen onto the invoice at the moment that invoice was generated, so a VAT return reports what was charged rather than what today's rates would charge. Re-running last quarter's return after a rate change gives the same answer it gave at the time, which is the whole point of a return.

Two policy decisions follow from that, and are stated in the shipped VAT Return report rather than hidden in the engine:

  • What counts. A draft was never issued and a void invoice was cancelled, so neither was ever charged; a proforma is a request for payment, not a tax document. Everything else that was issued counts — including a credited invoice, whose reversal is its credit note's own entry.
  • Which period. The period is the invoice's issue date, not the moment the snapshot was written. An invoice drafted in March and issued in April belongs to April's return.

System reports

Sixteen reports ship with the framework and are available to everyone with reporting access — subject to the source's own permission, so someone who cannot see rentals does not see the rental reports:

Report Source Category
Revenue by Warehouse Invoices Finance
Outstanding Invoices Invoices Finance
VAT Return Invoice taxes Finance
Tax by Treatment Invoice taxes Finance
Invoiced Revenue by Month Invoices Finance
Payments Received by Month Invoices Finance
Revenue by Currency Invoices Finance
Top Clients Invoices Sales
Pipeline by Stage Rentals Sales
Rental Turnover by Warehouse Rentals Operations
Rental Revenue by Month Rentals Sales
Revenue Mix by Warehouse Rentals Sales
Upcoming Shortages Rentals Operations
Uninvoiced Orders Rentals Finance
New Accounts by Type Accounts Accounts
Catalogue Composition Catalogue items Catalogue

They are keyed and re-seeded by the report seeder, so a deleted or edited system report is restored on the next seeder run. See Seeders for the full list of seeded data.

API

Reports are available over the REST API under /api/v1/reports. Reading and running need the reports:read ability; creating, editing, deleting, and managing schedules need reports:write. The caller's permissions and warehouse scoping apply exactly as they do in the UI.

Endpoint Purpose Ability
GET /api/v1/reports List saved reports, paginated and filterable by key, name, source, category, and visibility reports:read
GET /api/v1/reports/{report} Fetch one report definition reports:read
POST /api/v1/reports Save a report reports:write
PUT/PATCH /api/v1/reports/{report} Update a report reports:write
DELETE /api/v1/reports/{report} Delete a report reports:write
POST /api/v1/reports/{report}/execute Run a saved report, optionally with overrides reports:read
POST /api/v1/reports/execute Run an ad-hoc definition without saving it reports:read
GET/POST/PUT/DELETE /api/v1/reports/{report}/schedules Manage a report's schedules reports:read / reports:write

Execution accepts overrides (any part of the definition — dimensions, measures, date window, filters, limit), comparison, warehouse_ids (intersected with what the caller may see, never widened), and fresh to bypass the cache. The result carries columns, rows, subtotals, totals, and a meta block describing the dimensions, measures, resolved date range, comparison window, currency, row count, whether the result was truncated, whether it came from cache, and how long it took.

Filtering, pagination (page, per_page), and the q[field_predicate]=value query syntax follow the usual API conventions.

Notes

  • Results are cached per definition and per permission context, so a cached result is never served to someone whose access differs. Closed historical windows cache for a day; windows that are still open cache for five minutes, and are invalidated when relevant records change.
  • Cost fields are refused outright to roles without cost visibility rather than blanked, so a total is never quietly wrong.
  • Filters that cannot be applied are reported back in the result's ignored_filters rather than being silently dropped.