<?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: Gu</title>
    <description>The latest articles on DEV Community by Gu (@ink_gu).</description>
    <link>https://dev.to/ink_gu</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%2F4076980%2Fd3fc766a-8784-4432-a531-6f2d4bf242ca.png</url>
      <title>DEV Community: Gu</title>
      <link>https://dev.to/ink_gu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ink_gu"/>
    <language>en</language>
    <item>
      <title>The function you wrote last month is a third-party API</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:36:35 +0000</pubDate>
      <link>https://dev.to/ink_gu/the-function-you-wrote-last-month-is-a-third-party-api-2h7n</link>
      <guid>https://dev.to/ink_gu/the-function-you-wrote-last-month-is-a-third-party-api-2h7n</guid>
      <description>&lt;p&gt;There is a habit I have for other people's libraries that I do not have for my own code: before I call something, I read what it returns.&lt;/p&gt;

&lt;p&gt;With my own functions I skip that, because I wrote them, so I know. Three times in three days that turned out to be false, and the third time I caught it before it cost anything only because I had started treating my own modules like somebody else's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version I had already been burned by twice
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://qbofile.com" rel="noopener noreferrer"&gt;qbofile&lt;/a&gt;, a set of browser-based converters between the file formats accounting software uses. It is a small codebase: a parser per input format, a generator per output format, and pages that wire one to the other.&lt;/p&gt;

&lt;p&gt;Wiring a new pair felt like plumbing, so I estimated it like plumbing. Two new pages, both reusing an existing parser and an existing generator: &lt;strong&gt;no new code.&lt;/strong&gt; I said that out loud before opening either end.&lt;/p&gt;

&lt;p&gt;The generator had no column for the thing the parser produced. The parser could read the category a user had assigned to each transaction; the CSV generator emitted six fixed columns and category was not one of them. Not a bug — it had simply never needed one, because the format it was originally written for does not carry categories.&lt;/p&gt;

&lt;p&gt;That is a strange kind of wrong. Nothing was broken. The code did exactly what it always had. My model of it was built from the function name.&lt;/p&gt;

&lt;p&gt;The same evening, in the same pair of modules, the second one:&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="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`P&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sanitizeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;P&lt;/code&gt; is the payee field in that output format. &lt;code&gt;M&lt;/code&gt; is the memo. Two fields, and upstream, &lt;code&gt;description&lt;/code&gt; was defined as &lt;code&gt;memo || payee&lt;/code&gt;. So for any transaction that had a memo, the memo took the payee slot and the actual payee was dropped. Silently — the file is valid, it imports fine, and the missing name never announces itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two minutes that caught the third one
&lt;/h2&gt;

&lt;p&gt;After the second one I wrote down a rule and did not really believe I needed it: &lt;strong&gt;before wiring two components together, open both ends and read what actually crosses.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two days later I was writing a parser for another old format, and it needed to work out two things that the format does not state: whether dates are day-first or month-first, and whether a dot or a comma is the decimal mark. Both of those I had already solved elsewhere in the codebase. Reusing them was obviously right.&lt;/p&gt;

&lt;p&gt;So I opened them. Not the call sites — the functions.&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="c1"&gt;// what I was about to write&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detectDateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;warn&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;sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detectDecimalSeparator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amounts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;parseAmountCents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what the functions actually do&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectDateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// ← returns null, does not throw&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rival&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;coverage&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectDecimalSeparator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ambiguous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// ← an object, not a character&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two crashes, neither of them written yet. &lt;code&gt;fmt.ambiguous&lt;/code&gt; on a null, and an object handed to a function expecting &lt;code&gt;'.'&lt;/code&gt; or &lt;code&gt;','&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both were mine. Both were about two weeks old. I could have told you what they were &lt;em&gt;for&lt;/em&gt; without hesitating, and I would have been right — and I still had the return shape wrong on both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "I wrote it" is the reason, not the excuse
&lt;/h2&gt;

&lt;p&gt;For a library I have never seen, the first thing I do is find out what comes back. For my own module I skip that step, and the thing I skip it in favour of is the function's name.&lt;/p&gt;

&lt;p&gt;A name is a summary written before the function was finished. &lt;code&gt;detectDecimalSeparator&lt;/code&gt; sounds like it returns a separator. It returns a separator plus the evidence for it plus whether the evidence was conclusive — because when I wrote it, the interesting part was that sometimes the file cannot tell you, and callers need to say so. That is a better function than the name suggests. The name just never got updated to admit it.&lt;/p&gt;

&lt;p&gt;So the rule is not really "read your own code more carefully". It is narrower:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A function you have not opened in a few weeks is a dependency. Give it the courtesy you give a stranger's package: look at what it returns before you use it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What types would and would not have caught
&lt;/h2&gt;

&lt;p&gt;I write this project in plain JavaScript with JSDoc, so the obvious response is that a type checker catches all of this. It catches some of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;detectDecimalSeparator&lt;/code&gt; returning an object where I expected a string — &lt;strong&gt;caught&lt;/strong&gt;, instantly, that is exactly what type checking is for.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;detectDateFormat&lt;/code&gt; returning &lt;code&gt;null&lt;/code&gt; — &lt;strong&gt;caught, but only if I had annotated the return as nullable.&lt;/strong&gt; The annotation is a claim I make by hand, and it is the same claim I got wrong in my head.&lt;/li&gt;
&lt;li&gt;The generator with no category column — &lt;strong&gt;not caught by anything.&lt;/strong&gt; Both sides were &lt;code&gt;string[]&lt;/code&gt;. The type was right and the content was missing. No checker knows that a column named &lt;code&gt;Category&lt;/code&gt; should exist because some other module can produce categories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the one that cost the most time, and it is the one no tooling was going to hand me. Which is why the fix I ended up with is a reading habit and not a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole check is a four-row table
&lt;/h2&gt;

&lt;p&gt;Opening both ends does not mean rereading two files. In practice it is one grep per module — find the &lt;code&gt;return&lt;/code&gt; statements — and one comparison of the field lists that cross the boundary.&lt;/p&gt;

&lt;p&gt;For the parser and the generator, that comparison is a table you can write in a minute:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser produces&lt;/th&gt;
&lt;th&gt;generator writes&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;date&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;amount&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;payee&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;overwritten by the memo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;category&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;no column&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two rows that are not a plain tick are the entire finding, and writing that table is the whole check.&lt;/p&gt;

&lt;p&gt;Doing it took two minutes and turned up two crashes that did not exist yet. Skipping it, twice, shipped a converter that quietly dropped a field — and I found that one myself, by reading the code again, rather than from a bug report. That is the part worth sitting with: nothing about the output looked wrong, so there was no report coming.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you have a QIF file whose dates come out in the wrong month, or a converter that disagrees with any of this, I would like to see it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>codequality</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My tests could fail. They still could not tell me I was wrong.</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 01:52:58 +0000</pubDate>
      <link>https://dev.to/ink_gu/my-tests-could-fail-they-still-could-not-tell-me-i-was-wrong-5a95</link>
      <guid>https://dev.to/ink_gu/my-tests-could-fail-they-still-could-not-tell-me-i-was-wrong-5a95</guid>
      <description>&lt;p&gt;A test proves your code does what you meant. It cannot prove that what you meant was correct.&lt;/p&gt;

&lt;p&gt;I could have written that sentence a year ago. I still shipped on the wrong side of it this week.&lt;/p&gt;

&lt;p&gt;Here is the assertion I was leaning on:&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="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sumOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;splits&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A transaction split across several categories has to add up to the total. That is a real invariant, and the test catches plenty. What it cannot catch is the one thing I was actually unsure about, because &lt;strong&gt;both sides of that equation are produced by my code, using a sign convention I chose.&lt;/strong&gt; Get the convention backwards and both sides flip together. The test stays green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two formats disagree about what a number means
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://qbofile.com" rel="noopener noreferrer"&gt;qbofile&lt;/a&gt;, a browser based converter for the file formats small business accounting runs on. This particular conversion goes from IIF, which QuickBooks Desktop has used since the 1990s, into QIF, which Quicken and GnuCash read.&lt;/p&gt;

&lt;p&gt;Spend $150 at one shop and split it between two categories. Here is IIF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TRNS   ...   Amex Platinum      -150.00
SPL    ...   Office Supplies     100.00
SPL    ...   Meals                50.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Double entry. The account side is negative, the category side is positive, and the whole block sums to zero.&lt;/p&gt;

&lt;p&gt;QIF takes the same event from one point of view only, the account's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T-150.00
SOffice Supplies
$-100.00
SMeals
$-50.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$&lt;/code&gt; lines are negative here, and they sum to the &lt;code&gt;T&lt;/code&gt; line rather than cancelling it. So the converter negates every split amount on the way across. One character:&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="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;formatQifAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountCents&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That character is a claim about how another company's file format works. I read the spec, I read other people's files, and I was fairly confident. Confident is not the same as measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mutation test proved the wrong thing
&lt;/h2&gt;

&lt;p&gt;Before trusting a test I like to break the code on purpose and check that the test notices. So I deleted the minus sign. Four tests went red, including the one above. Restore it, all green again.&lt;/p&gt;

&lt;p&gt;That is a useful result. It rules out the test being vacuous, which is a real failure mode and more common than people admit.&lt;/p&gt;

&lt;p&gt;But look at what it actually establishes. &lt;strong&gt;Flip a correct sign and the tests go red. Flip an incorrect sign and the tests also go red.&lt;/strong&gt; The mutation test tells you the assertion is wired to the code. It is completely blind to whether the code is on the right side of the truth. I had proved my test could fail, and then quietly treated that as having proved my belief.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I asked the software that has to read the output
&lt;/h2&gt;

&lt;p&gt;The only authority on whether a QIF file is right is a program that imports QIF files. I built one file designed so that a sign error would be impossible to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one credit card account, so the file also exercises the &lt;code&gt;!Type:CCard&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;two separate split transactions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;one category that receives money from both of them&lt;/strong&gt; — $100 from one, $120 from the other&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the whole design. It gives me a number that only comes out right if the sign is right, and it comes from adding across two transactions rather than reading one back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Sign flipped would give&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Office Supplies&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;220.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-220.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meals&lt;/td&gt;
&lt;td&gt;50.00&lt;/td&gt;
&lt;td&gt;-50.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repairs&lt;/td&gt;
&lt;td&gt;180.00&lt;/td&gt;
&lt;td&gt;-180.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then I handed it to GnuCash. Import, no warnings, no "split does not balance". Office Supplies: 220.00. The account came in as a credit card rather than a bank account, so the type header was read too.&lt;/p&gt;

&lt;p&gt;Fifteen minutes. The belief is now a measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I found on the way to the test
&lt;/h2&gt;

&lt;p&gt;Deciding what was worth testing meant reading the whole chain again, and that is where the actual defect turned up. It had nothing to do with signs.&lt;/p&gt;

&lt;p&gt;QIF has two separate fields: &lt;code&gt;P&lt;/code&gt; for the payee, &lt;code&gt;M&lt;/code&gt; for the memo. IIF carries both, in &lt;code&gt;NAME&lt;/code&gt; and &lt;code&gt;MEMO&lt;/code&gt;. The generator wrote one line:&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="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`P&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sanitizeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and upstream, &lt;code&gt;description&lt;/code&gt; was defined as &lt;code&gt;MEMO || NAME&lt;/code&gt;. So for any transaction that had a memo, the memo took the payee field and &lt;strong&gt;the actual payee was dropped on the floor.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before   PSUPPLIES RUN            &amp;lt;- memo posing as the payee, COSTCO gone
after    PCOSTCO / MSUPPLIES RUN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing errors. The importing software has no way to know a field went missing, and neither does the person looking at their register afterwards. That is the failure mode this entire project exists to fight, and I had built one.&lt;/p&gt;

&lt;p&gt;It was also the second time that day I had assumed a downstream generator could hold everything the upstream parser produces. The rule I wrote down: when you wire two components together, open the field lists at both ends and diff them. Do not just read the part in the middle that you are writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which beliefs are worth fifteen minutes
&lt;/h2&gt;

&lt;p&gt;You cannot run every assumption past real software; most of the time the unit test genuinely is the last word. What decided it here was a question I now ask explicitly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this line rest on a belief about someone else's software?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sign conventions in a foreign file format. Which header field another program actually reads. What an importer does with a value it does not recognise. Those are not properties of my code, so my code cannot testify about them, and a passing suite is the wrong kind of evidence entirely.&lt;/p&gt;

&lt;p&gt;I had learned this once already and forgotten it. An earlier version of the same converter emitted an account type declaration, on the shared understanding that QuickBooks would create missing accounts with the type you declare. Every test passed. On a real copy of QuickBooks, every invented account came out as a bank account regardless of what the file said.&lt;/p&gt;

&lt;p&gt;What makes this worth the interruption is the shape of the trade. Fifteen minutes buys an answer that no amount of additional test writing can produce, and the thing you are buying insurance against does not announce itself. A sign convention that is backwards does not crash anything. It produces a file that imports cleanly, into a register that looks plausible, for someone who has no way to check.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The IIF file I built for the GnuCash test, and the four things I checked after importing it, are in the repo notes. If you have a QIF importer that disagrees with any of this, I would like to hear about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A failed import that still created three records</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Mon, 24 Aug 2026 00:55:09 +0000</pubDate>
      <link>https://dev.to/ink_gu/a-failed-import-that-still-created-three-records-5e5i</link>
      <guid>https://dev.to/ink_gu/a-failed-import-that-still-created-three-records-5e5i</guid>
      <description>&lt;p&gt;Every importer has one moment where it tells you it is done.&lt;/p&gt;

&lt;p&gt;Here is that moment, at the end of a file import into a twenty year old accounting product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QuickBooks Information
Your data has been imported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have nine test files that produce that message. Two of them wrote no transactions at all, and one of those two still left three new records behind in the database on its way out.&lt;/p&gt;

&lt;p&gt;That last part is the one that travels. If a format has no per record result channel, there is exactly one place left to tell the user what happened, and it fires whether or not anything worked. A partial write underneath it is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine files, each broken in one specific way
&lt;/h2&gt;

&lt;p&gt;I maintain a browser based converter that turns bank CSV files into the formats accounting software will take. One of them is IIF, a tab separated format QuickBooks Desktop has used since the 1990s, with a reputation for failing quietly.&lt;/p&gt;

&lt;p&gt;I had a specific worry. Our files reference accounts by name. If the name is not in the user's chart of accounts, what happens? Everyone writing about IIF says QuickBooks creates the account for you. Almost nobody says what kind of account it creates.&lt;/p&gt;

&lt;p&gt;So I built five files, each broken in one way, and ran them against QuickBooks Mac Plus 2024 in a throwaway company file. Four more came later, for a reason I will get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two of the five said imported and posted nothing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The file&lt;/th&gt;
&lt;th&gt;What QuickBooks showed&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid, account exists&lt;/td&gt;
&lt;td&gt;Your data has been imported&lt;/td&gt;
&lt;td&gt;Posted correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unknown name in the NAME field&lt;/td&gt;
&lt;td&gt;Your data has been imported&lt;/td&gt;
&lt;td&gt;Posted. Name created as an "Other Name"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receivable with no customer&lt;/td&gt;
&lt;td&gt;Warning, then &lt;em&gt;Your data has been imported&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nothing posted&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amount written &lt;code&gt;-1,234.56&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Your data has been imported&lt;/td&gt;
&lt;td&gt;Posted correctly as -1234.56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two sides that do not balance&lt;/td&gt;
&lt;td&gt;Warning, then &lt;em&gt;Your data has been imported&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nothing posted&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the warning from the unbalanced file, the one thing that distinguished a failed run from a good one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction is not in balance. Make sure the amounts in the detail
area on the form for this transaction equal the amount at the top
of the form.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads like instructions for a form you are looking at. There is no form. There is one button, it says OK, and it takes you to the success message.&lt;/p&gt;

&lt;p&gt;So the warning was the result, and clicking OK destroyed the only copy of it.&lt;/p&gt;

&lt;p&gt;I went looking for the copy that should have survived. QuickBooks for Windows 2019 and later is widely described as writing a line by line import report with numbered error codes, and people quote things like &lt;code&gt;[3040]&lt;/code&gt; on Intuit's forums. None of my imports produced one, and the app's own log held internal identifiers and nothing else. On this build the dialog you clicked past is the entire record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every account it invented was a bank account
&lt;/h2&gt;

&lt;p&gt;The company file started with exactly one account in it. Everything else the five files referenced was missing on purpose. QuickBooks created all of them without asking, and gave all of them the same type:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name in the file&lt;/th&gt;
&lt;th&gt;Type it should be&lt;/th&gt;
&lt;th&gt;Type QuickBooks made&lt;/th&gt;
&lt;th&gt;Created during&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Uncategorized Expense&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expense&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a run that posted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Accounts Receivable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Accounts Receivable&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a run that posted nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Uncategorized Income&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a run that posted nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It does not infer the type from the name, not even for names QuickBooks itself uses. It does not infer it from how the account is used in the file. It creates a bank account and moves on.&lt;/p&gt;

&lt;p&gt;A wrong account &lt;em&gt;name&lt;/em&gt; announces itself, because the account appears in the wrong place and somebody goes and fixes it. A wrong account &lt;em&gt;type&lt;/em&gt; does not. Expenses sit in something that looks like a bank account, the profit and loss report is missing them, and every total involved is wrong until someone notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure was only half a failure
&lt;/h2&gt;

&lt;p&gt;Look at the right hand column again. Two of those three accounts were created by the receivable file, and the receivable file is one of the two that wrote no transactions.&lt;/p&gt;

&lt;p&gt;So that run rejected the transaction and kept the side effects. Nothing was posted, two new accounts were left behind in the chart of accounts, both of them the wrong type, and the last thing on screen said the data had been imported.&lt;/p&gt;

&lt;p&gt;That is the case worth designing for if you consume other people's files. "It failed" is not the same statement as "nothing happened", and a user who is only told the first one will assume the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  We had been telling people the opposite
&lt;/h2&gt;

&lt;p&gt;This is the part I would rather not write. Our own converter pages said that &lt;code&gt;Uncategorized Expense&lt;/code&gt; and &lt;code&gt;Uncategorized Income&lt;/code&gt; are accounts QuickBooks ships with, so they would always be there and the category side of every entry was safe.&lt;/p&gt;

&lt;p&gt;I did not measure that before publishing it. I read it, it matched what everyone else says, and it went on the page as a reassurance. The blank company file had neither account. The line had been sitting there telling people not to worry about the exact thing that was going wrong.&lt;/p&gt;

&lt;p&gt;Both pages now say what the test found instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the test that had to come first
&lt;/h2&gt;

&lt;p&gt;IIF has a section that carries account types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="k"&gt;ACCNT&lt;/span&gt;  &lt;span class="k"&gt;NAME&lt;/span&gt;    &lt;span class="k"&gt;ACCNTTYPE&lt;/span&gt;
&lt;span class="k"&gt;ACCNT&lt;/span&gt;   &lt;span class="k"&gt;Amex&lt;/span&gt; &lt;span class="k"&gt;Platinum&lt;/span&gt;   &lt;span class="k"&gt;CCARD&lt;/span&gt;
&lt;span class="k"&gt;ACCNT&lt;/span&gt;   &lt;span class="k"&gt;Uncategorized&lt;/span&gt; &lt;span class="k"&gt;Expense&lt;/span&gt;   &lt;span class="k"&gt;EXP&lt;/span&gt;
&lt;span class="k"&gt;ACCNT&lt;/span&gt;   &lt;span class="k"&gt;Uncategorized&lt;/span&gt; &lt;span class="k"&gt;Income&lt;/span&gt;    &lt;span class="k"&gt;INC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had deliberately not written that section, on the reasoning that creating accounts in somebody's books is a bigger thing to get wrong than a bad import you can restore from backup.&lt;/p&gt;

&lt;p&gt;That reasoning was wrong, and measuring is what showed it. We were never choosing between creating accounts and not creating them. QuickBooks creates the missing ones either way. We were only choosing whether they came out with the right type.&lt;/p&gt;

&lt;p&gt;That still left the question of whether the section is safe, which needed a second round of files. The one that mattered declares an account that &lt;strong&gt;already exists&lt;/strong&gt; as the wrong type. If &lt;code&gt;!ACCNT&lt;/code&gt; can retype an account somebody is already using, this fix is worse than the bug: instead of a stray account in a new file, you have a corrupted chart of accounts in a real one.&lt;/p&gt;

&lt;p&gt;It cannot. The account was still a bank account afterwards. The section fills gaps and leaves everything else alone. That is the result that made the change shippable, and out of nine files it is the only one whose answer could have stopped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two rules I came out with
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Put the outcome where dismissing it does not destroy it.&lt;/strong&gt; A modal warning is gone the moment someone clicks OK, and on the build I tested nothing else recorded it. The user is then holding a screen that says the import succeeded and no way to find out otherwise except by counting rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before changing how a tool writes to somebody else's data, write the test whose result would stop you, and run that one first.&lt;/strong&gt; Eight of my nine files told me things worth knowing. Only one of them could have cancelled the change, and if I had run it last I would have built the whole thing before finding out.&lt;/p&gt;

&lt;p&gt;The converter is at &lt;a href="https://qbofile.com" rel="noopener noreferrer"&gt;qbofile.com&lt;/a&gt;. It runs in the browser, nothing is uploaded, and every file it writes now declares its account types.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Is 1.234 one, or one thousand? Getting CSV decimal separators right</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Thu, 20 Aug 2026 01:02:41 +0000</pubDate>
      <link>https://dev.to/ink_gu/is-1234-one-or-one-thousand-getting-csv-decimal-separators-right-3n7j</link>
      <guid>https://dev.to/ink_gu/is-1234-one-or-one-thousand-getting-csv-decimal-separators-right-3n7j</guid>
      <description>&lt;p&gt;Here is a number from a bank statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.234
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the bank is American, that is one point two three four. If the bank is German, that is one thousand two hundred and thirty four. Same characters, 1000x apart.&lt;/p&gt;

&lt;p&gt;You cannot tell which one it is by looking at it. That is the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  We had already solved this. For dates.
&lt;/h2&gt;

&lt;p&gt;I build a browser-based converter that turns bank CSV files into formats accounting software will accept. Three weeks ago I wrote this comment at the top of the date parser:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The most dangerous thing here: MM/DD/YYYY and DD/MM/YYYY look identical. &lt;code&gt;03/04/2024&lt;/code&gt; is either March 4th or April 3rd, and a single value can never tell you which. The fix is to judge the whole column at once. Raising a warning matters, because a whole column of wrong dates shifts the books by a month, and it is the kind of error that looks fine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every word of that applies to amounts. I did not notice.&lt;/p&gt;

&lt;p&gt;The amount parser was guessing one value at a time:&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="c1"&gt;// only comma present: is it a decimal point or a thousands separator?&lt;/span&gt;
&lt;span class="c1"&gt;// guess from whether exactly 3 digits follow it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastComma&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isGrouping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{3}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That heuristic is fine on &lt;code&gt;1,234&lt;/code&gt; and fine on &lt;code&gt;12,50&lt;/code&gt;. It is wrong on &lt;code&gt;1.234&lt;/code&gt;, and it is wrong silently.&lt;/p&gt;

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

&lt;p&gt;Three lines from a German statement, before the fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;in the file&lt;/th&gt;
&lt;th&gt;we read it as&lt;/th&gt;
&lt;th&gt;the bank meant&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-1.234&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-1.23&lt;/td&gt;
&lt;td&gt;-1234.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2.500&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.50&lt;/td&gt;
&lt;td&gt;2500.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-12,50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-12.50&lt;/td&gt;
&lt;td&gt;-12.50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every amount in the column comes out 1000x too small. Nothing throws. The import succeeds. The register looks normal. You find out at reconciliation, if you find out at all.&lt;/p&gt;

&lt;p&gt;Rent showing up as 1.23 is not a crash. It is a number that sits there looking like a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is the same one, applied to the other column
&lt;/h2&gt;

&lt;p&gt;Look at the whole column and find a value that gives the answer away. In order of strength:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Both separators in one value.&lt;/strong&gt; &lt;code&gt;1,234.56&lt;/code&gt; or &lt;code&gt;1.234,56&lt;/code&gt;. Whichever comes last is the decimal point. This settles it immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separator with something other than 3 digits after it.&lt;/strong&gt; &lt;code&gt;12,5&lt;/code&gt; cannot be a thousands separator. Neither can &lt;code&gt;1,2345&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same separator twice in one value.&lt;/strong&gt; &lt;code&gt;1.234.567&lt;/code&gt; means the dot is grouping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of the above.&lt;/strong&gt; Every value in the column looks like &lt;code&gt;1.234&lt;/code&gt;. Now you genuinely cannot tell.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 4 is the one that matters. Most parsers pick a default here and say nothing. We pick US convention and raise a warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Amounts like "1.234" could mean 1.234 or 1,234. Every value in this file has
exactly three digits after the separator, so the file alone can't tell us.
We read "." as the decimal point (US convention). If this statement is
European, every amount is 1000x too small.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One qualifying value rescues the entire column. In the German sample above, &lt;code&gt;-12,50&lt;/code&gt; does it: two digits after the comma, so the comma is not grouping, so the column is European, so &lt;code&gt;-1.234&lt;/code&gt; is 1234.00.&lt;/p&gt;

&lt;h2&gt;
  
  
  A detail I only found by writing the test wrong
&lt;/h2&gt;

&lt;p&gt;My first test file used commas as the CSV delimiter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;Amount&lt;/span&gt;
&lt;span class="ld"&gt;06/04/2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;KAFFEE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That row has four fields, not three. Which is exactly why European CSV files use semicolons. The comma was already taken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I actually want to remember
&lt;/h2&gt;

&lt;p&gt;The method was already in the codebase. I wrote it down myself, in a comment, at the top of a file I open regularly. Then I wrote a second parser with the same class of ambiguity and guessed value by value for three weeks.&lt;/p&gt;

&lt;p&gt;Writing something down is not the same as remembering it. When the same shape of problem shows up a second time, the useful move is to go look at how you solved it the first time, and I did not.&lt;/p&gt;

&lt;p&gt;The converter is at &lt;a href="https://qbofile.com" rel="noopener noreferrer"&gt;qbofile.com&lt;/a&gt; if you want to see the behaviour. It runs entirely in the browser, so nothing gets uploaded.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>IIF, the QuickBooks import format with no way to report an error</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Tue, 18 Aug 2026 01:57:47 +0000</pubDate>
      <link>https://dev.to/ink_gu/iif-the-quickbooks-import-format-with-no-way-to-report-an-error-4kim</link>
      <guid>https://dev.to/ink_gu/iif-the-quickbooks-import-format-with-no-way-to-report-an-error-4kim</guid>
      <description>&lt;p&gt;I maintain a browser-based converter that turns bank CSV exports into the file formats QuickBooks accepts. One of those formats is IIF, which is what QuickBooks Desktop uses for bulk imports, and it has a property I hadn't thought carefully about until last week: it has no way to report an error.&lt;/p&gt;

&lt;p&gt;An IIF file is tab-separated text. Here is one my converter produced for a credit card statement, which is the file the rest of this post is about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="k"&gt;TRNS&lt;/span&gt;   &lt;span class="k"&gt;TRNSID&lt;/span&gt;  &lt;span class="k"&gt;TRNSTYPE&lt;/span&gt;    &lt;span class="k"&gt;DATE&lt;/span&gt;    &lt;span class="k"&gt;ACCNT&lt;/span&gt;   &lt;span class="k"&gt;NAME&lt;/span&gt;    &lt;span class="k"&gt;AMOUNT&lt;/span&gt;  &lt;span class="k"&gt;DOCNUM&lt;/span&gt;  &lt;span class="k"&gt;MEMO&lt;/span&gt;
&lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="k"&gt;SPL&lt;/span&gt;    &lt;span class="k"&gt;SPLID&lt;/span&gt;   &lt;span class="k"&gt;TRNSTYPE&lt;/span&gt;    &lt;span class="k"&gt;DATE&lt;/span&gt;    &lt;span class="k"&gt;ACCNT&lt;/span&gt;   &lt;span class="k"&gt;NAME&lt;/span&gt;    &lt;span class="k"&gt;AMOUNT&lt;/span&gt;  &lt;span class="k"&gt;DOCNUM&lt;/span&gt;  &lt;span class="k"&gt;MEMO&lt;/span&gt;
&lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="k"&gt;ENDTRNS&lt;/span&gt;
&lt;span class="k"&gt;TRNS&lt;/span&gt;        &lt;span class="k"&gt;CREDIT&lt;/span&gt; &lt;span class="k"&gt;CARD&lt;/span&gt; &lt;span class="k"&gt;CHARGE&lt;/span&gt;  &lt;span class="ld"&gt;01/05/2024&lt;/span&gt;  &lt;span class="k"&gt;Visa&lt;/span&gt; &lt;span class="k"&gt;Card&lt;/span&gt;       &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;12.40&lt;/span&gt;      &lt;span class="k"&gt;COFFEE&lt;/span&gt; &lt;span class="k"&gt;SHOP&lt;/span&gt; &lt;span class="mf"&gt;4471&lt;/span&gt;
&lt;span class="k"&gt;SPL&lt;/span&gt;     &lt;span class="k"&gt;CREDIT&lt;/span&gt; &lt;span class="k"&gt;CARD&lt;/span&gt; &lt;span class="k"&gt;CHARGE&lt;/span&gt;  &lt;span class="ld"&gt;01/05/2024&lt;/span&gt;  &lt;span class="k"&gt;Uncategorized&lt;/span&gt; &lt;span class="k"&gt;Expense&lt;/span&gt;       &lt;span class="mf"&gt;12.40&lt;/span&gt;       &lt;span class="k"&gt;COFFEE&lt;/span&gt; &lt;span class="k"&gt;SHOP&lt;/span&gt; &lt;span class="mf"&gt;4471&lt;/span&gt;
&lt;span class="k"&gt;ENDTRNS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rows beginning &lt;code&gt;!&lt;/code&gt; declare columns; the rest are data. Each transaction names the account it belongs to in &lt;code&gt;ACCNT&lt;/code&gt; — by name, as a string, matched against whatever is in the user's chart of accounts. There is no account number and no identifier, and no column anywhere says what &lt;em&gt;kind&lt;/em&gt; of account it is. Just a name.&lt;/p&gt;

&lt;p&gt;Every write-up I could find mentions what happens when that name doesn't match: QuickBooks creates a new account rather than complaining. I had that sentence on my own site, sourced from several places that all said the same thing, and I'd never seen it happen. So I finally set up a scratch company file and watched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;Two imports through &lt;em&gt;File › Utilities › Import › IIF Files&lt;/em&gt;, both from a credit card statement, both containing the same transactions with &lt;code&gt;CREDIT CARD CHARGE&lt;/code&gt; and &lt;code&gt;CREDIT CARD CREDIT&lt;/code&gt; transaction types. The only difference was the account name.&lt;/p&gt;

&lt;p&gt;The first import named an account that did not exist. QuickBooks reported success. It did not prompt, it did not warn, and it created the account. All of that matched what I expected.&lt;/p&gt;

&lt;p&gt;What I hadn't expected was the account it created. &lt;strong&gt;It was a Bank account&lt;/strong&gt; — for a credit card statement. And that changed the transactions as well, because a Bank account cannot hold a credit card charge. The register showed &lt;code&gt;CHK&lt;/code&gt; and &lt;code&gt;DEP&lt;/code&gt; where the file had said &lt;code&gt;CREDIT CARD CHARGE&lt;/code&gt; and &lt;code&gt;CREDIT CARD CREDIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second import pointed the same file at a Credit Card account that already existed, and everything landed correctly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the file&lt;/th&gt;
&lt;th&gt;Real Credit Card account&lt;/th&gt;
&lt;th&gt;Auto-created account&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREDIT CARD CHARGE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CHK&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREDIT CARD CREDIT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CC CRED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DEP&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Signs behaved correctly in the second case too — a charge increased the balance owed, a payment reduced it — which settled a separate question I'd had, since published sources disagree about whether the right transaction types are &lt;code&gt;CREDIT CARD CHARGE&lt;/code&gt;/&lt;code&gt;CREDIT CARD CREDIT&lt;/code&gt; or &lt;code&gt;CREDIT CARD&lt;/code&gt;/&lt;code&gt;CCARD REFUND&lt;/code&gt;. The first pair is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it behaves this way
&lt;/h2&gt;

&lt;p&gt;A typo in an account name doesn't produce a duplicate account you clean up later. It produces an account of the wrong &lt;em&gt;kind&lt;/em&gt;, holding transactions of the wrong &lt;em&gt;type&lt;/em&gt;, and the import that did it reported success. In bookkeeping terms a liability has quietly become an asset. Nothing in the process ever says so; you find out at reconciliation.&lt;/p&gt;

&lt;p&gt;That outcome isn't a bug so much as the only thing the format leaves available. IIF was designed for bulk loading, so the importer is built to complete rather than to validate, and it runs without a user sitting in front of it. When it meets a name it doesn't know it has two options: refuse the whole file, or guess. It guesses, and it guesses Bank.&lt;/p&gt;

&lt;p&gt;What makes it worth a second look is that the guess ignores evidence the file does supply. Look at the row above: &lt;code&gt;TRNSTYPE&lt;/code&gt; and &lt;code&gt;ACCNT&lt;/code&gt; are on the same line, so the line that names the unknown account also says &lt;code&gt;CREDIT CARD CHARGE&lt;/code&gt;. The importer creates a Bank account anyway, and then rewrites the transaction into something a Bank account can hold.&lt;/p&gt;

&lt;p&gt;Every part of that is locally reasonable. The result is still a silent corruption of someone's books.&lt;/p&gt;

&lt;p&gt;It's a useful contrast with the other format I generate. A QBO file lands in the bank feed's review queue, where each transaction is matched and approved before anything is recorded, and the account is identified by a number the bank issued rather than a name a human typed. The failure mode there is "nothing happens and you don't know why," which is annoying but recoverable. The IIF failure mode is "everything happens and looks fine."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;The copy on my converter used to say what everyone else says: make sure the account name matches your chart of accounts exactly, because otherwise QuickBooks will create a new account. That's true, and it's incomplete in a way that matters, because it implies the worst case is an extra account. The requirement is actually stronger — the account has to already exist, with the correct type, because nothing in the file says what kind of account it is, the importer won't infer it from the transaction type, and it will not ask.&lt;/p&gt;

&lt;p&gt;I also can't tell you whether changing the invented account's type from Bank to Credit Card afterwards produces correct results, because I didn't test it and I'm not going to assume. Restoring the backup is the fix I can vouch for, which is a good argument for taking one, since an IIF import writes straight into the company file with no undo.&lt;/p&gt;

&lt;p&gt;One more gap worth stating: I ran this on QuickBooks Desktop for Mac. The IIF importer is the same feature on Windows and I have no particular reason to expect it to differ, but I haven't checked, so I'd be glad to hear from anyone who has.&lt;/p&gt;

&lt;p&gt;The full write-up, with both imports side by side, is at &lt;a href="https://qbofile.com/help/iif-import-created-a-new-account/" rel="noopener noreferrer"&gt;qbofile.com&lt;/a&gt; if it's useful to you.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>testing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Reading .xlsx in the browser without a spreadsheet library</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Mon, 17 Aug 2026 06:14:06 +0000</pubDate>
      <link>https://dev.to/ink_gu/reading-xlsx-in-the-browser-without-a-spreadsheet-library-5937</link>
      <guid>https://dev.to/ink_gu/reading-xlsx-in-the-browser-without-a-spreadsheet-library-5937</guid>
      <description>&lt;p&gt;I run a small site that converts bank CSV exports into the file format QuickBooks Desktop accepts. The whole thing runs client-side, and the privacy claim it makes is unusually literal: every page ships a Content Security Policy with &lt;code&gt;connect-src 'none'&lt;/code&gt;, so the browser refuses to let the page make any network request at all. Open the Network tab while you convert a file and it stays empty. That's the feature, not a nice-to-have on top of it.&lt;/p&gt;

&lt;p&gt;When I added Excel support last week, the obvious choice was SheetJS. I decided against it, and the reason wasn't bundle size. The claim I want to be able to make is "your file never leaves the browser, and here is the policy that enforces it." Pulling in a large third-party parser turns that into "…and also trust this dependency," which is a materially weaker claim for a tool that handles people's bank statements. So I wanted to find out how much of the format I actually needed.&lt;/p&gt;

&lt;p&gt;Less than I expected. An &lt;code&gt;.xlsx&lt;/code&gt; file is a ZIP archive containing XML: &lt;code&gt;xl/workbook.xml&lt;/code&gt; lists the sheets, &lt;code&gt;xl/worksheets/sheet1.xml&lt;/code&gt; holds the cells, &lt;code&gt;xl/sharedStrings.xml&lt;/code&gt; is a deduplicated string pool that cells reference by index, and &lt;code&gt;xl/styles.xml&lt;/code&gt; carries the number formats. Reading that needs three capabilities, and the browser already provides two of them. Unzipping means walking the ZIP central directory, which is about forty lines. Decompression is &lt;code&gt;DecompressionStream('deflate-raw')&lt;/code&gt;, which is native. For the XML I hand-rolled a tag scanner rather than reaching for &lt;code&gt;DOMParser&lt;/code&gt;, because my tests run in Node where &lt;code&gt;DOMParser&lt;/code&gt; doesn't exist, and I would rather have one code path than two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dates
&lt;/h2&gt;

&lt;p&gt;The part that took the most care was dates, because Excel doesn't store them as dates. A cell holding 5 January 2024 contains the number &lt;code&gt;45296&lt;/code&gt;, and whether that number should be displayed as a date depends on the cell's number format — which lives in a different file inside the archive. So the parser has to read &lt;code&gt;styles.xml&lt;/code&gt;, work out which style indexes correspond to date formats, and check every numeric cell against that list.&lt;/p&gt;

&lt;p&gt;Both failure modes are bad, and neither of them raises an error. Miss a date format and the user's date column arrives as a column of five-digit numbers, which at least looks wrong. Treat a currency format as a date and an amount that happens to be &lt;code&gt;45296&lt;/code&gt; silently becomes a day in January 2024, which doesn't.&lt;/p&gt;

&lt;p&gt;Then there's the epoch, which is &lt;code&gt;1899-12-30&lt;/code&gt; rather than &lt;code&gt;1900-01-01&lt;/code&gt;. Excel believes 1900 was a leap year and reserves serial 60 for a 29 February that never existed, so starting a day and a half earlier makes everything from March 1900 onward line up. Bank statements don't reach back that far, so the gap never surfaces. Older Mac files are a separate case: they use a 1904 epoch, flagged by &lt;code&gt;date1904="1"&lt;/code&gt; on &lt;code&gt;workbookPr&lt;/code&gt;, and missing that attribute puts every date in the file off by four years and a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that cost me an afternoon
&lt;/h2&gt;

&lt;p&gt;I wrote 26 tests against workbooks the test suite generates itself, covering the date handling, sparse cells, shared strings and sheet selection. All of them passed. Then I opened the real page in a browser, dropped a file on it, and got back a single line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to fetch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the parser fetches anything, and the page it runs on is specifically configured so that fetching is impossible. The line responsible was this one:&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;ds&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;DecompressionStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deflate-raw&lt;/span&gt;&lt;span class="dl"&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;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getWriter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping a stream in a &lt;code&gt;Response&lt;/code&gt; and calling &lt;code&gt;arrayBuffer()&lt;/code&gt; is the tidiest way to collect its output, and it's what most examples show. Chrome treats it as a fetch, &lt;code&gt;connect-src 'none'&lt;/code&gt; blocks it, and the error it returns reads like a network failure in code that has no network. Because Node doesn't enforce CSP, the test suite could never have caught this, which is the part I'd want to remember: a green suite told me nothing about whether the code worked on the page it was written for.&lt;/p&gt;

&lt;p&gt;Draining the stream by hand avoids the problem entirely:&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;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getReader&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;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;There is a smaller companion problem worth knowing about. When the compressed data is corrupt, the writer side of the stream rejects as well as the reader side, so unless you attach a &lt;code&gt;.catch()&lt;/code&gt; to both &lt;code&gt;writer.write()&lt;/code&gt; and &lt;code&gt;writer.close()&lt;/code&gt; you get an &lt;code&gt;Uncaught (in promise)&lt;/code&gt; in the console sitting next to the error you are already handling correctly. A handled failure ends up looking unhandled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I left out
&lt;/h2&gt;

&lt;p&gt;I don't read &lt;code&gt;.xls&lt;/code&gt;. It's an OLE compound document rather than a ZIP — a genuinely different format that happens to have a similar name — so the converter detects it and asks the user to save as &lt;code&gt;.xlsx&lt;/code&gt; first. Zip64 archives bail out with a message rather than being supported, on the grounds that I have yet to meet a four-gigabyte bank statement.&lt;/p&gt;

&lt;p&gt;Multiple sheets is the decision I'm least happy with. A workbook often has a cover page, a summary tab and the actual transactions somewhere in the middle, and there's no reliable signal for which one the user meant. I take the first sheet with more than one row of data and name it in the results panel so the choice is at least visible, but that's a compromise rather than a solution; every alternative I considered came down to asking the user a question they don't want to be asked.&lt;/p&gt;

&lt;p&gt;One thing I haven't been able to confirm is whether &lt;code&gt;new Response(stream)&lt;/code&gt; counts against &lt;code&gt;connect-src&lt;/code&gt; in Firefox and Safari the way it does in Chrome. If anyone knows, I'd be glad to hear it — the workaround is harmless either way, but the diagnosis took long enough that I'd rather it were written down somewhere.&lt;/p&gt;

&lt;p&gt;The parser is at &lt;code&gt;/src/xlsx/parse.js&lt;/code&gt; on &lt;a href="https://qbofile.com" rel="noopener noreferrer"&gt;qbofile.com&lt;/a&gt;, unminified, if you'd like to look at it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Notes from getting QuickBooks to accept a generated .qbo file</title>
      <dc:creator>Gu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 03:24:14 +0000</pubDate>
      <link>https://dev.to/ink_gu/notes-from-getting-quickbooks-to-accept-a-generated-qbo-file-3k5i</link>
      <guid>https://dev.to/ink_gu/notes-from-getting-quickbooks-to-accept-a-generated-qbo-file-3k5i</guid>
      <description>&lt;p&gt;I'm building a small tool that converts bank CSV files into .qbo files for QuickBooks (&lt;a href="https://qbofile.com" rel="noopener noreferrer"&gt;qbofile.com&lt;/a&gt;). When a generated file is wrong, QuickBooks rejects it with vague errors and the OFX spec doesn't tell you what QuickBooks actually checks. So I ran some experiments. Notes below, in case someone else hits the same wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file is not XML
&lt;/h2&gt;

&lt;p&gt;.qbo is Intuit's version of OFX 1.0.2, which is SGML. Leaf tags have no closing tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;TRNAMT&amp;gt;&lt;/span&gt;-42.50
&lt;span class="nt"&gt;&amp;lt;FITID&amp;gt;&lt;/span&gt;8f3a2b...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only aggregate tags close. The file also needs a 9-line key:value header, then one blank line, then the body. Line endings are CRLF. My first bug was closing every tag like XML.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Missing bid data" means one tag: INTU.BID
&lt;/h2&gt;

&lt;p&gt;QuickBooks checks &lt;code&gt;&amp;lt;INTU.BID&amp;gt;&lt;/code&gt; against an internal list of banks that pay Intuit for Web Connect. I tested three variants on QuickBooks Desktop for Mac 2024:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;&amp;lt;FI&amp;gt;&lt;/code&gt; block, no &lt;code&gt;&amp;lt;INTU.BID&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rejected: "Missing bid data"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only &lt;code&gt;&amp;lt;INTU.BID&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;FI&amp;gt;&lt;/code&gt; block + &lt;code&gt;&amp;lt;INTU.BID&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the whole &lt;code&gt;&amp;lt;FI&amp;gt;&lt;/code&gt; block (bank name, org id) can be dropped, but INTU.BID cannot. I have only tested the Mac version. If you know whether Windows versions behave the same, I'd like to hear.&lt;/p&gt;

&lt;h2&gt;
  
  
  FITID decides duplicates
&lt;/h2&gt;

&lt;p&gt;QuickBooks dedupes on FITID, not on date + amount. If a converter generates random FITIDs, re-importing an overlapping date range creates duplicate transactions. I hash account + date + amount + description, so the same transaction always gets the same FITID. Credit card statement cycles never match calendar months, so overlapping imports happen more often than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  QuickBooks cannot export .qbo
&lt;/h2&gt;

&lt;p&gt;This one surprised me. No version of QuickBooks can produce a .qbo file. The format only goes one direction, from bank to QuickBooks. Every .qbo file in the world came from a bank's download button or from a converter.&lt;/p&gt;




&lt;p&gt;That's what I have so far. The tool is free for single files and runs fully in the browser, nothing gets uploaded. I have only tested against QuickBooks Desktop — if you use QuickBooks Online, I'm interested in what its importer accepts.&lt;/p&gt;

</description>
      <category>quickbooks</category>
      <category>fintech</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
