ai newsFr., 23. Okt.
EilmeldungOpenAI skaliert Online-Speicher fuer ueber eine Milliarde ChatGPT-Nutzer

Banktransaktionen automatisch kategorisieren mit n8n und Gemini im Free Tier

Wer Kleingewerbe oder Freiberufler ist, kennt das manuelle Sortieren von Kontoauszuegen in der Tabelle. Ein Workflow mit n8n und Gemini uebernimmt die Kategorisierung automatisch und ist im Free Tier sogar kostenlos nutzbar.

dev.toFreitag 15:48 · 23.10.DE7 Min. Lesezeit
Bild: dev.to

If you run a small business or freelance, you probably know the monthly ritual: export the bank statement as CSV, open it in a spreadsheet, and type "Software", "Travel", "Bank fees" next to two hundred rows of CARD PAYMENT TO ADOBE *CREATIVE 12/09 REF 88812. It is dull, and it is exactly the kind of thing an LLM is good at, as long as you give it the right shape of problem.

This post walks through how I built an n8n workflow that takes any bank CSV, categorises every transaction with Google Gemini (free tier), and upserts the results into Google Sheets with review flags. The interesting part is not "send rows to AI". It is the three things that make it cheap, repeatable and safe to use for real books.

n8n Form (CSV upload, currency, date format) -> Extract from File (CSV rows) -> Code: normalise transactions -> Code: group unique merchants, 40 per batch -> Basic LLM Chain + Gemini + Structured Output Parser -> Code: map categories back + review flags -> Google Sheets: append or update by txn_id -> Form completion page with a summary Enter fullscreen mode Exit fullscreen mode Everything is built-in n8n nodes. No community nodes, no vector database, no paid API.

Every bank exports differently. Some have one Amount column, some have Paid in and Paid out. Some write (12.50) for negatives, some 12.50 DR, European banks write 1.234,56. Dates are 31/01/2026 or 01/31/2026 depending on the country.

Rather than asking users to map columns, a Code node guesses the columns from header patterns, collecting headers from every row (n8n's CSV reader drops empty cells, so the first row does not always have every key):

const keys = [... new Set ( rows. flatMap ( r => Object. keys ( r )))]; const find = (... pats ) => keys. find ( k => pats. some ( p => p. test ( k. trim ()))); const kDate = find ( /^ ( transaction |posting |value )? date$/i, /date/i, /posted/i ); const kDesc = find ( /desc/i, /narrative/i, /details/i, /memo/i, /payee/i, /merchant/i ); const kAmt = find ( /^amount/i, /amount/i, /^value$/i ); const kOut = find ( /debit/i, /paid out/i, /money out/i, /withdraw/i ); const kIn = find ( /credit/i, /paid in/i, /money in/i, /deposit/i ); const num = v => { if ( v === undefined || v === null || String ( v ). trim () === '' ) return null; let s = String ( v ). trim (); const neg = /^ \(.* \) $/. test ( s ) || /^-/. test ( s ) || /-$/. test ( s ) || / \b DR$/i. test ( s ); s = s. replace ( / [^ 0-9., ] /g, '' ); // "1.234,56" -> "1234.56", "1,234.56" -> "1234.56" if ( /, \d{1,2} $/. test ( s ) &&! / \.\d{1,2} $/. test ( s )) s = s. replace ( / \. /g, '' ). replace ( ', ', '. ' ); else s = s. replace ( /,/g, '' ); const n = parseFloat ( s ); return isNaN ( n )? null: ( neg? - Math. abs ( n ): n ); }; Enter fullscreen mode Exit fullscreen mode The date format is the one thing you genuinely cannot guess (is 03/04/2026 March or April?), so the upload form asks for it in a dropdown.

The same node also cleans the merchant name: upper-case it, strip dates, card words ( CARD PAYMENT, CONTACTLESS, VISA ), reference codes and * suffixes. CARD PAYMENT TO ADOBE *CREATIVE 12/09 REF 88812 becomes ADOBE. That cleaned name matters for the next step.

Finally, each row gets a stable ID built from date, amount and description. If two rows are genuinely identical, the second gets a -2 suffix, which later becomes a "possible duplicate" flag.

This is the trick that keeps it on the free tier. A typical month of 300 transactions might only have 60 unique merchants: Tesco ten times, Stripe payouts twenty times, AWS once. So instead of sending rows to the model, a Code node groups by merchant and sends 40 merchants per call:

const lines = list. slice ( i, i + 40 ). map (( m, j ) => ` ${ i + j + 1 }. ${ m. merchant } | money ${ m. ins > m. count / 2? ' IN ': ' OUT ' } | ${ m. count } txn | total ${ m. total. toFixed ( 2 )} | e.g. " ${ m. sample. slice ( 0, 60 )} "` ); Enter fullscreen mode Exit fullscreen mode Each line gives the model the direction of money, a count, a total and one raw example. Five hundred rows become two or three AI calls instead of five hundred, and the LLM Chain node's batching option adds a 4-second delay between batches so you never hit the free tier's per-minute limit.

The prompt gives a fixed category list and a handful of rules that stop the classic mistakes:

Allowed categories (use the exact spelling): Sales, Other income, Refunds received, Cost of sales, Advertising & marketing, Software & subscriptions, Bank & payment fees, Travel, Meals & entertainment, Office & supplies, Professional fees, Rent & utilities, Payroll & contractors, Tax payments, Insurance, Transfers, Owner drawings, Uncategorised Rules: - Money IN is normally Sales, Other income, Refunds received or Transfers. Money OUT is never Sales. - Transfers between the business's own accounts, card repayments and savings pots are Transfers. - HMRC, IRS, VAT, PAYE and corporation tax payments are Tax payments. - Use "Uncategorised" with confidence below 0.5 when the merchant is genuinely unclear. Never guess wildly.

Enter fullscreen mode Exit fullscreen mode A Structured Output Parser with this example forces clean JSON back:

{ "results": [ { "merchant": "ADOBE", "category": "Software & subscriptions", "confidence": 0.95, "reason": "Design software subscription" } ] } Enter fullscreen mode Exit fullscreen mode Temperature is 0.1. You want boring and consistent here, not creative.

Another Code node maps the categories back onto every transaction and decides which rows a human should look at:

if ( t. direction === ' out ' && [ ' Sales ', ' Other income ' ]. includes ( category )) check = ' Money out labelled as income '; if ( t. direction === ' in ' &&! INCOME. includes ( category ) && category!== ' Uncategorised ' ) check = ' Money in labelled as an expense '; if ( /- \d +$/. test ( t. txn_id )) check = ' Possible duplicate: same date, amount and description as another row '; const needs_review = category === ' Uncategorised ' || confidence < 0.7 ||!! check; Enter fullscreen mode Exit fullscreen mode Sign mismatches are the most useful check. The model occasionally calls a refund from a supplier an expense, and the direction of money makes that easy to catch deterministically.

The Google Sheets node uses Append or Update with txn_id as the matching column. That means you can upload overlapping statements (1 to 31 October, then 15 October to 15 November) and nothing is duplicated. It also means a re-run never wipes the corrected_category column where you have fixed things by hand.

txn _ id | date | description | merchant | amount | currency | category | confidence | needs _ review | review _ note | ai _ reason | corrected _ category Enter fullscreen mode Exit fullscreen mode The form then shows a summary page: total in, total out (excluding transfers), spend by category and top suppliers.

The version above is the complete free approach. I also built a Pro version for people doing this every month: keyword rules first in a Rules tab so known merchants never hit the AI, a daily job that turns your corrected_category fixes into new rules, your own chart of accounts with VAT rates and VAT estimates, recurring subscription and anomaly detection, and a monthly P&L email on the 1st. It is a paid template ($29) on Payhip if it saves you the build: Bank transaction categoriser Pro. More of my n8n templates are on the store page and on my n8n creator profile.

Questions about the normalisation logic or a bank format it chokes on? Drop it in the comments and I'll take a look.

Originaltitel: How to auto-categorise bank transactions with n8n and Gemini (free tier)Quelle: dev.to (dev.to). Original öffnen