RecipesFinance & bookkeeping

Recipes: finance & bookkeeping

Two pipelines that make the money admin a habit instead of a chore — chasing what you’re owed, and turning receipts into rows.


Invoice chaser

Each morning, pull overdue invoices and send yourself a tidy chase list — who owes what, oldest first — so getting paid becomes a 30-second daily habit. Shown with Xero; QuickBooks works the same. Auth is an oauth2 credential you connect once.

The shape:

overdue-invoices (xero, daily) → overdue-summary (code) → overdue-gate (filter) → chase-list (email)
# jobs/finance/overdue.yaml
id: overdue-invoices
name: Overdue invoices
group: finance
type: xero
credential: xero
operation: getInvoices
where: 'Type=="ACCREC"&&Status=="AUTHORISED"&&AmountDue>0'
schedule:
  - { kind: daily, time: '08:00' }

A code transform totals what’s owed and builds a per-invoice list:

# jobs/finance/overdue-summary.yaml
id: overdue-summary
name: Summarize overdue
group: finance
type: code
script: |
  const inv = (($parent.json && $parent.json.Invoices) || []);
  let total = 0;
  const lines = [];
  for (const i of inv) {
    total += Number(i.AmountDue || 0);
    const who = (i.Contact && i.Contact.Name) || 'Unknown';
    lines.push(who + ' — ' + (i.InvoiceNumber || '') + ': ' + i.AmountDue + ' (due ' + (i.DueDate || '?') + ')');
  }
  return { count: inv.length, total, list: lines.join('\n') };
schedule:
  - { kind: afterParent, parent: overdue-invoices, on: succeeded }
# jobs/finance/overdue-gate.yaml
id: overdue-gate
name: Anything overdue?
group: finance
type: filter
input: '{{ $parent.json.count }}'
operator: gt
value: '0'
schedule:
  - { kind: afterParent, parent: overdue-summary, on: succeeded }
# jobs/finance/chase-list.yaml
id: chase-list
name: Overdue chase list
group: finance
type: email
to: '{{ $vars.ownerEmail }}'
subject: '{{ $parent.json.count }} overdue invoice(s) — {{ $parent.json.total }} outstanding'
body: |
  Time to chase:
 
  {{ $parent.json.list }}
schedule:
  - { kind: afterParent, parent: overdue-gate, on: succeeded, branch: pass }

Chase each customer automatically. To email a reminder the moment a payment fails, use a stripe-trigger on invoice.payment_failedfiltergmail/email — that fires once per event, so each invoice gets its own reminder.


Receipt inbox → books

Forward (or BCC) receipts to a dedicated mailbox and each one is parsed into a row in your expenses sheet — vendor, amount, date, category — bookkeeping prep that does itself.

The shape:

receipts (inbox) → receipt-extract (claude-api) → receipt-parse (code) → log-expense (sheets)

An inbox trigger fires once per new message (the first poll only sets the watermark, so it never replays old mail):

# jobs/finance/receipts.yaml
id: receipts
name: Receipts inbox
group: finance
type: inbox
credential: receipts-imap
host: imap.fastmail.com
mailbox: INBOX
pollSeconds: 120
schedule: []

A claude-api step extracts the fields as strict JSON:

# jobs/finance/extract.yaml
id: receipt-extract
name: Extract receipt
group: finance
type: claude-api
credential: anthropic
maxTokens: 200
system: >
  Extract the receipt's vendor, total amount (number only), ISO date (YYYY-MM-DD), and a spending
  category. Reply with ONLY minified JSON: {"vendor":"","amount":"","date":"","category":""}
prompt: '{{ $parent.json.text }}'
schedule:
  - { kind: afterParent, parent: receipts, on: succeeded }

A code step parses that JSON text into fields for the sheet columns:

# jobs/finance/parse.yaml
id: receipt-parse
name: Parse receipt JSON
group: finance
type: code
script: |
  let r;
  try { r = JSON.parse($parent.text); }
  catch (e) { throw new Error('Could not parse receipt JSON: ' + String($parent.text).slice(0, 200)); }
  return {
    vendor: r.vendor || '',
    amount: r.amount || '',
    date: r.date || '',
    category: r.category || '',
  };
schedule:
  - { kind: afterParent, parent: receipt-extract, on: succeeded }
# jobs/finance/log-expense.yaml
id: log-expense
name: Log expense
group: finance
type: sheets
credential: google_sa
spreadsheetId: '{{ $vars.expensesSheetId }}'
operation: appendRow
range: Expenses!A:E
values: |
  {{ $parent.json.date }}
  {{ $parent.json.vendor }}
  {{ $parent.json.amount }}
  {{ $parent.json.category }}
  {{ $now.iso }}
schedule:
  - { kind: afterParent, parent: receipt-parse, on: succeeded }

Note: the inbox trigger reads plain-text bodies well; forward receipts as text (or with the key details in the body) rather than relying on PDF attachments, which v1 doesn’t parse.