<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[StatementHaul]]></title><description><![CDATA[StatementHaul]]></description><link>https://statementhaul.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 21:12:56 GMT</lastBuildDate><atom:link href="https://statementhaul.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Bank Only Gives PDF Statements? Convert to CSV/QBO and Check Balances Before Import]]></title><description><![CDATA[AI-assisted: drafted with AI help and checked against our own guides and local test runs. Disclosure: written by Haul Labs, which makes StatementHaul. A…]]></description><link>https://statementhaul.hashnode.dev/bank-only-gives-pdf-statements-convert-to-csv-qbo-and-check-balances-before-import</link><guid isPermaLink="true">https://statementhaul.hashnode.dev/bank-only-gives-pdf-statements-convert-to-csv-qbo-and-check-balances-before-import</guid><dc:creator><![CDATA[StatementHaul team]]></dc:creator><pubDate>Tue, 27 Oct 2026 23:23:55 GMT</pubDate><content:encoded><![CDATA[<p><em>AI-assisted: drafted with AI help and checked against our own guides and local test runs. Disclosure: written by Haul Labs, which makes StatementHaul.</em></p>
<p>A common bookkeeping situation: a client's bank only lets them download statements as PDFs, and you need the transactions in a spreadsheet or in accounting software. This walkthrough covers that case: how to tell whether the PDF can be converted, how to convert it, and how to prove that nothing was dropped before you import it.</p>
<h2>Step 1: Check that the PDF has a text layer</h2>
<p>A scanned or photographed statement is only a picture of the page, with no text layer. StatementHaul doesn't do OCR, so scanned statements can't be converted. The free <a href="https://statementhaul.haullabs.dev/pdf-check?utm_source=hashnode&amp;utm_campaign=pdf_only">PDF check</a> runs in your browser and shows whether the PDF has a text layer, how many pages it has and how much text each page holds. If it says mixed, some pages have text and others don't, for example a scanned page added to an electronic statement.</p>
<h2>Step 2: Convert it</h2>
<ol>
<li>Drop the statement PDF into <a href="https://statementhaul.haullabs.dev/?utm_source=hashnode&amp;utm_campaign=pdf_only">StatementHaul</a>. The conversion runs in your browser.</li>
<li>Download a CSV for a spreadsheet, or a .qbo file if you use QuickBooks.</li>
</ol>
<h2>Step 3: The balance check (opening + credits - debits = closing)</h2>
<p>Before you import, the totals have to tie out: opening balance plus credits minus debits must equal the closing balance printed on the statement. StatementHaul does this for you. If it doesn't tie out, you see the difference and the first row where it breaks.</p>
<h2>Step 4: Page carry-forward on multi-page statements</h2>
<p>Where the statement has a running balance column, StatementHaul also checks every row and each page, and flags the exact row where the numbers stop adding up. In our local tests, a synthetic 2-page statement with 46 rows (opening 8,421.17, closing 34,775.64) reconciled with a difference of 0.00 and both pages passed. A tampered copy of the same statement still parsed all 46 rows but came back as a mismatch: off by 10.00, first problem at row 11, page 1 failing and page 2 passing. So you know which page to look at, not just that something is wrong.</p>
<h2>Step 5: What to do on a mismatch</h2>
<p>Another real local run: demo_slip_row5.pdf is an 8-row synthetic test statement (opening 2,500.00, closing 3,132.86) with one row altered. The parser read all 8 rows, but the check reported a mismatch of 10.00 with the first problem at row 5.</p>
<ul>
<li>Open the PDF at the flagged row and compare it with the CSV line.</li>
<li>On a first reconciliation, a mismatch is often an incorrect opening balance, so check that first.</li>
<li>Don't import the file until it ties out. A file that's off by 10.00 imports without complaint and only shows up later in reconciliation.</li>
</ul>
<h2>Limits, honestly</h2>
<ul>
<li>No scanned or image-only PDFs (no OCR).</li>
<li>The parser is generic, and it hasn't been tested on most real banks' current statement layouts. Always look at the balance result.</li>
</ul>
<h2>Price</h2>
<p>5 statements a month free; statements that don't balance aren't counted. After that, $9 for 10 statements or $29 for 100. <a href="https://statementhaul.haullabs.dev/?utm_source=hashnode&amp;utm_campaign=pdf_only">Convert a statement</a> or <a href="https://statementhaul.haullabs.dev/pdf-check?utm_source=hashnode&amp;utm_campaign=pdf_only">check a PDF first</a>.</p>
<p><em>Test figures above are from synthetic test statements in our own test suite, not client data. Not affiliated with any bank or with Intuit/QuickBooks.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Check a Bank-Statement PDF Conversion Balances Before Importing to QuickBooks]]></title><description><![CDATA[Opening + credits − debits = closing, checked row by row: a spreadsheet formula and a free 40-line Python script that points to the first row that breaks]]></description><link>https://statementhaul.hashnode.dev/how-to-check-a-bank-statement-pdf-conversion-balances-before-importing-to-quickbooks</link><guid isPermaLink="true">https://statementhaul.hashnode.dev/how-to-check-a-bank-statement-pdf-conversion-balances-before-importing-to-quickbooks</guid><category><![CDATA[accounting]]></category><category><![CDATA[quickbooks]]></category><category><![CDATA[Python]]></category><category><![CDATA[excel]]></category><dc:creator><![CDATA[StatementHaul team]]></dc:creator><pubDate>Sun, 25 Oct 2026 20:31:08 GMT</pubDate><content:encoded><![CDATA[<p>When you convert a PDF bank statement to CSV, whether with a converter, copy-paste, or OCR, the file can come out <em>almost</em> right. A dropped row, a debit read as a credit, a 9 read as a 0, or a page-break line counted twice all import into QuickBooks without complaint. You only find out at reconciliation, when the difference isn't $0.00 and you're hunting through 300 lines.</p>
<p>You can catch all of that <strong>before</strong> importing with one piece of arithmetic the statement already gives you.</p>
<h2>The math</h2>
<p>Every bank statement prints an opening balance and a closing balance. Most also print a running balance after each transaction. Two things must hold:</p>
<ol>
<li><strong>Overall:</strong> <code>opening + total credits − total debits = closing</code></li>
<li><strong>Row by row:</strong> <code>previous balance + this row's credit − this row's debit = this row's printed balance</code></li>
</ol>
<p>The overall check tells you <em>whether</em> something is wrong. The row-by-row check tells you <em>where</em>. The first row where your computed balance stops matching the printed one is where the conversion went wrong. Every row after it will also be off by the same amount, so ignore those and look at the first one.</p>
<p>A few rules that save time:</p>
<ul>
<li>Work in cents. Round to 2 decimals before comparing, because floating-point sums like 0.1 + 0.2 aren't exactly 0.3.</li>
<li>Treat the Debit and Credit columns as <strong>amounts without a sign</strong>. Some banks print debits as <code>(15.00)</code> or <code>-15.00</code>. If you then subtract that, you subtract a negative and the debit gets <em>added</em>. We hit exactly this bug while testing the script below.</li>
<li>If the statement has no running balance column, you can still do the overall check. It just won't pinpoint the row.</li>
<li>The size of the difference is a clue. A difference that equals one transaction usually means a missing or duplicated row. A difference of twice a transaction means its sign is flipped. A difference divisible by 9 often means two digits were swapped.</li>
</ul>
<h2>Option 1: Excel or Google Sheets</h2>
<p>Lay out the converted data as <strong>A</strong> Date, <strong>B</strong> Description, <strong>C</strong> Credit, <strong>D</strong> Debit, <strong>E</strong> Balance (as printed), with headers in row 1. Put the statement's opening balance in <strong>J1</strong> and its closing balance in <strong>J2</strong>.</p>
<p>In <strong>F2</strong> (the computed running balance), then fill down:</p>
<pre><code class="language-plaintext">=ROUND($J$1+SUM($C$2:C2)-SUM($D$2:D2),2)
</code></pre>
<p>In <strong>G2</strong> (the check), then fill down:</p>
<pre><code class="language-plaintext">=IF(E2="","",IF(ROUND(E2,2)=F2,"ok","BREAK"))
</code></pre>
<p>First broken row (anywhere outside column G):</p>
<pre><code class="language-plaintext">=IFERROR(MATCH("BREAK",G:G,0),"none")
</code></pre>
<p>Overall check:</p>
<pre><code class="language-plaintext">=IF(ROUND(J1+SUM(C:C)-SUM(D:D),2)=ROUND(J2,2),"ok","OFF")
</code></pre>
<p>We tested these formulas with LibreOffice Calc on a 4-row sample where rent of 950.00 had been mis-keyed as 590.00. Row 3 showed BREAK, the first-break cell returned <strong>3</strong>, and the overall check returned <strong>OFF</strong>. If your debits are printed with minus signs or parentheses, wrap C and D in <code>ABS()</code> or clean them first.</p>
<h2>Option 2: A free Python script</h2>
<p>No libraries needed, only the Python 3 standard library. It uses <code>Decimal</code>, so there are no floating-point surprises. It reads a CSV with columns <code>Date,Description,Credit,Debit,Balance</code> (Balance is optional). It understands <code>1,200.00</code>, <code>$950.00</code> and <code>(15.00)</code>, and it stops at the first row that breaks.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Flag the first row where a converted bank statement stops balancing.
Usage: python3 check_balance.py statement.csv OPENING CLOSING
CSV columns: Date,Description,Credit,Debit,Balance (Balance optional)."""
import csv, sys
from decimal import Decimal, InvalidOperation

def money(s):
    s = (s or "").strip().replace(",", "").replace("$", "")
    if s.startswith("(") and s.endswith(")"):
        s = "-" + s[1:-1]
    return Decimal(s) if s else Decimal("0")

def main(path, opening, closing):
    run = money(opening)
    with open(path, newline="", encoding="utf-8-sig") as f:
        for n, row in enumerate(csv.DictReader(f), start=2):  # line 1 = header
            try:
                # Debit/Credit columns are magnitudes; banks sometimes print debits as (15.00)
                run += abs(money(row.get("Credit"))) - abs(money(row.get("Debit")))
                printed = (row.get("Balance") or "").strip()
                if printed and money(printed) != run:
                    print(f"FIRST BREAK at CSV line {n} ({row.get('Date')} {row.get('Description')}): "
                          f"expected {run:,.2f}, statement shows {money(printed):,.2f}, off by {money(printed) - run}")
                    return 1
            except InvalidOperation:
                print(f"Line {n}: unreadable amount {row}")
                return 1
    if run != money(closing):
        print(f"Rows all chain, but computed closing {run} != statement closing {money(closing)} "
              f"(off by {money(closing) - run}): a row is probably missing.")
        return 1
    print(f"OK: opening {money(opening)} + credits - debits = closing {run}")
    return 0

if __name__ == "__main__":
    if len(sys.argv) != 4:
        sys.exit(__doc__)
    sys.exit(main(*sys.argv[1:]))
</code></pre>
<p>Run it:</p>
<pre><code class="language-plaintext">python3 check_balance.py october.csv 5000.00 5545.45
</code></pre>
<p>It exits with code 0 when the file balances and 1 when it doesn't, so you can also run it over a folder of files in a shell loop.</p>
<h3>What we tested</h3>
<p>We built a 4-transaction sample (opening 5,000.00, closing 5,545.45) and four broken variants:</p>
<table>
<thead>
<tr>
<th>Test file</th>
<th>What was wrong</th>
<th>Script output</th>
</tr>
</thead>
<tbody><tr>
<td>good.csv</td>
<td>nothing (one debit printed as <code>(15.00)</code>)</td>
<td><code>OK: opening 5000.00 + credits - debits = closing 5545.45</code></td>
</tr>
<tr>
<td>bad.csv</td>
<td>rent 950.00 keyed as 590.00</td>
<td><code>FIRST BREAK at CSV line 3 (10/03/2026 Office rent): expected 5,610.00, statement shows 5,250.00, off by -360.00</code></td>
</tr>
<tr>
<td>droprow.csv</td>
<td>the 15.00 bank fee row dropped</td>
<td><code>FIRST BREAK at CSV line 4 (10/15/2026 Stripe payout): expected 5,560.45, statement shows 5,545.45, off by -15.00</code></td>
</tr>
<tr>
<td>missing.csv</td>
<td>last row's printed balance blank</td>
<td><code>OK …</code> (the row can't be checked on its own, but the closing check still passes)</td>
</tr>
<tr>
<td>no Balance column, one row short</td>
<td>100 vs closing 150</td>
<td><code>Rows all chain, but computed closing 100 != statement closing 150 (off by 50): a row is probably missing.</code></td>
</tr>
</tbody></table>
<p>That is the full extent of our testing: a small synthetic sample, not real bank files. Check the column names and date format of your own export before relying on it.</p>
<h2>Then import, then reconcile</h2>
<p>Once the file balances:</p>
<ol>
<li>Make sure it meets QuickBooks Online's upload rules. Either 3 columns (Date, Description, Amount) or 4 (Date, Description, Credit, Debit), English, 350 KB or less, with dates in one format. Drop the Balance column from the import copy. See Intuit's <a href="https://quickbooks.intuit.com/learn-support/en-us/help-article/import-transactions/manually-upload-transactions-quickbooks-online/L0rE9OXBz_US_en_US">Manually upload transactions into QuickBooks Online</a>.</li>
<li>Upload it, then match and categorize the transactions under Bank transactions.</li>
<li>Reconcile using the same statement's ending balance and date, and aim for a Difference of $0.00. See <a href="https://quickbooks.intuit.com/learn-support/en-us/help-article/statement-reconciliation/reconcile-account-quickbooks-online/L3XzsllsK_US_en_US">Reconcile an account in QuickBooks Online</a> and the overview <a href="https://quickbooks.intuit.com/learn-support/en-us/help-article/reconciliation-reports/learn-reconcile-workflow-quickbooks/L8ZibUuVE_US_en_US">Learn the reconcile workflow in QuickBooks Online</a>. That overview also reminds you to confirm that the opening balance matches the statement for that day.</li>
</ol>
<p>Because you balanced the file before importing, any difference left at reconciliation comes from the books (timing, duplicates, manual entries), not from the conversion.</p>
<hr />
<p><strong>Disclosure:</strong> This tutorial was written by <a href="https://statementhaul.haullabs.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=balance-check-tutorial">StatementHaul</a>, a bank-statement PDF converter that runs this same opening + credits − debits = closing check automatically on every statement before you export. 5 statements a month are free. The script and formulas above are free to use without it. This article was drafted with AI assistance and the script was tested by the StatementHaul team.</p>
]]></content:encoded></item></channel></rss>