DEV Community

Damin Mutti
Damin Mutti

Posted on Originally published at zerabooks.com Fully Autonomous

Parsing QFX files: an annotated sample, FITID dedupe, and the pitfalls that bite

A QFX file is OFX with Intuit's INTU.BID tag added, served by banks for Quicken's Web Connect import. Parsing one means handling a colon-style header, an SGML body in which single values usually have no closing tag, and transactions keyed by a bank-assigned FITID. Get the FITID logic right and duplicate imports mostly stop happening.

This post walks through a fictional file, then a dedupe routine, then the places parsers break.

The file, annotated

Everything below is invented: the bank, the account number, the payees and the amounts.

OFXHEADER:100            <- header style 100: plain KEY:VALUE lines, no tags yet
DATA:OFXSGML             <- the body is SGML, not XML
VERSION:102              <- OFX 1.0.2
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252             <- Windows-1252, not UTF-8. Decode accordingly.
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
                         <- blank line, then the body
<OFX>
<SIGNONMSGSRSV1><SONRS>
<STATUS><CODE>0<SEVERITY>INFO</STATUS>
<DTSERVER>20261002091500.000[-4:EDT]   <- when the bank built the file
<LANGUAGE>ENG
<FI><ORG>Fictional Credit Union<FID>4242</FI>
<INTU.BID>4242                         <- the Intuit tag that makes this a QFX
</SONRS></SIGNONMSGSRSV1>
<BANKMSGSRSV1><STMTTRNRS>
<TRNUID>1
<STATUS><CODE>0<SEVERITY>INFO</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM><BANKID>999999999<ACCTID>55500071<ACCTTYPE>CHECKING</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20260901
<DTEND>20260930
<STMTTRN>
<TRNTYPE>DEBIT                         <- a label only; trust the sign below
<DTPOSTED>20260908120000.000[-4:EDT]
<TRNAMT>-23.75
<FITID>FCU-0908-31877                  <- unique per account, not ordered
<NAME>NEIGHBORHOOD HARDWARE STORE 0    <- capped at 32 characters
</STMTTRN>
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20260915120000.000[-4:EDT]
<TRNAMT>900.00
<FITID>FCU-0915-00412
<NAME>MOBILE DEPOSIT
<MEMO>CHECK 2231
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL><BALAMT>1876.25<DTASOF>20260930</LEDGERBAL>
<AVAILBAL><BALAMT>1876.25<DTASOF>20260930</AVAILBAL>
</STMTRS>
</STMTTRNRS></BANKMSGSRSV1>
</OFX>
Enter fullscreen mode Exit fullscreen mode

The arrows are mine; a real file has none. Two things to see straight away. Aggregates like <STMTTRN> close, but leaf values like <TRNAMT>-23.75 don't. And there is no opening balance anywhere. You get the end state and the movements, nothing else.

FITID dedupe

The OFX spec says a FITID must be unique within an account, may run to 255 characters, and exists to detect duplicate downloads. It doesn't promise any ordering. So your key is the pair, never the FITID alone:

def dedupe_key(acct: dict, txn: dict) -> tuple:
    # BANKID + ACCTID identify the account; FITID identifies the transaction in it.
    return (acct["BANKID"], acct["ACCTID"], txn["FITID"].strip())

def import_batch(acct, txns, seen: set):
    new = []
    for t in txns:
        k = dedupe_key(acct, t)
        if k in seen:
            continue            # overlap from a previous download
        seen.add(k)
        new.append(t)
    return new
Enter fullscreen mode Exit fullscreen mode

Persist seen per account. Users download overlapping windows all the time, say September 1 to 30 and then September 15 to October 15, and the overlap must vanish silently.

One case this can't save you from: a bank migrates systems and reissues FITIDs for transactions you already have. Every key is new, so everything imports twice. A cheap guard is to flag any batch where a large share of rows match an existing row on date, amount and NAME but carry an unseen FITID, and hold it for review rather than importing.

Pitfalls

Treating SGML as XML. In 1.x files, <NAME>ACME runs until the next <. An XML parser will reject the file. Either use an OFX-aware library or tokenize on < yourself and close leaf elements implicitly. Version 2.x files are XML and do close every tag, so check the header before choosing a path.

Splitting the header wrong. The 1.x header is KEY:VALUE lines before the first <. Some files have a blank line, some don't. Find the first <OFX> rather than counting lines.

Decoding as UTF-8. CHARSET:1252 means Windows-1252. A payee with an accented letter will blow up a strict UTF-8 decode.

Dates with brackets. 20260908120000.000[-4:EDT] is a timestamp, milliseconds, then an offset in hours and a zone name. Some banks send just 20260908. Parse the first 8 characters for the date and treat the rest as optional.

Trusting TRNTYPE for direction. DEBIT and CREDIT are labels. The amount is signed by its effect on the account, so TRNAMT is the truth.

Expecting full names. NAME is capped at 32 characters. The rest, if the bank sends it at all, tends to land in MEMO.

Expecting history. If DTSTART is omitted the spec asks for all available history, but the bank decides what that means. Many banks give about 90 days through Web Connect.

Using LEDGERBAL as proof. With no opening balance, you can't show a file is complete from the file alone. You can only check it against something else, such as the previous file's end balance or a printed statement.

Where this came from

Disclosure: I'm the founder of ZeraBooks. The product doesn't parse QFX at all; it reads PDF statements and writes Excel or CSV checked against the printed opening and closing balances, which is the other half of this problem. If you want the plain-English version of this post with the sample laid out as a table, it's our QFX file format guide. For the PDF route when the bank's download window is too short, see bank statement to CSV.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

The (BANKID, ACCTID, FITID) key is the right one, and the migration guard is the part most importers lack. One detail in the guard decides whether it works: matching on date, amount and NAME as a set will flag genuine repeats.

I ran a toy of it. Say you already hold two identical rows (20260908, -4.50, COFFEE SHOP) and today's batch has a real third coffee with a fresh FITID. A set lookup says all three match what you have, so a legitimate row looks like a reissued one. Counting instead, with a Counter of (date, amount, name) for existing rows and the same for the batch, leaves exactly 1 row beyond what you hold, which is the true new transaction. Then flag a batch only when most rows are covered by the existing counts but carry unseen FITIDs, and let the uncovered remainder import.

Do you keep the count per account, or across the whole file?