<?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: Satwinder Dev</title>
    <description>The latest articles on DEV Community by Satwinder Dev (@satwinder-codes).</description>
    <link>https://dev.to/satwinder-codes</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%2F4204610%2F8c35d158-4d21-4a9a-b21b-8070006f0cfd.png</url>
      <title>DEV Community: Satwinder Dev</title>
      <link>https://dev.to/satwinder-codes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/satwinder-codes"/>
    <language>en</language>
    <item>
      <title>What's inside a QBO file (and why QuickBooks silently skips transactions)</title>
      <dc:creator>Satwinder Dev</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:24:26 +0000</pubDate>
      <link>https://dev.to/satwinder-codes/whats-inside-a-qbo-file-and-why-quickbooks-silently-skips-transactions-18np</link>
      <guid>https://dev.to/satwinder-codes/whats-inside-a-qbo-file-and-why-quickbooks-silently-skips-transactions-18np</guid>
      <description>&lt;p&gt;When you download transactions from a bank "for QuickBooks", you get a &lt;code&gt;.qbo&lt;/code&gt; file. I recently added a QBO export to &lt;a href="https://bankstatementhub.com" rel="noopener noreferrer"&gt;BankStatementHub&lt;/a&gt;, a free tool that turns PDF bank statements into spreadsheets and accounting files. It turned out a QBO file is mostly something older and simpler, plus one extra line. There's also one field that decides whether your transactions quietly disappear.&lt;/p&gt;

&lt;p&gt;Here's what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A QBO file is OFX with one extra line
&lt;/h2&gt;

&lt;p&gt;QBO (QuickBooks Web Connect) is OFX 1.x, the bank-download format from the late '90s. It isn't XML. It's SGML: a plain-text header, then tags where the leaf elements are never closed.&lt;/p&gt;

&lt;p&gt;A trimmed-down file looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE

&lt;span class="nt"&gt;&amp;lt;OFX&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;SIGNONMSGSRSV1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;SONRS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;STATUS&amp;gt;&amp;lt;CODE&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;SEVERITY&amp;gt;&lt;/span&gt;INFO&lt;span class="nt"&gt;&amp;lt;/STATUS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;DTSERVER&amp;gt;&lt;/span&gt;20260930
&lt;span class="nt"&gt;&amp;lt;LANGUAGE&amp;gt;&lt;/span&gt;ENG
&lt;span class="nt"&gt;&amp;lt;INTU.BID&amp;gt;&lt;/span&gt;3000
&lt;span class="nt"&gt;&amp;lt;/SONRS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/SIGNONMSGSRSV1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;BANKMSGSRSV1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;STMTTRNRS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;TRNUID&amp;gt;&lt;/span&gt;1
&lt;span class="nt"&gt;&amp;lt;STATUS&amp;gt;&amp;lt;CODE&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;SEVERITY&amp;gt;&lt;/span&gt;INFO&lt;span class="nt"&gt;&amp;lt;/STATUS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;STMTRS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;CURDEF&amp;gt;&lt;/span&gt;USD
&lt;span class="nt"&gt;&amp;lt;BANKACCTFROM&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;BANKID&amp;gt;&lt;/span&gt;000000000
&lt;span class="nt"&gt;&amp;lt;ACCTID&amp;gt;&lt;/span&gt;4821
&lt;span class="nt"&gt;&amp;lt;ACCTTYPE&amp;gt;&lt;/span&gt;CHECKING
&lt;span class="nt"&gt;&amp;lt;/BANKACCTFROM&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;BANKTRANLIST&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;DTSTART&amp;gt;&lt;/span&gt;20260901
&lt;span class="nt"&gt;&amp;lt;DTEND&amp;gt;&lt;/span&gt;20260930
&lt;span class="nt"&gt;&amp;lt;STMTTRN&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;TRNTYPE&amp;gt;&lt;/span&gt;DEBIT
&lt;span class="nt"&gt;&amp;lt;DTPOSTED&amp;gt;&lt;/span&gt;20260903
&lt;span class="nt"&gt;&amp;lt;TRNAMT&amp;gt;&lt;/span&gt;-54.20
&lt;span class="nt"&gt;&amp;lt;FITID&amp;gt;&lt;/span&gt;20260903-9c1e4a7b-1
&lt;span class="nt"&gt;&amp;lt;NAME&amp;gt;&lt;/span&gt;Green Grocer
&lt;span class="nt"&gt;&amp;lt;MEMO&amp;gt;&lt;/span&gt;Card Purchase Green Grocer
&lt;span class="nt"&gt;&amp;lt;/STMTTRN&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/BANKTRANLIST&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;LEDGERBAL&amp;gt;&amp;lt;BALAMT&amp;gt;&lt;/span&gt;1765.80&lt;span class="nt"&gt;&amp;lt;DTASOF&amp;gt;&lt;/span&gt;20260930&lt;span class="nt"&gt;&amp;lt;/LEDGERBAL&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/STMTRS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/STMTTRNRS&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/BANKMSGSRSV1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/OFX&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;&amp;lt;TRNAMT&amp;gt;-54.20&lt;/code&gt; with no &lt;code&gt;&amp;lt;/TRNAMT&amp;gt;&lt;/code&gt;. That's valid here, and it's the first thing that breaks if you reach for an XML library.&lt;/p&gt;

&lt;p&gt;The QuickBooks-specific part is &lt;code&gt;&amp;lt;INTU.BID&amp;gt;&lt;/code&gt;, the Intuit bank ID. Without it you have an &lt;code&gt;.ofx&lt;/code&gt; file. With it, and the &lt;code&gt;.qbo&lt;/code&gt; extension, you have Web Connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. INTU.BID: QuickBooks Desktop checks it
&lt;/h2&gt;

&lt;p&gt;QuickBooks Online accepts &lt;code&gt;.qbo&lt;/code&gt;, &lt;code&gt;.ofx&lt;/code&gt; and &lt;code&gt;.qfx&lt;/code&gt; uploads without much fuss. QuickBooks Desktop is stricter: it checks the bank ID against Intuit's list of supported banks, and if the number isn't on it, the import stops.&lt;/p&gt;

&lt;p&gt;So when you generate QBO files yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use a bank ID that QuickBooks Desktop recognises (converters commonly use a well-known one)&lt;/li&gt;
&lt;li&gt;let the user change it, because the one that works can differ between setups&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The FITID trap
&lt;/h2&gt;

&lt;p&gt;Every transaction has a &lt;code&gt;FITID&lt;/code&gt;. QuickBooks (and Quicken) remember the FITIDs they've imported for an account and &lt;strong&gt;silently skip&lt;/strong&gt; any they've seen before. That's how they avoid duplicates when you download overlapping date ranges.&lt;/p&gt;

&lt;p&gt;It also means the way you generate FITIDs decides whether transactions go missing. There are two easy ways to get it wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong #1: ID from the row's position.&lt;/strong&gt; &lt;code&gt;FITID = statementId + rowIndex&lt;/code&gt; looks unique, but convert an overlapping statement next month and the same transaction gets a different index. QuickBooks treats it as new, and you get duplicates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong #2: ID from the transaction's content only.&lt;/strong&gt; &lt;code&gt;FITID = hash(date + amount + description)&lt;/code&gt; is stable across conversions, which is good. But two genuine $4.50 coffees on the same day hash to the same ID, and QuickBooks drops the second one without a word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; hash the content, then count repeats of that same content within the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fitid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1st, 2nd... identical transaction&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Converting the same or an overlapping statement again gives the same IDs, so nothing duplicates. Two identical coffees get &lt;code&gt;-1&lt;/code&gt; and &lt;code&gt;-2&lt;/code&gt;, so neither disappears.&lt;/p&gt;

&lt;p&gt;If you already have a file with colliding IDs from a bank, the repeats need new IDs before import. I made a small free tool for exactly that: &lt;a href="https://bankstatementhub.com/fix-duplicate-fitid" rel="noopener noreferrer"&gt;Fix duplicate FITIDs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Credit cards have their own block
&lt;/h2&gt;

&lt;p&gt;Bank accounts live in &lt;code&gt;BANKMSGSRSV1 → STMTTRNRS → STMTRS → BANKACCTFROM&lt;/code&gt;. Credit cards use a parallel set: &lt;code&gt;CREDITCARDMSGSRSV1 → CCSTMTTRNRS → CCSTMTRS → CCACCTFROM&lt;/code&gt; (with no &lt;code&gt;BANKID&lt;/code&gt; or &lt;code&gt;ACCTTYPE&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Write a card statement in the bank block and it gets treated as a bank account. For signs, &lt;code&gt;TRNAMT&lt;/code&gt; follows the account holder: money out of a bank account is negative, and so is a purchase on a card. A payment to the card is positive.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Small things that cause import errors
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dates&lt;/strong&gt; are &lt;code&gt;YYYYMMDD&lt;/code&gt; (optionally with a time), not ISO with dashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape&lt;/strong&gt; &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; in &lt;code&gt;NAME&lt;/code&gt; and &lt;code&gt;MEMO&lt;/code&gt;. A payee like &lt;code&gt;AT&amp;amp;T&lt;/code&gt; breaks an unescaped file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NAME&lt;/code&gt; is short.&lt;/strong&gt; OFX 1.x limits it to 32 characters. Put the full description in &lt;code&gt;MEMO&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CHARSET:1252&lt;/code&gt;&lt;/strong&gt; is what most banks send. Stick to plain ASCII in payee names if you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LEDGERBAL&lt;/code&gt;&lt;/strong&gt; carries the closing balance. Include it when you know it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Check the numbers before you write the file
&lt;/h2&gt;

&lt;p&gt;QuickBooks imports whatever is in the file. If a row was misread or a page was skipped, the file looks fine and the problem shows up weeks later as a reconciliation that won't balance.&lt;/p&gt;

&lt;p&gt;Before writing any QBO file I check that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;opening balance + every amount in the file = closing balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, where the statement prints one, that each row's running balance follows from the one before. If either fails, the user sees which row before downloading anything.&lt;/p&gt;

&lt;p&gt;You can try the whole thing with a PDF statement here: &lt;a href="https://bankstatementhub.com/bank-statement-to-qbo" rel="noopener noreferrer"&gt;Bank statement to QBO converter&lt;/a&gt;. Free, no signup. If you already have a bank CSV or QBO file and want to check it before importing, there's a &lt;a href="https://bankstatementhub.com/bank-csv-checker" rel="noopener noreferrer"&gt;bank CSV checker&lt;/a&gt; that reads QBO files too.&lt;/p&gt;

&lt;p&gt;Have you run into other OFX or QBO quirks? I'd like to hear about them in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>fintech</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Show DEV: RingMasti, a free ringtone site with Next.js, nginx and a Flutter app</title>
      <dc:creator>Satwinder Dev</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:09:21 +0000</pubDate>
      <link>https://dev.to/satwinder-codes/show-dev-ringmasti-a-free-ringtone-site-with-nextjs-nginx-and-a-flutter-app-5f0g</link>
      <guid>https://dev.to/satwinder-codes/show-dev-ringmasti-a-free-ringtone-site-with-nextjs-nginx-and-a-flutter-app-5f0g</guid>
      <description>&lt;p&gt;I have been building &lt;a href="https://ringmasti.com" rel="noopener noreferrer"&gt;RingMasti&lt;/a&gt;, a free ringtone site. This is a short introduction to what it is, how it is put together, and how I handle content. Feedback is welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Browse ringtones and &lt;strong&gt;listen on the page first&lt;/strong&gt;, then get the file as &lt;strong&gt;MP3 for Android&lt;/strong&gt; or &lt;strong&gt;M4A for iPhone&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;No sign-up and no account.&lt;/li&gt;
&lt;li&gt;Clips are short (under a minute) and cut to the part people actually recognise, not the whole song.&lt;/li&gt;
&lt;li&gt;Hindi, Punjabi, Tamil, Telugu, Malayalam and other regional music, plus BGM, instrumentals, devotional tones and notification sounds.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;ringtone cutter&lt;/strong&gt; that trims your own audio file in the browser.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;name ringtone maker&lt;/strong&gt; that turns a name into a custom tone.&lt;/li&gt;
&lt;li&gt;An Android app (Flutter) on Google Play.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js 15 (App Router) with ISR, so popular ringtone pages are served from cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files:&lt;/strong&gt; a separate nginx + PHP host. PHP only checks access, and nginx sends the audio itself (&lt;code&gt;X-Accel-Redirect&lt;/code&gt;), which keeps PHP workers free during traffic spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; MariaDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge:&lt;/strong&gt; Cloudflare in front of both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile:&lt;/strong&gt; Flutter app using the same API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How content is handled
&lt;/h2&gt;

&lt;p&gt;Ringtones are added as short clips, and automated imports are capped each day so the catalogue grows steadily instead of in bulk. Anything longer than about a minute is rejected, so full songs do not end up on the site. If a rights holder asks for something to be removed, it is taken down and that URL returns &lt;code&gt;410 Gone&lt;/code&gt;. The site has a DMCA page and a contact page for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like feedback on
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Is the listen-first flow clear on mobile?&lt;/li&gt;
&lt;li&gt;Does the M4A option for iPhone make sense, or would you expect something else?&lt;/li&gt;
&lt;li&gt;What would make the ringtone cutter more useful?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you try it, tell me what felt slow or confusing.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>flutter</category>
    </item>
    <item>
      <title>What I learned building a bank statement PDF to Excel converter</title>
      <dc:creator>Satwinder Dev</dc:creator>
      <pubDate>Sat, 24 Oct 2026 12:33:24 +0000</pubDate>
      <link>https://dev.to/satwinder-codes/what-i-learned-building-a-bank-statement-pdf-to-excel-converter-4lef</link>
      <guid>https://dev.to/satwinder-codes/what-i-learned-building-a-bank-statement-pdf-to-excel-converter-4lef</guid>
      <description>&lt;p&gt;A PDF bank statement looks like a table. It isn't one. There are no rows or columns inside the file, only pieces of text placed at x/y positions on a page. Everything that makes it a "table" is in your head.&lt;/p&gt;

&lt;p&gt;I spent the last few months building &lt;a href="https://bankstatementhub.com" rel="noopener noreferrer"&gt;BankStatementHub&lt;/a&gt;, a free tool that turns PDF bank and credit card statements into Excel, CSV and QuickBooks files. Here is what broke along the way, and the one check that caught most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Columns are a guess
&lt;/h2&gt;

&lt;p&gt;To rebuild rows you group text by its vertical position, then split it into columns by horizontal position. That works until:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a description is long enough to run into the amount column&lt;/li&gt;
&lt;li&gt;one transaction's description wraps onto two or three lines&lt;/li&gt;
&lt;li&gt;the page header (bank name, account number, "continued") repeats on every page and lands in the middle of your data&lt;/li&gt;
&lt;li&gt;a "Balance forward" or "Subtotal" line looks exactly like a transaction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every bank does this a little differently, and the same bank changes its layout every few years.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The sign of an amount lives in different places
&lt;/h2&gt;

&lt;p&gt;"Is this money in or money out?" has no single answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;separate &lt;strong&gt;Withdrawals&lt;/strong&gt; and &lt;strong&gt;Deposits&lt;/strong&gt; columns (most US checking accounts)&lt;/li&gt;
&lt;li&gt;one &lt;strong&gt;Amount&lt;/strong&gt; column with a minus sign&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;CR&lt;/code&gt; or &lt;code&gt;DR&lt;/code&gt; suffix, or a trailing minus like &lt;code&gt;45.00-&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;credit card statements, where a positive number is a charge and a payment is negative, the opposite of a checking account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get this wrong and the totals still look plausible, which is the dangerous part.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Numbers and dates are not what you expect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1,234.56&lt;/code&gt; in the US, &lt;code&gt;1.234,56&lt;/code&gt; in much of Europe and Latin America&lt;/li&gt;
&lt;li&gt;amounts with no leading zero, like &lt;code&gt;.15&lt;/code&gt;, which a careless regex reads as &lt;code&gt;15&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;dates printed without a year (&lt;code&gt;Dec 28&lt;/code&gt;, &lt;code&gt;Jan 03&lt;/code&gt;), so a statement that runs from December into January needs the year worked out from the statement period&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The check that saved me: the balance
&lt;/h2&gt;

&lt;p&gt;The most useful thing I built was not a better parser. It was a check.&lt;/p&gt;

&lt;p&gt;A statement gives you an opening balance, a closing balance, and usually a running balance on every row. So after reading the transactions you can test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;opening balance + money in − money out = closing balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, row by row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;previous row balance ± this amount = this row balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If either fails, something was misread, dropped or double counted, and you know which row to look at.&lt;/p&gt;

&lt;p&gt;A real example: on a 14-page BBVA México statement, one whole page of transactions was skipped. Every row that &lt;em&gt;was&lt;/em&gt; read looked perfect. The only sign of trouble was that the totals didn't add up to the closing balance, and that check flagged it.&lt;/p&gt;

&lt;p&gt;Credit card statements usually have no running balance, so there the check falls back to the statement totals (previous balance, payments, purchases, new balance). It's weaker, but it still catches a missing page.&lt;/p&gt;

&lt;p&gt;The lesson I'd pass on: &lt;strong&gt;don't trust extracted data you can't reconcile.&lt;/strong&gt; If the numbers can't be checked against something on the page, show the user that clearly instead of handing them a clean-looking spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Export formats have their own traps
&lt;/h2&gt;

&lt;p&gt;CSV and Excel are easy. QuickBooks and Quicken files (QBO/OFX) are not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;each transaction needs a &lt;code&gt;FITID&lt;/code&gt;, an ID that should be unique within the account&lt;/li&gt;
&lt;li&gt;QuickBooks silently skips a transaction if it has already seen that FITID, so two genuine $4.50 coffees on the same day can turn into one if the IDs collide&lt;/li&gt;
&lt;li&gt;the account type (checking vs credit card) changes how the signs are read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran into the duplicate FITID problem often enough that I made a small free tool just for that: &lt;a href="https://bankstatementhub.com/fix-duplicate-fitid" rel="noopener noreferrer"&gt;Fix duplicate FITIDs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PDF to Excel, CSV, QuickBooks (QBO), Xero and Quicken (OFX)&lt;/li&gt;
&lt;li&gt;every row checked against the balance, with mismatches flagged&lt;/li&gt;
&lt;li&gt;bulk conversion of up to 12 statements at once&lt;/li&gt;
&lt;li&gt;password-protected PDFs&lt;/li&gt;
&lt;li&gt;free, no signup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've worked with bank statement PDFs, I'd like to hear about the strangest layout you've come across. And if you try &lt;a href="https://bankstatementhub.com" rel="noopener noreferrer"&gt;BankStatementHub&lt;/a&gt; on a statement it gets wrong, tell me in the comments; that's how most of these fixes happened.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>bankstatementconverter</category>
      <category>satwinder</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
