Skip to main content
Reporter is Lerian’s source-available reporting engine that enables teams to generate dynamic, data-driven reports through simple plain-text templates (.tpl files). Templates render into multiple output formats while maintaining the document structure you define. The source code is publicly available on GitHub.
Template formatOutput format
CSV-structured .tplCSV file
XML-structured .tplXML file
HTML-structured .tplHTML or PDF file
TXT-structured .tplTXT file

Why use Reporter?


Rather than writing complex SQL queries, you reference domains, tables, and fields through intuitive placeholders. This makes report creation faster, more flexible, and easier to maintain.

How it works


Workflow

Reporter follows a simple and efficient workflow that turns your templates into production-ready reports:
  1. Submit templates with optional filters and parameters
  2. Reporter retrieves data from configured databases (PostgreSQL, MongoDB)
  3. Template logic is applied (loops, conditions, calculations)
  4. The final output is generated in the requested format
Reporter workflow showing the process from template to rendered report

Architecture

Reporter is built on a layered architecture that keeps responsibilities clear and supports growth:
  • Data layer: Connects to databases through configured data sources. Supports PostgreSQL and MongoDB, with multi-schema queries for PostgreSQL.
  • Business logic layer: Manages template parsing, placeholder resolution, and rendering.
  • Storage layer: Stores templates and generated reports using S3-compatible object storage (AWS S3, MinIO, or SeaweedFS).
  • Presentation layer: Returns formatted output through RESTful APIs.
Reporter architecture layers showing the component structure

What it can do


  • Dynamic queries with placeholders: Reference any data point via direct paths — no SQL required.
  • Multi-schema support: Query tables across multiple PostgreSQL schemas from a single template using explicit schema syntax.
  • Loop and condition logic: Build dynamic content with for loops, if/elif/else conditionals, and scoped blocks.
  • Math operations and aggregation: Perform calculations with sum_by, avg_by, count_by, min_by, max_by, calc, and aggregate_balance.
  • Counter tracking: Track and display named counters across template iterations with counter and counter_show.
  • Data transformation filters: Transform values inline with where, sum, count, replace, slice, strip_zeros, and percent_of.
  • Async processing: Heavy reports are handled asynchronously via message queue.
  • S3-compatible storage: Templates and reports are stored in any S3-compatible service (AWS S3, MinIO, SeaweedFS).
  • Multiple output formats: Generate CSV, XML, HTML, TXT, or PDF output from a single template engine.

Template model


Reporter uses templates that mirror the final document structure. Files must have the .tpl extension regardless of the content format inside.
Even though the file content must follow the output format, make sure to save it with a .tpl extension. This is required for the template to work properly.

Setting up your environment


Database references must be renamed in the project’s .env file to avoid conflicts when tables share names across databases. Example naming:
  • midaz_onboarding (PostgreSQL)
  • midaz_onboarding_metadata (MongoDB)

Using placeholders


The placeholder structure follows a path-based syntax:
{{ base.table_or_collection.field_or_document }}
This works for both SQL databases (tables and fields) and MongoDB (collections and documents).

Multi-schema placeholders

When your PostgreSQL data source has multiple schemas, use the explicit schema syntax to avoid ambiguity:
{{ base:schema.table.field }}
For example:
{{ external_db:sales.orders.total }}
{{ external_db:inventory.items.quantity }}
If a table name exists in only one schema, the legacy syntax {{ base.table.field }} still works — Reporter auto-discovers the correct schema. If a table exists in multiple schemas, Reporter defaults to the public schema. If the table is not in public, Reporter returns an error with suggestions:
ambiguous table reference: 'db.orders' exists in multiple schemas: [sales, shipping]
Please use explicit schema syntax:
  {{ db:sales.orders }}
  {{ db:shipping.orders }}

Building templates


Common blocks

  • Loop
{% for <item> in <list> %}
  ...
{% endfor %}
  • Loop with explicit schema
{% for order in external_db:sales.orders %}
  {{ order.id }} - {{ order.total }}
{% endfor %}
  • Simple condition
{% if value_a == value_b %}
  ...
{% endif %}
  • Temporary scope
{% with <object> as <alias> %}
  ...
{% endwith %}
  • Value formatting
{{ field_name | floatformat:2 }}   --> renders 123.45

Conditional blocks

BlockDescriptionExample
IfRuns block if condition is true{% if condition %}...{% endif %}
If-elseRuns one block if true, another if false{% if condition %}...{% else %}...{% endif %}
If-else-ifAllows multiple checks{% if a %}...{% elif b %}...{% else %}...{% endif %}
EqualChecks if two values are equal{% if a == b %}
Not equalChecks if two values are different{% if a != b %}
Greater thanChecks if a is greater than b{% if a > b %}
Less thanChecks if a is less than b{% if a < b %}
Greater than or equalChecks if a is greater than or equal to b{% if a >= b %}
Less than or equalChecks if a is less than or equal to b{% if a <= b %}
AndReturns true if both conditions true{% if a and b %}
OrReturns true if at least one true{% if a or b %}
NotInverts Boolean result{% if not a %}

Tags reference


Aggregation tags

sum_by — Sums numeric values from a field across all items in a collection.
{% sum_by <collection> by <field> %}
{% sum_by <collection> by <field> if <condition> %}
Example:
<Sum>
  {% sum_by transaction.operation by "amount" if accountAlias != "@external/BRL" %}
</Sum>
count_by — Counts the number of items in a collection.
{% count_by <collection> %}
{% count_by <collection> if <condition> %}
Example:
<Count>
  {% count_by transaction.operation if accountAlias != "@external/BRL" %}
</Count>
avg_by — Calculates the average of numeric values in a field.
{% avg_by <collection> by <field> %}
{% avg_by <collection> by <field> if <condition> %}
min_by — Finds the minimum numeric value in a field.
{% min_by <collection> by <field> %}
{% min_by <collection> by <field> if <condition> %}
max_by — Finds the maximum numeric value in a field.
{% max_by <collection> by <field> %}
{% max_by <collection> by <field> if <condition> %}
All aggregation tags use decimal precision to avoid floating-point rounding errors. Missing or non-numeric fields are skipped. Returns 0 if no items match.

Date and time tag

date_time — Outputs the current date and time formatted according to the provided format string. Time is generated in UTC.
{% date_time "<format>" %}
Format codes:
CodeMeaningExample
YYYY4-digit year2025
MM2-digit month01-12
dd2-digit day01-31
HH2-digit hour (24h)00-23
mm2-digit minute00-59
ss2-digit second00-59
Examples:
{% date_time "YYYY-MM-dd" %}           --> 2025-02-06
{% date_time "dd/MM/YYYY HH:mm:ss" %} --> 06/02/2025 14:30:45

Arithmetic tag

calc — Evaluates mathematical expressions with support for variables from the template context.
{% calc <expression> %}
Supported operators:
OperatorDescriptionPrecedence
**ExponentiationHighest (right-to-left)
* /Multiplication, divisionMiddle
+ -Addition, subtractionLowest
( )ParenthesesOverride precedence
Examples:
{% calc 100 + 50 %}                                --> 150
{% calc balance.available * 0.5 %}                 --> calculated value
{% calc (balance.available + 1.2) * balance.on_hold - balance.available / 2 %}
Variables that cannot be resolved default to 0. Division by zero produces an error.

Financial aggregation tag

aggregate_balance — Groups items by a field, selects the most recent entry per account within each group, and sums the balances. Useful for regulatory reports that require the latest balance per account grouped by category.
{% aggregate_balance <collection> by "<balance_field>" group_by "<group_field>" order_by "<date_field>" [if <condition>] as <result_var> %}
The result is stored in a variable you can iterate over:
{% aggregate_balance accounts by "balance" group_by "cosif_code" order_by "created_at" as balances %}
{% for b in balances %}
  {{ b.group_value }}: {{ b.balance }} ({{ b.count }} accounts)
{% endfor %}
Each result item contains:
FieldTypeDescription
group_valuestringThe value from the group_by field
balancedecimalSum of latest balances per account in the group
countintegerNumber of accounts in the group
Maximum collection size: 100,000 items. Results are sorted by group_value.

Counter tags

counter — Increments a named counter by 1. Produces no output. Counters are scoped per render.
{% counter "<counter_name>" %}
counter_show — Displays the sum of one or more named counters.
{% counter_show "<name1>" %}
{% counter_show "<name1>" "<name2>" "<name3>" %}
Example:
{% for tx in ledger.transactions %}
  {% counter tx.type %}
{% endfor %}
Total credits: {% counter_show "credit" %}
Total debits: {% counter_show "debit" %}
Combined: {% counter_show "credit" "debit" %}

Filters reference


percent_of

Calculates the percentage of a value relative to a total. Returns a formatted string with 2 decimal places.
{{ value | percent_of: total }}
Example: if category.amount = "6.00" and total.expenses = "20.00":
{{ category.amount | percent_of: total.expenses }}  --> 30.00%

strip_zeros

Removes trailing zeros from a numeric value without rounding.
{{ number | strip_zeros }}
Examples:
{{ "100.50000" | strip_zeros }}  --> 100.5
{{ "100.00" | strip_zeros }}     --> 100
{{ "99.990" | strip_zeros }}     --> 99.99

slice

Extracts a substring using start and end indices (0-based).
{{ string | slice:"start:end" }}
Examples:
{{ "hello" | slice:"0:3" }}  --> hel
{{ "12345" | slice:"1:4" }}  --> 234

replace

Replaces all occurrences of a search string with a replacement string. Format: "search:replacement".
{{ string | replace:"search:replacement" }}
Examples:
{{ "01310-100" | replace:"-:" }}      --> 01310100   (removes hyphens)
{{ "1234.56" | replace:".:," }}       --> 1234,56    (dot to comma)
{{ "12.345.678/0001-99" | replace:".:" }}  --> 12345678/0001-99

where

Filters an array of objects by field equality. Supports nested fields via dot notation.
{{ array | where:"field:value" }}
Examples:
{{ holders | where:"state:SP" }}
{{ holders | where:"address.state:SP" }}
Use inside loops:
{% for holder in holders|where:"state:SP" %}
  {{ holder.name }}
{% endfor %}

sum (filter)

Sums numeric values from a field across all items in an array. Uses decimal precision.
{{ array | sum:"field" }}
Examples:
{{ operations | sum:"amount" }}
{{ items | sum:"price.value" }}

count (filter)

Counts elements in an array where a field matches a value. Supports nested fields.
{{ array | count:"field:value" }}
Examples:
{{ operations | count:"nat_oper:6" }}
{{ holders | count:"address.state:SP" }}

contains

Checks if one value is partially included in another. Useful when data includes dynamic prefixes or suffixes.
{% if contains(source_field, target_field) %}
Example:
  • Source: 0#@external/BRL
  • Target: @external/BRL
Returns true because @external/BRL exists within the source value.

Operators and filters summary


NameTypeDescription
sum_byTagSum values by field with optional filter
count_byTagCount items with optional filter
avg_byTagCalculate average by field
min_byTagFind minimum value
max_byTagFind maximum value
date_timeTagFormat current date/time
calcTagEvaluate arithmetic expressions
aggregate_balanceTagGrouped financial balance aggregation
counterTagIncrement a named counter
counter_showTagDisplay counter value(s)
percent_ofFilterCalculate percentage
strip_zerosFilterRemove trailing zeros
sliceFilterExtract substring
replaceFilterString replacement
whereFilterFilter array by field value
sumFilterSum array field values
countFilterCount matching items
containsFunctionPartial string match
floatformatFilterFormat decimal places

Advanced filtering


When generating a report, you can pass filters in the request body to narrow the data. Filters follow a structure of datasource > table > field: Single schema (default):
{
  "templateId": "00000000-0000-0000-0000-000000000000",
  "filters": {
    "midaz_onboarding": {
      "account": {
        "id": { "eq": ["123", "456"] },
        "createdAt": { "between": ["2023-01-01", "2023-01-31"] },
        "status": { "in": ["active", "pending"] }
      }
    }
  }
}
Multi-schema (explicit schema.table key):
{
  "templateId": "00000000-0000-0000-0000-000000000000",
  "filters": {
    "external_db": {
      "sales.orders": {
        "total": { "gt": [100] },
        "created_at": { "gte": ["2025-01-01"] }
      },
      "finance.invoices": {
        "status": { "eq": ["paid"] }
      }
    }
  }
}
Supported operators:
OperatorDescriptionExample
eqEqual to{ "eq": ["active", "pending"] }
gtGreater than{ "gt": [100] }
gteGreater than or equal to{ "gte": ["2025-06-01"] }
ltLess than{ "lt": [1000] }
lteLess than or equal to{ "lte": ["2025-06-30"] }
betweenValue falls within a range{ "between": [100, 1000] }
inValue is within a list{ "in": ["active", "pending"] }
ninValue is not within a list{ "nin": ["deleted", "archived"] }

Need inspiration?


Check out the Template examples page to explore what you can do and start shaping your own template.

Authentication and authorization


Reporter does not require authentication by default, but it ships with native Access Manager integration. When enabled, Access Manager provides role-based access control (RBAC) for templates, reports, and data sources — giving you fine-grained control over who can view, create, or manage reporting resources.
Access Manager is an optional feature available under the Enterprise model. For details on enabling it, see the Access Manager documentation.

Next steps