Tutorial · Use Case · Automation

How to Build a Spend Dashboard From Receipts and Invoices (Google Sheets and Looker Studio)

By Othmane M., founder of DocuParse API

2026-11-03 · 10 min read

A spend dashboard answers a few plain questions. What did we spend this month? On what? With whom? How much of it was tax? Is anything unusual?

The charts are the easy part. The hard part is getting every receipt and invoice into rows with clean numbers, a date you can group by and a category. This tutorial builds the whole thing with tools most small teams already have: a Google Drive folder, a Google Sheet, a short Apps Script and Looker Studio.

If you don't want to build anything, skip to the no-code option at the end. If you're a developer feeding your own finance dashboard from code, receipt and invoice data for finance dashboards covers that route.

What you are building

  1. You drop receipts and invoices (PDF, JPG or PNG) into a Drive folder called Receipts Inbox.
  2. Every 15 minutes, a script sends new files to a parsing API, writes one row per document to a sheet, and moves the file to Receipts Processed. Anything that can't be read goes to Receipts Needs Attention.
  3. A rules table assigns a category from the merchant name.
  4. Summary tables and a Looker Studio report show spend by month, category and vendor.

You need a Google account and a DocuParse API key. Sign up free for one: the free plan covers 20 documents a month with full API access, which is enough to build and test this.

Step 1: Set up the sheet

Create a Google Sheet with two tabs.

The Expenses tab gets these headers in row 1:

A B C D E F G H I J K L M
Date Month Merchant Category Currency Subtotal Tax Total Type Number Needs review File Document ID

Before you add any data, select columns B and J and set them to plain text (Format, Number, Plain text). Otherwise Sheets turns a month like 2026-10 into a date and strips leading zeros from document numbers like 000412.

The Rules tab maps merchant text to a category. Column A is the text to look for, column B is the category:

Merchant contains Category
shell Fuel
uber Travel
amazon web services Software and hosting
staples Office supplies
starbucks Meals

Matching is case-insensitive and partial, so shell matches "Shell Station #4471". Start with your ten most common vendors. You'll add more as you go.

Step 2: Create the three Drive folders

Make Receipts Inbox, Receipts Processed and Receipts Needs Attention in Drive. Open each one and copy the folder ID from the URL, the long string after /folders/.

Step 3: Add the script

In the sheet, open Extensions, then Apps Script. Replace the contents of Code.gs with this:

javascript · 105 lines
const API = 'https://docuparseapi.com/api/v1';
const INBOX_ID = 'PASTE_INBOX_FOLDER_ID';
const DONE_ID = 'PASTE_PROCESSED_FOLDER_ID';
const ATTENTION_ID = 'PASTE_NEEDS_ATTENTION_FOLDER_ID';
const MAX_PER_RUN = 3;    // files handled per run
const POLLS_PER_FILE = 8; // result checks per file per run, 1.5 seconds apart

function processInbox() {
  const ss = SpreadsheetApp.getActive();
  const sheet = ss.getSheetByName('Expenses');
  const rules = ss.getSheetByName('Rules').getDataRange().getValues().slice(1);
  const pending = PropertiesService.getScriptProperties();
  const files = DriveApp.getFolderById(INBOX_ID).getFiles();

  let handled = 0;
  while (files.hasNext() && handled < MAX_PER_RUN) {
    const file = files.next();
    handled++;
    try {
      // Each file is submitted once. Its document_id is saved, so later runs only fetch.
      let documentId = pending.getProperty(file.getId());
      if (!documentId) {
        const submitted = submit(file.getBlob());
        if (!submitted.success) {
          console.error(file.getName() + ': ' + submitted.error.code + ' ' + submitted.error.message);
          file.moveTo(DriveApp.getFolderById(ATTENTION_ID));
          continue;
        }
        documentId = submitted.document_id;
        pending.setProperty(file.getId(), documentId);
      }

      const doc = fetchResult(documentId);
      if (!doc) continue; // still processing: the next run checks again

      if (doc.status === 'completed') {
        sheet.appendRow(toRow(doc, file, rules));
        file.moveTo(DriveApp.getFolderById(DONE_ID));
      } else {
        const reason = doc.error ? doc.error.code + ' ' + doc.error.message : 'failed';
        console.error(file.getName() + ': ' + reason);
        file.moveTo(DriveApp.getFolderById(ATTENTION_ID));
      }
      pending.deleteProperty(file.getId());
    } catch (e) {
      console.error(file.getName() + ': ' + e.message); // network error: retried next run
    }
  }
}

function apiHeaders() {
  const key = PropertiesService.getScriptProperties().getProperty('DOCUPARSE_API_KEY');
  return { Authorization: 'Bearer ' + key };
}

// Step 1: submit the file. Returns { success, document_id } or { success: false, error }.
function submit(blob) {
  return JSON.parse(UrlFetchApp.fetch(API + '/extract', {
    method: 'post',
    headers: apiHeaders(),
    payload: { file: blob }, // sent as multipart/form-data
    muteHttpExceptions: true,
  }).getContentText());
}

// Step 2: fetch the result. Returns the document once it is completed or failed, else null.
function fetchResult(documentId) {
  for (let i = 0; i < POLLS_PER_FILE; i++) {
    const doc = JSON.parse(UrlFetchApp.fetch(API + '/documents/' + documentId, {
      headers: apiHeaders(),
      muteHttpExceptions: true,
    }).getContentText());
    if (doc.status === 'completed' || doc.status === 'failed') return doc;
    Utilities.sleep(1500);
  }
  return null;
}

function toRow(result, file, rules) {
  const d = result.data;
  const num = (v) => (v === null || v === undefined ? '' : Number(v));
  return [
    d.date ? new Date(d.date + 'T00:00:00') : '',
    d.date ? d.date.slice(0, 7) : '',
    d.merchant || '',
    categorize(d.merchant, rules),
    d.currency || '',
    num(d.subtotal),
    num(d.tax),
    num(d.total),
    d.document_type || '',
    d.document_number || '',
    d.needs_review ? 'Yes' : 'No',
    file.getUrl(),
    result.document_id,
  ];
}

function categorize(merchant, rules) {
  const name = (merchant || '').toLowerCase();
  for (const [text, category] of rules) {
    if (text && name.includes(String(text).toLowerCase())) return category;
  }
  return 'Uncategorized';
}

Then store your key so it isn't sitting in the code. In the Apps Script editor, open Project Settings, scroll to Script Properties, and add DOCUPARSE_API_KEY with your key as the value.

A few notes on the script:

  • payload: { file: blob } is how Apps Script sends a file as a form upload. Google's UrlFetchApp reference documents that an object payload containing a blob is sent as form data.
  • Extraction is a two-step flow: the first call returns a document_id, and fetchResult fetches the finished result. Reading fields off the first response is the most common integration bug.
  • Each file is submitted only once. The script saves its document_id in Script Properties, so if a result isn't ready yet, the next run fetches it instead of uploading the file again. Every submit counts toward your monthly documents, so this matters.
  • A file that is rejected or can't be read moves to Receipts Needs Attention, and the reason is in the execution log. If the reason is your monthly limit, move the files back to the inbox once it resets.
  • Each run handles at most three files and waits at most about 12 seconds for each, which keeps runs well under Apps Script's 6-minute limit and inside the daily trigger allowance of a free Google account.
  • Amounts come back as decimal strings like "34.17", so Number() turns them into real numbers you can sum. The JSON schema lists every field if you want more columns.

Step 4: Run it on a timer

Run processInbox once by hand from the editor. Google will ask you to authorize access to Drive, Sheets and external requests. Drop a test receipt in the inbox first so you can see a row appear.

Then open Triggers (the clock icon), add a trigger for processInbox, choose "Time-driven", and set it to every 15 minutes.

iPhone photos are often saved as HEIC, which isn't an accepted format. Either set the camera to "Most Compatible" so it saves JPG, or upload receipts through an app that converts them.

Step 5: Summary tables with QUERY

Add a Summary tab. These formulas assume one main currency, USD here. Change 'USD' to yours.

Spend by month and category:

text · 1 line
=QUERY(Expenses!A:M, "select B, D, sum(H) where E = 'USD' and B is not null group by B, D order by B label sum(H) 'Spent'", 1)

Top ten vendors:

text · 1 line
=QUERY(Expenses!A:M, "select C, count(C), sum(H) where E = 'USD' group by C order by sum(H) desc limit 10 label count(C) 'Documents', sum(H) 'Spent'", 1)

Tax paid per month:

text · 1 line
=QUERY(Expenses!A:M, "select B, sum(G) where E = 'USD' and B is not null group by B order by B label sum(G) 'Tax'", 1)

Documents waiting for review:

text · 1 line
=FILTER(Expenses!A:L, Expenses!K:K = "Yes")

That last one is your to-do list. Rows marked Needs review are documents the extractor flagged as not adding up. Open the file link, compare, and fix the row. For more checks you can add yourself, see six checks that catch bad extractions.

Step 6: The Looker Studio report

Sheets formulas are enough for one person. If you want something to share, Looker Studio is free and reads the sheet directly.

  1. Go to Looker Studio, create a blank report, and add the Google Sheets connector. Pick your spreadsheet and the Expenses tab.
  2. Check the field types in the data source. Date should be a date, Total, Tax and Subtotal should be numbers, everything else text.
  3. Add these charts:
    • Scorecards for sum of Total, sum of Tax and record count.
    • A time series of Total by Date, set to monthly.
    • A bar chart of Total by Category.
    • A table of Merchant, record count and sum of Total, sorted by spend.
  4. Add a drop-down control on Currency and a date range control at the top.

The currency control matters. A dashboard that adds 100 EUR and 100 USD into "200" is wrong in a way nobody notices. If you have meaningful spend in several currencies, add a column with the amount converted at the rate on the document date and chart that column instead.

Keeping the categories honest

The rules table does most of the work, but some rows will land in Uncategorized. Once a week:

  1. Filter Expenses to Uncategorized.
  2. Fix each category by hand.
  3. Add a rule for any vendor you expect to see again.

After a few weeks, most new rows get a category on arrival. Vendors that sell many kinds of things, like a supermarket or Amazon, may never be safe to categorize from the name alone. Leave those for a person, or split them using the line_items the API returns.

The no-code option

If you would rather not run a script, DocuParse API has a built-in spend dashboard. Upload documents in the dashboard, one at a time or as a batch, or send them through the API. The dashboard shows total spend, document count, total tax and average document value, with filters for date range, document type and currency. Pick one currency in the filter before you read Total Spend: with All currencies selected, it adds every currency together. You can export the data to CSV or JSON, or download a formatted PDF report.

A simple routine is to upload during the month, export the CSV at month end, and import it into its own tab. The QUERY formulas and Looker Studio report above work on that tab too once you point them at the matching column letters.

For other routes into a spreadsheet, including n8n, Make and Zapier, see how to convert PDF invoices to Excel or Google Sheets automatically. For the full picture, from capture to record keeping, start with the receipt and invoice data entry guide.

More from the blog

All posts →