<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alessio Bianchini</title>
    <description>The latest articles on DEV Community by Alessio Bianchini (@alessio_bianchini).</description>
    <link>https://dev.to/alessio_bianchini</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4184671%2Fff6bfb33-8b7b-4380-860f-64a68ff62943.png</url>
      <title>DEV Community: Alessio Bianchini</title>
      <link>https://dev.to/alessio_bianchini</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alessio_bianchini"/>
    <language>en</language>
    <item>
      <title>I was tired of manual bookkeeping, so I wrote an offline receipt parser with Ollama</title>
      <dc:creator>Alessio Bianchini</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:32:16 +0000</pubDate>
      <link>https://dev.to/alessio_bianchini/i-was-tired-of-manual-bookkeeping-so-i-wrote-an-offline-receipt-parser-with-ollama-3jn7</link>
      <guid>https://dev.to/alessio_bianchini/i-was-tired-of-manual-bookkeeping-so-i-wrote-an-offline-receipt-parser-with-ollama-3jn7</guid>
      <description>&lt;p&gt;Last weekend was tax prep weekend. &lt;/p&gt;

&lt;p&gt;Like every quarter, I found myself sitting at my desk with an iced coffee, staring at a Downloads folder with 40-something random files: AWS invoices, Uber rides, railway tickets, client coffee receipts, and software subscriptions. And like every quarter, I was manually opening each PDF, finding the total amount, locating the VAT percentage, and typing it into a spreadsheet like a caveman.&lt;/p&gt;

&lt;p&gt;I looked at commercial receipt scanners. They all have the same two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;They want $25 to $40 a month. I do my bookkeeping four times a year; I'm not paying a recurring \$360/year subscription for something I touch once a quarter.&lt;/li&gt;
&lt;li&gt;They make you upload everything to their servers. My freelance invoices contain NDA-protected project scopes, client corporate names, and my personal card details. I am not sending all of that to an unvetted cloud API.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I saw the Hacktoberfest Week 2 challenge theme (&lt;strong&gt;"Side Hustle"&lt;/strong&gt;—build something with open-source AI that solves a real problem someone would actually pay for), I realized this was the perfect weekend project.&lt;/p&gt;

&lt;p&gt;The tool is called &lt;strong&gt;LedgerLocal&lt;/strong&gt;. It's a small local app that extracts dates, vendors, tax/VAT rates, and deduction categories from your receipts using an open-weight model running on your own machine. Zero cloud calls, zero token bills.&lt;/p&gt;




&lt;h3&gt;
  
  
  Why this only works with open-source models
&lt;/h3&gt;

&lt;p&gt;If you build this with closed APIs (like OpenAI or Claude), the product doesn't make sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The privacy wall:&lt;/strong&gt; The moment you tell a freelancer or a small business accountant &lt;em&gt;"just upload your client invoices to our cloud backend"&lt;/em&gt;, anyone dealing with serious NDAs will say no. With a local open-weight model running through Ollama, data privacy isn't a legal promise in a privacy policy—it's physically impossible for data to leak because the packets never leave &lt;code&gt;localhost&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unit economics:&lt;/strong&gt; If you want to sell a utility like this as a one-time purchase (say, $20 on Gumroad) or release it as a free tool with optional paid add-ons, you can't pay \$0.03 per API call. If a user processes a backlog of 500 invoices from the past two years, cloud API fees would kill you. With local inference, processing 10 receipts or 10,000 receipts costs the exact same: $0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline reliability:&lt;/strong&gt; I often do admin work while traveling on trains or flights where connection drops constantly. A local model doesn't care if you have WiFi or not.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  How I built it (without framework bloat)
&lt;/h3&gt;

&lt;p&gt;I didn't use LangChain, LlamaIndex, or any heavy agent framework for this. For a tool like this, you don't need 15 layers of abstractions; you just need clean parsing and reliable JSON.&lt;/p&gt;

&lt;p&gt;The pipeline is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Text Extraction:&lt;/strong&gt; When you upload a PDF or text file, &lt;code&gt;pypdf&lt;/code&gt; extracts the raw text. If it's an image, it base64-encodes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local Inference:&lt;/strong&gt; It posts the raw text to Ollama running &lt;code&gt;phi4-mini:latest&lt;/code&gt; (or &lt;code&gt;llama3.2-vision&lt;/code&gt; if you want multimodal). &lt;code&gt;phi4-mini&lt;/code&gt; is roughly 3.8B parameters, fits comfortably in 4GB of RAM, and parses an invoice on my laptop in about 2-3 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured Output:&lt;/strong&gt; I used Ollama's native &lt;code&gt;format: "json"&lt;/code&gt; with a strict prompt specifying standard accounting deduction categories (Software, Hosting, Meals, Travel, etc.) and explicit rules for VAT and ISO dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation &amp;amp; Storage:&lt;/strong&gt; A Pydantic v2 schema validates the output and saves it into a local SQLite file (&lt;code&gt;ledger.db&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI:&lt;/strong&gt; I didn't want a 500MB &lt;code&gt;node_modules&lt;/code&gt; folder, so the web UI is a single &lt;code&gt;index.html&lt;/code&gt; file using standard system fonts, responsive CSS, and vanilla JavaScript &lt;code&gt;fetch()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab0ar8fyzg8gaco6jyw9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab0ar8fyzg8gaco6jyw9.png" alt="LedgerLocal Dashboard" width="799" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the core Ollama caller from &lt;code&gt;parser.py&lt;/code&gt;—just clean Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_ollama&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phi4-mini:latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Keep it deterministic
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_predict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  The messy reality of invoice parsing (and how I fixed it)
&lt;/h3&gt;

&lt;p&gt;Building the happy path took two hours. Handling real-world receipts took the rest of the weekend:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous Dates:&lt;/strong&gt; Depending on where the receipt comes from, &lt;code&gt;05/06/2026&lt;/code&gt; could be May 6th or June 5th. I added a regex validation check in the pipeline; if the model can't determine the date with high confidence, it falls back to &lt;code&gt;null&lt;/code&gt; so you can manually select it instead of silently logging the wrong tax quarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Italian "Documento Commerciale":&lt;/strong&gt; Local Italian restaurant and taxi receipts don't have an "Invoice Number"; they have an &lt;code&gt;RT&lt;/code&gt; (Registratore Telematico) code like &lt;code&gt;RT: 99M102391024&lt;/code&gt;. The model was initially returning &lt;code&gt;null&lt;/code&gt; for invoice IDs on these. I tuned the prompt instructions to recognize fiscal printer IDs as valid document references.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-currency headaches:&lt;/strong&gt; I had receipts in USD (DigitalOcean, GitHub) and receipts in EUR (trains, lunches). My first prototype summed all numbers into one total. That obviously makes no sense, so I updated SQLite queries to separate balances by currency (&lt;code&gt;EUR: 86.33&lt;/code&gt;, &lt;code&gt;USD: 69.68&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop:&lt;/strong&gt; No LLM is 100% accurate on messy documents. The UI includes an inline "Edit" button on every row so you can tweak any field in two seconds before exporting.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Testing it on a real freelancer
&lt;/h3&gt;

&lt;p&gt;To test if someone would actually use this, I sent the repo to Ale, a freelance consultant friend. I had him drop 15 of his Q3 receipts into the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It parsed 15 invoices (PDFs and text receipts) in under 4 minutes.&lt;/li&gt;
&lt;li&gt;Digital invoices from Google Cloud, GitHub, and Adobe were recognized with 100% accuracy on tax rates and totals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What he asked for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"Can you add a CLI command? Sometimes I just want to run a terminal script against a folder without opening a browser."&lt;/em&gt;
-&amp;gt; Added &lt;code&gt;python -m src.cli import ./folder&lt;/code&gt; and &lt;code&gt;python -m src.cli export -o expenses.csv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Would I pay for this?"&lt;/em&gt;
His exact answer: &lt;em&gt;"I would never pay a monthly subscription for this, but if this was packaged as a standalone desktop app for €25, I'd buy it immediately just so I never have to manually type VAT numbers into Excel again."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Running it yourself
&lt;/h3&gt;

&lt;p&gt;The code is completely open-source (MIT license):&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/alessiobianchini/ledgerlocal" rel="noopener noreferrer"&gt;github.com/alessiobianchini/ledgerlocal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Setup takes about 2 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/alessiobianchini/ledgerlocal.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ledgerlocal
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Run the web dashboard:&lt;/span&gt;
python run.py

&lt;span class="c"&gt;# Or use the CLI:&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; src.cli import ./sample_data
python &lt;span class="nt"&gt;-m&lt;/span&gt; src.cli list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8000&lt;/code&gt;, drag your receipts in, and hit &lt;strong&gt;Export CSV&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you're a freelancer dealing with quarterly receipts, give it a spin. If it breaks on a receipt format from your country, open an issue or submit a PR on GitHub.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>python</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
