Escape

Expense report template with a chart

An expense report is an employee’s claim. It lists what they spent on the company’s behalf over a period, and what the company owes them back. This one opens with who and why, then four figures — the total, the receipts, the cash advance, and the reimbursement due. The ring under them shows where the money went at a glance. Each category has one colour, and its amount and its share stand beside it. Underneath, every receipt sits under its category with a subtotal, so an approver who doubts a slice can count it. Your code draws the ring from the same receipts the table lists, every time the report renders. This is a ready-made quario expense report — copy it, point it at your expense claims, and render to PDF or HTML.

The template

The whole report is a definition — data you can version and review. This is the exact file the preview below renders:

expense-breakdown.report.json
{
  "data": "$.expenses.items[*]",
  "sort": [{ "by": "[email protected]" }, { "by": "[email protected]" }],
  "aggregates": {
    "total": "sum:[email protected]",
    "count": "count"
  },
  "style": { "family": "sans", "size": 10 },
  "header": [
    {
      "type": "split",
      "slots": [
        { "type": "text", "value": "Expense report", "width": 60,
          "style": { "family": "serif", "size": 19, "bold": true, "color": "#1e1f22" } },
        { "type": "text", "value": [{ "value": "{{ $.input.expenses.period.from }}" }, { "value": " to " }, { "value": "{{ $.input.expenses.period.to }}" }],
          "style": { "size": 8, "bold": false, "color": "#989a97", "align": "right", "format": "date" } }
      ]
    },
    {
      "type": "split",
      "slots": [
        { "type": "text", "value": "No. {{ $.input.expenses.number }}", "width": 60,
          "style": { "size": 8, "bold": false, "color": "#989a97" } },
        { "type": "text", "value": [{ "value": "Submitted " }, { "value": "{{ $.input.expenses.submitted }}" }],
          "style": { "size": 8, "bold": false, "color": "#989a97", "align": "right", "format": "date" } }
      ]
    },
    {
      "type": "grid",
      "columns": [{ "width": 50 }, {}],
      "rows": [
        { "style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97", "paddingTop": 22 },
          "slots": [{ "type": "text", "value": "Employee" }, { "type": "text", "value": "Purpose" }] },
        { "style": { "size": 10, "bold": true, "color": "#1e1f22" },
          "slots": [{ "type": "text", "value": "{{ $.input.expenses.employee.name }}" }, { "type": "text", "value": "{{ $.input.expenses.purpose }}" }] },
        { "style": { "size": 10, "bold": false, "color": "#5e6062" },
          "slots": [{ "type": "text", "value": "{{ $.input.expenses.employee.role }}, No. {{ $.input.expenses.employee.number }}" }, { "type": "text", "value": "{{ $.input.expenses.company }}, {{ $.input.expenses.period.label }}" }] }
      ]
    },
    {
      "type": "split",
      "style": { "spaceBefore": 24 },
      "slots": [
        { "type": "text", "value": "Total expenses", "width": 25,
          "style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } },
        { "type": "text", "value": "Receipts", "width": 25,
          "style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } },
        { "type": "text", "value": "Cash advance", "width": 25,
          "style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } },
        { "type": "text", "value": "Reimbursement due",
          "style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } }
      ]
    },
    {
      "type": "split",
      "style": { "spaceAfter": 20 },
      "slots": [
        { "type": "text", "value": "{{ round($.total, 2) }}", "width": 25,
          "style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22", "format": "currency" } },
        { "type": "text", "value": "{{ $.count }}", "width": 25,
          "style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22" } },
        { "type": "text", "value": "{{ $.input.expenses.advance }}", "width": 25,
          "style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22", "format": "currency" } },
        { "type": "text", "value": "{{ round($.total - $.input.expenses.advance, 2) }}",
          "style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22", "format": "currency" } }
      ]
    },
    {
      "type": "image",
      "source": "=chart($.input.expenses.items, $.input.expenses.categories)",
      "fit": "width",
      "alt": "Expenses per category, {{ $.input.expenses.period.label }}",
      "style": { "spaceAfter": 6 }
    }
  ],
  "groups": [
    {
      "name": "category",
      "by": "[email protected]",
      "aggregates": { "subtotal": "sum:[email protected]", "receipts": "count" },
      "header": [
        {
          "type": "split",
          "style": { "spaceBefore": 14, "spaceAfter": 2 },
          "slots": [
            { "type": "image", "source": "=swatch(category.key, $.input.expenses.categories)", "fit": "width", "alt": "", "width": 3, "style": { "valign": "middle" } },
            { "type": "text", "value": "{{ category.key }}", "width": 47,
              "style": { "size": 11, "bold": true, "color": "#1e1f22" } },
            { "type": "text",
              "value": [{ "value": "{{ category.receipts }} receipts · " }, { "value": "{{ round(category.subtotal / $.total, 3) }}", "style": { "format": { "kind": "percent", "digits": 1 } } }, { "value": " of the claim" }],
              "style": { "size": 8, "bold": false, "color": "#989a97", "align": "right" } }
          ]
        }
      ]
    }
  ],
  "detail": {
    "header": {
      "style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97",
        "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
    },
    "row": { "style": { "color": "#5e6062" } },
    "columns": [
      { "header": "Date", "value": "{{ @.date }}", "width": 16, "style": { "format": "date" } },
      { "header": "Merchant", "value": "{{ @.merchant }}", "width": 28 },
      { "header": "Description", "value": "{{ @.description }}", "width": 40 },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ @.amount }}", "width": 16, "style": { "align": "right", "format": "currency" } }
    ],
    "total": {
      "style": { "color": "#1e1f22", "align": "right", "paddingTop": 3, "paddingBottom": 0,
        "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#989a97" },
      "rows": [
        {
          "cells": [
            { "value": "Subtotal {{ category.key }}", "span": 3 },
            { "value": "{{ round(category.subtotal, 2) }}", "style": { "format": "currency" } }
          ]
        }
      ]
    }
  },
  "footer": [
    {
      "type": "grid",
      "columns": [{ "width": 84, "style": { "align": "right" } }, { "style": { "align": "right", "format": "currency" } }],
      "rows": [
        { "style": { "color": "#5e6062", "paddingTop": 16 },
          "slots": [{ "type": "text", "value": "Total expenses, {{ $.count }} receipts" }, { "type": "text", "value": "{{ round($.total, 2) }}" }] },
        { "style": { "color": "#5e6062" },
          "slots": [{ "type": "text", "value": "Less cash advance" }, { "type": "text", "value": "{{ $.input.expenses.advance }}" }] },
        { "style": { "size": 11, "bold": true, "color": "#1e1f22", "paddingTop": 6, "paddingBottom": 4,
            "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
          "slots": [{ "type": "text", "value": "Reimbursement due" }, { "type": "text", "value": "{{ round($.total - $.input.expenses.advance, 2) }}" }] }
      ]
    },
    {
      "type": "grid",
      "columns": [{ "width": 45 }, { "width": 10 }, {}],
      "rows": [
        { "style": { "paddingTop": 34 },
          "slots": [
            { "type": "text", "value": "", "style": { "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
            { "type": "text", "value": "" },
            { "type": "text", "value": "", "style": { "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } }
          ] },
        { "style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97", "paddingTop": 4 },
          "slots": [
            { "type": "text", "value": "Employee · {{ $.input.expenses.employee.name }}" },
            { "type": "text", "value": "" },
            { "type": "text", "value": "Approved · {{ $.input.expenses.approver }}" }
          ] }
      ]
    }
  ],
  "page": {
    "header": [
      {
        "type": "text",
        "value": "Expense report {{ $.input.expenses.number }}, {{ $.input.expenses.employee.name }} · continued",
        "visible": "=page.number > 1",
        "style": { "size": 8, "color": "#989a97", "paddingBottom": 6, "spaceAfter": 18,
          "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
      }
    ],
    "footer": [
      {
        "type": "split",
        "style": { "paddingTop": 10,
          "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
        "slots": [
          { "type": "text", "value": "Amounts as charged, VAT included. Every line has its receipt attached. Approval releases the reimbursement with the next payroll.", "width": 80,
            "style": { "size": 8, "color": "#989a97" } },
          { "type": "text", "value": "Page {{ page.number }} of {{ page.total }}",
            "style": { "size": 8, "color": "#989a97", "align": "right" } }
        ]
      }
    ]
  }
}

The sections are a group on [email protected]. The rows sort by category first and by date second. Each instance carries its own subtotal and its count of receipts, and its header reads its share of the claim as round(category.subtotal / $.total, 3) under the percent kind. The subtotal row is detail.total, which repeats per category.

The report footer settles the claim: the total, less the cash advance, and the reimbursement due as round($.total - $.input.expenses.advance, 2). Then come two signature lines, one for the employee and one for the approver. The fine print sits in page.footer, on every page’s baseline.

Two images come from your code. The ring is =chart($.input.expenses.items, $.input.expenses.categories) in the header. Each group header opens with =swatch(category.key, $.input.expenses.categories), a small square in that category’s colour. So the table carries the chart’s colour key, and a reader never depends on the colour alone.

The chart

d3-shape’s pie() and arc() turn the totals into ring segments, and the rest is a string of SVG. The colours follow the order of categories in the data, not the size of each slice. A category keeps its colour from one claim to the next:

chart.ts
import { arc, pie } from "d3-shape";

type Expense = { category: string; amount: number };

/** One colour per category, in the order the data lists them: a category keeps its colour whatever its rank. */
const COLOURS = [
  "#2a78d6",
  "#eb6834",
  "#1baf7a",
  "#eda100",
  "#e87ba4",
  "#008300",
];

const money = new Intl.NumberFormat("en-IE", {
  style: "currency",
  currency: "EUR",
  maximumFractionDigits: 0,
});
const share = new Intl.NumberFormat("en-IE", {
  style: "percent",
  maximumFractionDigits: 1,
});

const svg = (width: number, height: number, body: string) =>
  new TextEncoder().encode(
    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" font-family="Helvetica, Arial, sans-serif" font-size="12" fill="#5e6062">${body}</svg>`,
  );

/** Spend per category as a ring, labelled beside it, returned as the SVG's bytes. */
export function chart(expenses: Expense[], categories: string[]): Uint8Array {
  const totals = categories.map((name) =>
    expenses
      .filter((e) => e.category === name)
      .reduce((sum, e) => sum + e.amount, 0),
  );
  const whole = totals.reduce((a, b) => a + b, 0);

  // `sort(null)` keeps the data's order, so the ring reads in the legend's order.
  const slices = pie<number>().sort(null)(totals);
  const ring = arc<(typeof slices)[number]>()
    .innerRadius(62)
    .outerRadius(110)
    .padAngle(0.012);

  const segments = slices
    .map(
      (slice, i) =>
        `<path d="${ring(slice)}" fill="${COLOURS[i]}" stroke="#ffffff" stroke-width="2" />`,
    )
    .join("");

  const legend = categories
    .map((name, i) => {
      const y = 34 + i * 36;
      return `
    <rect x="290" y="${y - 10}" width="12" height="12" rx="2" fill="${COLOURS[i]}" />
    <text x="312" y="${y}" fill="#1e1f22">${name}</text>
    <text x="600" y="${y}" text-anchor="end" fill="#1e1f22">${money.format(totals[i])}</text>
    <text x="680" y="${y}" text-anchor="end">${share.format(totals[i] / whole)}</text>`;
    })
    .join("");

  return svg(
    680,
    240,
    `<g transform="translate(118 120)">${segments}
    <text y="-4" text-anchor="middle" font-size="11">Total</text>
    <text y="16" text-anchor="middle" font-size="16" fill="#1e1f22">${money.format(whole)}</text></g>
    ${legend}`,
  );
}

/** The colour key for one category, as a small square's SVG bytes. */
export function swatch(category: string, categories: string[]): Uint8Array {
  const colour = COLOURS[categories.indexOf(category)] ?? "#989a97";
  // Wider than the square, so the name beside it starts clear of it.
  return svg(
    18,
    12,
    `<rect y="2" width="10" height="10" rx="2" fill="${colour}" />`,
  );
}

Six colours is the ceiling. A seventh category is better folded into one of the six than given a colour a reader can’t tell from its neighbour.

The data

The claim carries the employee, the approver, the period, and the advance. Each receipt carries its category, and categories lists them once, in the order the ring and the legend read them:

expense-breakdown.data.json
{
  "expenses": {
    "number": "EXP-2026-118",
    "company": "Quire Press Ltd.",
    "employee": { "name": "J. Bakker", "number": "10388", "role": "Rights and sales manager" },
    "approver": "M. de Graaf",
    "purpose": "Client visits in Antwerp, Cologne and Brussels",
    "period": { "label": "September 2026", "from": "2026-09-01", "to": "2026-09-30" },
    "submitted": "2026-10-02",
    "advance": 300,
    "categories": ["Travel", "Lodging", "Meals", "Local transport", "Client entertainment", "Supplies"],
    "items": [
      {"date": "2026-09-02", "merchant": "Hollandia Office", "description": "Sample folders and mailers", "category": "Supplies", "amount": 34.75},
      {"date": "2026-09-03", "merchant": "NS International", "description": "Train, Amsterdam to Antwerp, return", "category": "Travel", "amount": 89.4},
      {"date": "2026-09-03", "merchant": "Hotel Scheldekaai", "description": "1 night, Antwerp", "category": "Lodging", "amount": 168},
      {"date": "2026-09-03", "merchant": "Taxi Antwerpen", "description": "Taxi, station to client", "category": "Local transport", "amount": 18.4},
      {"date": "2026-09-03", "merchant": "Brasserie Zuid", "description": "Dinner, Antwerp", "category": "Meals", "amount": 38.5},
      {"date": "2026-09-04", "merchant": "Het Pakhuis", "description": "Dinner with Folio & Sons, 3 guests", "category": "Client entertainment", "amount": 212.6},
      {"date": "2026-09-11", "merchant": "Q-Park", "description": "Parking, Utrecht client visit", "category": "Local transport", "amount": 24},
      {"date": "2026-09-15", "merchant": "Deutsche Bahn", "description": "ICE, Amsterdam to Cologne, return", "category": "Travel", "amount": 142.8},
      {"date": "2026-09-15", "merchant": "Hotel am Domplatz", "description": "2 nights, Cologne", "category": "Lodging", "amount": 254},
      {"date": "2026-09-15", "merchant": "Bahnhof Deli", "description": "Lunch, Cologne", "category": "Meals", "amount": 21.9},
      {"date": "2026-09-16", "merchant": "KVB", "description": "Day ticket, Cologne", "category": "Local transport", "amount": 9.2},
      {"date": "2026-09-16", "merchant": "Brauhaus am Rhein", "description": "Lunch with Rheinland Verlag, 2 guests", "category": "Client entertainment", "amount": 96.4},
      {"date": "2026-09-16", "merchant": "Altstadt Bistro", "description": "Dinner, Cologne", "category": "Meals", "amount": 44.6},
      {"date": "2026-09-22", "merchant": "Copyshop Centraal", "description": "Printed dummies, 6 copies", "category": "Supplies", "amount": 58.5},
      {"date": "2026-09-24", "merchant": "NS International", "description": "Train, Amsterdam to Brussels, return", "category": "Travel", "amount": 96.2},
      {"date": "2026-09-24", "merchant": "Hotel Zavelhof", "description": "1 night, Brussels", "category": "Lodging", "amount": 149},
      {"date": "2026-09-24", "merchant": "STIB", "description": "Metro, 10-ride card", "category": "Local transport", "amount": 16.8},
      {"date": "2026-09-25", "merchant": "Le Petit Sablon", "description": "Lunch, Brussels", "category": "Meals", "amount": 26.3}
    ]
  }
}

What it renders

This isn’t a screenshot. quario compiles the definition and data above, runs chart.ts in your browser, and @quario/viewer paints the result right here. The PDF button hands you the same report as real @quario/pdf bytes, where the ring and the swatches are vector paths. There’s no Word button: @quario/docx refuses an SVG image.

Rendering live with @quario/viewerA4

Use it

shell
npm install quario @quario/pdf d3-shape
expense-breakdown.ts
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import expenses from './expense-breakdown.report.json' with { type: 'json' }

// `chart` and `swatch` are the functions from chart.ts
const q = quario({ locale: 'en-IE', currency: 'EUR' })   // what `format` presents through
const report = q.report(expenses, { chart, swatch })

const data = await getExpenses('EXP-2026-118')        // your expense claims
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

writeFileSync('expense-report.pdf', bytes)

Make it yours

A policy limit beside each category is a limit per category in the data and one more column in the legend. A section per trip instead of per category is the group’s by. A claim with no advance is an advance of 0, and the footer still reads true. The ring is your code. A bar chart in its place is a different function under the same name, and the definition doesn’t change.

FAQ

Can I export the report to Excel or Word?

Not with SVG images in it. A worksheet drawing holds a raster picture only, and Word shows an SVG only beside a raster copy. So @quario/xlsx and @quario/docx refuse one, with a render error that names the item. For those renders, register a chart and a swatch that return PNG bytes.

Why a ring and not a bar chart?

A ring shows part of a whole at a glance, and six categories is few enough to read that way. It’s weak at comparing two slices of nearly the same size. That’s why each one carries its amount and share beside it, and why the table underneath holds every figure.

Where are the other templates?

Browse the template gallery → — revenue reports, scorecards, invoices, and more.