Commit0d01253dRecorded25 Mar 2026Repositorytally
Add QBO CSV/XLSX import to hledger journal format
Message
New (tally qbo) module converts QuickBooks Online "Transaction Detail by Account" exports to hledger journal entries. Handles CSV parsing, row grouping by transaction (date+type+num), configurable account name mapping, and deduplication via ref tags.
CLI: tally import-qbo <csv-file> [--output FILE] [--account-map FILE]
Also includes a Python script (scripts/convert-qbo-xlsx.py) for converting the XLSX export directly, with per-year output files and a master include file. Requires openpyxl.
28 tests covering CSV parsing, grouping, mapping, conversion, and dedup integration.
Changed
scripts/convert-qbo-xlsx.py | 321 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/tally/main.sgl | 31 ++++++++++++++
src/tally/qbo.sgl | 366 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/fixtures/qbo-transactions.csv | 16 +++++++
test/test-qbo.sgl | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1076 insertions(+)Diff
scripts/convert-qbo-xlsx.pyadded
@@ -0,0 +1,321 @@
+1
#!/usr/bin/env python3+2
"""Convert a QBO Journal XLSX export to yearly hledger journal files.+3
+4
Usage:+5
python3 convert-qbo-xlsx.py <journal.xlsx> <output-dir>+6
+7
Reads the QBO "Journal" report export (one transaction = date row ++8
continuation rows + totals row + blank row) and writes one .journal+9
file per year to the output directory.+10
+11
Requires: openpyxl+12
"""+13
+14
import sys+15
import os+16
from collections import defaultdict+17
from decimal import Decimal, ROUND_HALF_UP+18
+19
try:+20
import openpyxl+21
except ImportError:+22
print("Error: openpyxl required. Install with: pip install openpyxl", file=sys.stderr)+23
print(" or: guix shell python python-openpyxl -- python3 convert-qbo-xlsx.py ...", file=sys.stderr)+24
sys.exit(1)+25
+26
+27
# ── Account Mapping ──────────────────────────────────────────────+28
# Maps QBO account names to hledger account hierarchy.+29
+30
ACCOUNT_MAP = {+31
# Assets+32
"QuickBooks Checking Account": "assets:bank:checking",+33
"Payments to deposit": "assets:payments-to-deposit",+34
"Uncategorized Asset": "assets:uncategorized",+35
"Accounts Receivable (A/R)": "assets:accounts-receivable",+36
+37
# Equity+38
"Owner draws": "equity:owner-draws",+39
"Owner investments": "equity:owner-investments",+40
+41
# Income+42
"Services": "income:services",+43
"Services:Consulting Services": "income:services:consulting",+44
"Services:Viewer Memberships": "income:services:memberships",+45
"Uncategorized Income:Advertising Revenue": "income:advertising",+46
"Uncategorized Income:Interest Received": "income:interest",+47
"Uncategorized Income:Viewer Tips": "income:tips",+48
"Sales of Product Income": "income:product-sales",+49
"Other income:Interest earned": "income:interest",+50
+51
# COGS+52
"Cost of goods sold": "expenses:cogs",+53
"Cost of goods sold:Merchandise Printing and Distribution": "expenses:cogs:merchandise",+54
+55
# Expenses+56
"General business expenses": "expenses:general",+57
"General business expenses:Bank fees & service charges": "expenses:bank-fees",+58
"General business expenses:Cloud Hosting": "expenses:hosting",+59
"General business expenses:Analytics Services": "expenses:analytics",+60
"General business expenses:Online Store Hosting": "expenses:hosting:store",+61
"Office expenses:Phone Service": "expenses:phone",+62
"Office expenses:Software & apps": "expenses:software",+63
"Advertising & marketing": "expenses:marketing",+64
"Legal & accounting services": "expenses:legal-accounting",+65
"Contract labor": "expenses:contract-labor",+66
"Business licences": "expenses:licenses",+67
"Utilities:Internet & TV services": "expenses:internet",+68
"QuickBooks Payments Fees": "expenses:payment-processing",+69
}+70
+71
+72
def map_account(qbo_name):+73
"""Map a QBO account name to hledger hierarchy."""+74
mapped = ACCOUNT_MAP.get(qbo_name)+75
if mapped:+76
return mapped+77
# Fallback: sanitize as-is+78
return qbo_name.lower().replace(" ", "-").replace("&", "and")+79
+80
+81
# ── Amount Formatting ────────────────────────────────────────────+82
+83
def fmt_amount(value):+84
"""Format a number as $X,XXX.XX with proper sign handling."""+85
if value is None:+86
return None+87
d = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)+88
sign = "-" if d < 0 else ""+89
d = abs(d)+90
# Format with comma separators+91
int_part = int(d)+92
frac_part = d - int_part+93
int_str = f"{int_part:,}"+94
return f"{sign}${int_str}.{str(frac_part)[2:4].ljust(2, '0')}"+95
+96
+97
# ── XLSX Parsing ─────────────────────────────────────────────────+98
+99
def parse_xlsx(path):+100
"""Parse QBO Journal XLSX into a list of transaction dicts.+101
+102
Each transaction dict has:+103
date: str (MM/DD/YYYY)+104
type: str+105
num: str or None+106
name: str or None+107
postings: list of {account, memo, debit, credit}+108
"""+109
wb = openpyxl.load_workbook(path, read_only=True)+110
ws = wb.active+111
all_rows = list(ws.iter_rows(values_only=True))+112
wb.close()+113
+114
transactions = []+115
i = 5 # skip header rows (0-indexed: rows 1-5)+116
+117
while i < len(all_rows):+118
row = all_rows[i]+119
+120
# Skip blank rows+121
if row[1] is None:+122
i += 1+123
continue+124
+125
# Skip the final TOTAL row+126
if row[0] == "TOTAL":+127
break+128
+129
# Skip footer/timestamp rows+130
if isinstance(row[0], str) and row[0] and not row[1]:+131
i += 1+132
continue+133
+134
# Date row — start of a new transaction+135
date = row[1]+136
txn_type = row[2] or ""+137
num = row[3]+138
name = row[4] or ""+139
memo_first = row[5] or ""+140
account_first = row[6] or ""+141
debit_first = row[7]+142
credit_first = row[8]+143
+144
# Clean up Num (openpyxl reads as float)+145
if num is not None:+146
num = str(int(num)) if isinstance(num, float) and num == int(num) else str(num)+147
+148
postings = [{+149
"account": account_first,+150
"memo": memo_first,+151
"debit": debit_first,+152
"credit": credit_first,+153
}]+154
+155
# Read continuation rows+156
i += 1+157
while i < len(all_rows):+158
row = all_rows[i]+159
+160
# Blank row or totals row = end of transaction+161
if all(cell is None for cell in row):+162
i += 1+163
break+164
# Totals row: debit and credit both non-None and equal+165
if row[7] is not None and row[8] is not None and row[6] is None:+166
i += 1+167
# Skip the blank row after totals+168
if i < len(all_rows) and all(cell is None for cell in all_rows[i]):+169
i += 1+170
break+171
+172
# Continuation posting row+173
if row[6] is not None:+174
postings.append({+175
"account": row[6] or "",+176
"memo": row[5] or "",+177
"debit": row[7],+178
"credit": row[8],+179
})+180
i += 1+181
+182
transactions.append({+183
"date": date,+184
"type": txn_type,+185
"num": num,+186
"name": name,+187
"postings": postings,+188
})+189
+190
return transactions+191
+192
+193
# ── Journal Formatting ───────────────────────────────────────────+194
+195
def posting_amount(posting):+196
"""Compute signed amount: debit positive, credit negative."""+197
if posting["debit"] is not None and posting["debit"] != 0:+198
return posting["debit"]+199
if posting["credit"] is not None and posting["credit"] != 0:+200
return -posting["credit"]+201
return 0+202
+203
+204
def txn_description(txn):+205
"""Build description from name and/or first posting memo."""+206
name = txn["name"].strip() if txn["name"] else ""+207
memo = txn["postings"][0]["memo"].strip() if txn["postings"] else ""+208
+209
if name and memo:+210
return f"{name} | {memo}"+211
return name or memo or "Unknown"+212
+213
+214
def format_ref_tag(txn):+215
"""Build a ref tag for deduplication."""+216
t = txn["type"].replace(" ", "-").lower()+217
if txn["num"]:+218
return f"qbo:{t}:{txn['num']}"+219
# Use date + description hash for uniqueness+220
desc = txn_description(txn).replace(" ", "-").lower()[:40]+221
return f"qbo:{t}:{txn['date'].replace('/', '-')}:{desc}"+222
+223
+224
def qbo_date_to_hledger(date_str):+225
"""Convert MM/DD/YYYY to YYYY-MM-DD."""+226
parts = date_str.split("/")+227
if len(parts) == 3:+228
return f"{parts[2]}-{parts[0].zfill(2)}-{parts[1].zfill(2)}"+229
return date_str+230
+231
+232
def format_transaction(txn):+233
"""Format a transaction as hledger journal text."""+234
date = qbo_date_to_hledger(txn["date"])+235
desc = txn_description(txn)+236
code = f" ({txn['num']})" if txn["num"] else ""+237
ref = format_ref_tag(txn)+238
+239
lines = [f"{date} !{code} {desc}"]+240
lines.append(f" ; ref: {ref}")+241
+242
for posting in txn["postings"]:+243
account = map_account(posting["account"])+244
amount = posting_amount(posting)+245
amount_str = fmt_amount(amount)+246
lines.append(f" {account:<45s} {amount_str}")+247
+248
return "\n".join(lines)+249
+250
+251
# ── Yearly Splitting ─────────────────────────────────────────────+252
+253
def group_by_year(transactions):+254
"""Group transactions by year from their date."""+255
by_year = defaultdict(list)+256
for txn in transactions:+257
year = txn["date"].split("/")[-1]+258
by_year[year].append(txn)+259
return dict(by_year)+260
+261
+262
def write_yearly_journals(transactions, output_dir):+263
"""Write one .journal file per year."""+264
by_year = group_by_year(transactions)+265
+266
os.makedirs(output_dir, exist_ok=True)+267
+268
for year in sorted(by_year.keys()):+269
txns = by_year[year]+270
path = os.path.join(output_dir, f"{year}.journal")+271
+272
with open(path, "w") as f:+273
f.write(f"; QBO Journal export — {year}\n")+274
f.write(f"; Converted from QuickBooks Online on {txns[0]['date'] if txns else 'N/A'}\n")+275
f.write(f"; {len(txns)} transactions\n\n")+276
+277
for i, txn in enumerate(txns):+278
f.write(format_transaction(txn))+279
if i < len(txns) - 1:+280
f.write("\n\n")+281
else:+282
f.write("\n")+283
+284
print(f" {path}: {len(txns)} transactions")+285
+286
# Write a master include file+287
master_path = os.path.join(output_dir, "qbo.journal")+288
with open(master_path, "w") as f:+289
f.write("; QBO imported data — master include file\n")+290
f.write("; Generated from QuickBooks Online Journal export\n\n")+291
for year in sorted(by_year.keys()):+292
f.write(f"include {year}.journal\n")+293
+294
print(f" {master_path}: master include file")+295
+296
+297
# ── Main ─────────────────────────────────────────────────────────+298
+299
def main():+300
if len(sys.argv) < 3:+301
print(f"Usage: {sys.argv[0]} <journal.xlsx> <output-dir>", file=sys.stderr)+302
sys.exit(1)+303
+304
xlsx_path = sys.argv[1]+305
output_dir = sys.argv[2]+306
+307
if not os.path.exists(xlsx_path):+308
print(f"Error: {xlsx_path} not found", file=sys.stderr)+309
sys.exit(1)+310
+311
print(f"Reading {xlsx_path}...")+312
transactions = parse_xlsx(xlsx_path)+313
print(f"Parsed {len(transactions)} transactions.")+314
+315
print(f"\nWriting yearly journals to {output_dir}/")+316
write_yearly_journals(transactions, output_dir)+317
print(f"\nDone! Validate with: hledger print -f {output_dir}/qbo.journal")+318
+319
+320
if __name__ == "__main__":+321
main()src/tally/main.sglmodified
@@ -9,8 +9,10 @@
9
(sigil process) 10
(sigil mcp server) 11
(wise)+12
(ledger) 13
(tally config) 14
(tally sync)+15
(tally qbo) 16
(tally tools)) 17
(export main) 18
(begin@@ -28,6 +30,8 @@
30
(display " Pull transactions from Wise\n") 31
(display " tally import [--journal FILE] [--since DATE] [--until DATE]\n") 32
(display " Pull and write to journal\n")+33
(display " tally import-qbo <csv-file> [--output FILE] [--account-map FILE]\n")+34
(display " Import QBO CSV export to journal\n") 35
(display " tally balance [--profile TYPE] Show Wise balances\n") 36
(display " tally report [--journal FILE] [--type bal|reg|is] [QUERY]\n") 37
(display " Run hledger reports\n")@@ -112,6 +116,31 @@
116
(display (run-hledger-report report-type journal query-args)) 117
(newline))) 118
+119
(define (cli-import-qbo config args)+120
(let* ((pos-args (positional-args args))+121
(csv-path (if (pair? pos-args)+122
(car pos-args)+123
(begin+124
(display "Error: CSV file path required.\n")+125
(display "Usage: tally import-qbo <csv-file> [--output FILE] [--account-map FILE]\n")+126
(exit 1))))+127
(output-path (find-flag args "--output" #f))+128
(map-path (find-flag args "--account-map" #f))+129
(account-map (if map-path+130
(load-account-map map-path)+131
default-account-map))+132
(txns (import-qbo-file csv-path output-path account-map))+133
(count (length txns)))+134
(display (string-append+135
"Converted " (number->string count) " transactions from QBO export.\n"))+136
(if output-path+137
(display (string-append "Written to " output-path "\n"))+138
;; No output file — print to stdout+139
(begin+140
(display "\n")+141
(display (format-transactions txns))+142
(newline)))))+143
144
(define (cli-categorize config args) 145
(let* ((journal-path (find-flag args "--journal" 146
(tally-config-journal-path config)))@@ -139,6 +168,8 @@
168
(cli-balance config (cdr args))) 169
((string=? (car args) "report") 170
(cli-report config (cdr args)))+171
((string=? (car args) "import-qbo")+172
(cli-import-qbo config (cdr args))) 173
((string=? (car args) "categorize") 174
(cli-categorize config (cdr args))) 175
(elsesrc/tally/qbo.sgladded
@@ -0,0 +1,366 @@
+1
;;; (tally qbo) - QuickBooks Online CSV export to hledger journal converter.+2
;;;+3
;;; Reads a QBO "Transaction Detail by Account" CSV export (one row per+4
;;; posting), groups rows into transactions, maps account names to hledger+5
;;; hierarchy, and outputs journal-transaction records.+6
;;;+7
;;; QBO CSV columns (after cleanup):+8
;;; Date, Transaction Type, Num, Name, Memo/Description, Account, Debit, Credit+9
;;;+10
;;; Grouping: rows with the same Date + Transaction Type + Num belong to+11
;;; the same transaction. When Num is empty, consecutive rows with the+12
;;; same Date + Transaction Type are grouped together.+13
+14
(define-library (tally qbo)+15
(import (sigil core)+16
(sigil string)+17
(sigil struct)+18
(sigil io)+19
(sigil fs)+20
(ledger))+21
(export parse-csv-line+22
parse-qbo-csv+23
qbo-row+24
qbo-row?+25
qbo-row-date+26
qbo-row-type+27
qbo-row-num+28
qbo-row-name+29
qbo-row-memo+30
qbo-row-account+31
qbo-row-debit+32
qbo-row-credit+33
group-qbo-rows+34
qbo-account-map+35
qbo-account-map?+36
qbo-account-map-rules+37
default-account-map+38
load-account-map+39
map-account-name+40
qbo-group->transaction+41
convert-qbo-journal+42
import-qbo-file)+43
(begin+44
+45
;;; Parse a single CSV line into a list of field strings.+46
;;; Handles quoted fields with escaped double-quotes ("").+47
(define (parse-csv-line line)+48
(let ((len (string-length line)))+49
(let loop ((i 0) (fields '()) (current '()) (in-quotes #f))+50
(cond+51
((>= i len)+52
(reverse (cons (list->string (reverse current)) fields)))+53
((and in-quotes (char=? (string-ref line i) #\")+54
(< (+ i 1) len) (char=? (string-ref line (+ i 1)) #\"))+55
(loop (+ i 2) fields (cons #\" current) in-quotes))+56
((and in-quotes (char=? (string-ref line i) #\"))+57
(loop (+ i 1) fields current #f))+58
((and (not in-quotes) (char=? (string-ref line i) #\"))+59
(loop (+ i 1) fields current #t))+60
((and (not in-quotes) (char=? (string-ref line i) #\,))+61
(loop (+ i 1)+62
(cons (list->string (reverse current)) fields)+63
'()+64
#f))+65
(else+66
(loop (+ i 1) fields (cons (string-ref line i) current) in-quotes))))))+67
+68
(define-struct qbo-row+69
(date default: "")+70
(type default: "")+71
(num default: "")+72
(name default: "")+73
(memo default: "")+74
(account default: "")+75
(debit default: #f)+76
(credit default: #f))+77
+78
;;; Parse a QBO amount string ("1,234.56", "(500.00)", "$42.99", "").+79
;;; Returns a number or #f if empty/invalid.+80
(define (parse-qbo-amount str)+81
(let ((trimmed (string-trim str)))+82
(if (string-empty? trimmed)+83
#f+84
(let* ((cleaned (string-replace trimmed "," ""))+85
(cleaned (if (and (string-starts-with? cleaned "(")+86
(string-ends-with? cleaned ")"))+87
(string-append "-"+88
(substring cleaned 1+89
(- (string-length cleaned) 1)))+90
cleaned))+91
(cleaned (string-replace cleaned "$" ""))+92
(cleaned (string-trim cleaned)))+93
(if (string-empty? cleaned)+94
#f+95
(string->number cleaned))))))+96
+97
;;; Convert CSV fields to a qbo-row, or #f for non-data rows+98
;;; (subtotals, blank lines, rows with fewer than 8 columns).+99
(define (fields->qbo-row fields)+100
(if (< (length fields) 8)+101
#f+102
(let ((date (string-trim (list-ref fields 0)))+103
(type (string-trim (list-ref fields 1)))+104
(num (string-trim (list-ref fields 2)))+105
(name (string-trim (list-ref fields 3)))+106
(memo (string-trim (list-ref fields 4)))+107
(acct (string-trim (list-ref fields 5)))+108
(deb (list-ref fields 6))+109
(cred (list-ref fields 7)))+110
(if (or (string-empty? date)+111
(not (char-numeric? (string-ref date 0))))+112
#f+113
(qbo-row+114
date: date+115
type: type+116
num: num+117
name: name+118
memo: memo+119
account: acct+120
debit: (parse-qbo-amount deb)+121
credit: (parse-qbo-amount cred))))))+122
+123
;;; Parse a full QBO CSV string into a list of qbo-row records.+124
;;; Skips the header row and any invalid/blank rows.+125
(define (parse-qbo-csv text)+126
(let* ((lines (string-split text "\n"))+127
(data-lines (if (pair? lines) (cdr lines) '())))+128
(let loop ((rest data-lines) (acc '()))+129
(if (null? rest)+130
(reverse acc)+131
(let* ((trimmed (string-trim (car rest)))+132
(row (if (string-empty? trimmed)+133
#f+134
(fields->qbo-row (parse-csv-line trimmed)))))+135
(loop (cdr rest)+136
(if row (cons row acc) acc)))))))+137
+138
;;; Grouping key for a QBO row: Date|Type|Num.+139
(define (row-group-key row)+140
(string-append (qbo-row-date row) "|"+141
(qbo-row-type row) "|"+142
(qbo-row-num row)))+143
+144
;;; Group QBO rows into transactions. Consecutive rows with the+145
;;; same Date + Type + Num are grouped together.+146
(define (group-qbo-rows rows)+147
(if (null? rows)+148
'()+149
(let loop ((rest (cdr rows))+150
(current-group (list (car rows)))+151
(current-key (row-group-key (car rows)))+152
(groups '()))+153
(cond+154
((null? rest)+155
(reverse (cons (reverse current-group) groups)))+156
((string=? (row-group-key (car rest)) current-key)+157
(loop (cdr rest)+158
(cons (car rest) current-group)+159
current-key+160
groups))+161
(else+162
(loop (cdr rest)+163
(list (car rest))+164
(row-group-key (car rest))+165
(cons (reverse current-group) groups)))))))+166
+167
(define-struct qbo-account-map+168
(rules default: '()))+169
+170
;;; Default account mapping rules.+171
;;; Each rule is (qbo-prefix . hledger-prefix).+172
;;; Rules are tried in order; first match wins.+173
(define default-account-map+174
(qbo-account-map+175
rules: '(("Checking" . "assets:bank:checking")+176
("Savings" . "assets:bank:savings")+177
("Cash" . "assets:cash")+178
("Accounts Receivable" . "assets:accounts-receivable")+179
("Undeposited Funds" . "assets:undeposited-funds")+180
("Credit Card" . "liabilities:credit-card")+181
("Visa" . "liabilities:credit-card:visa")+182
("Mastercard" . "liabilities:credit-card:mastercard")+183
("Amex" . "liabilities:credit-card:amex")+184
("Accounts Payable" . "liabilities:accounts-payable")+185
("Loan" . "liabilities:loan")+186
("Sales" . "income:sales")+187
("Services" . "income:services")+188
("Interest" . "income:interest")+189
("Other Income" . "income:other")+190
("Cost of Goods Sold" . "expenses:cogs")+191
("Advertising" . "expenses:advertising")+192
("Insurance" . "expenses:insurance")+193
("Meals" . "expenses:meals")+194
("Office Supplies" . "expenses:office-supplies")+195
("Rent" . "expenses:rent")+196
("Utilities" . "expenses:utilities")+197
("Travel" . "expenses:travel")+198
("Payroll" . "expenses:payroll")+199
("Professional Fees" . "expenses:professional-fees")+200
("Taxes" . "expenses:taxes")+201
("Depreciation" . "expenses:depreciation")+202
("Opening Balance Equity" . "equity:opening-balance")+203
("Retained Earnings" . "equity:retained-earnings")+204
("Owner's Equity" . "equity:owner"))))+205
+206
;;; Load an account mapping from a file.+207
;;; Format: one mapping per line, "QBO Account Name = hledger:account:name"+208
;;; Lines starting with # or ; are comments. Blank lines are skipped.+209
(define (load-account-map path)+210
(let* ((text (read-file-string path))+211
(lines (string-split text "\n"))+212
(rules+213
(let loop ((rest lines) (acc '()))+214
(if (null? rest)+215
(reverse acc)+216
(let ((trimmed (string-trim (car rest))))+217
(cond+218
((string-empty? trimmed)+219
(loop (cdr rest) acc))+220
((char=? (string-ref trimmed 0) #\#)+221
(loop (cdr rest) acc))+222
((char=? (string-ref trimmed 0) #\;)+223
(loop (cdr rest) acc))+224
(else+225
(let ((parts (string-split trimmed "=")))+226
(if (>= (length parts) 2)+227
(loop (cdr rest)+228
(cons (cons (string-trim (car parts))+229
(string-trim+230
(string-join (cdr parts) "=")))+231
acc))+232
(loop (cdr rest) acc))))))))))+233
(qbo-account-map rules: rules)))+234
+235
;;; Lowercase, replace spaces with hyphens, collapse empty segments.+236
(define (sanitize-account-name name)+237
(let* ((lower (string-downcase name))+238
(cleaned (string-replace lower " " "-"))+239
(parts (string-split cleaned ":"))+240
(parts (filter (lambda (s) (not (string-empty? s))) parts)))+241
(string-join parts ":")))+242
+243
;;; Map a QBO account name to an hledger account using the mapping.+244
;;; Tries each rule in order; exact match wins, then prefix match+245
;;; (appending the remaining suffix). Falls back to sanitizing+246
;;; the QBO name as-is.+247
(define (map-account-name account-map qbo-name)+248
(let ((rules (qbo-account-map-rules account-map))+249
(trimmed (string-trim qbo-name)))+250
(let loop ((rest rules))+251
(cond+252
((null? rest)+253
(sanitize-account-name trimmed))+254
((string-ci=? trimmed (caar rest))+255
(cdar rest))+256
((string-starts-with? (string-downcase trimmed)+257
(string-downcase (caar rest)))+258
(let* ((prefix-len (string-length (caar rest)))+259
(suffix (string-trim (substring trimmed prefix-len+260
(string-length trimmed))))+261
(mapped (cdar rest)))+262
(if (string-empty? suffix)+263
mapped+264
(string-append mapped ":"+265
(sanitize-account-name suffix)))))+266
(else (loop (cdr rest)))))))+267
+268
;;; Convert QBO date (MM/DD/YYYY) to hledger date (YYYY-MM-DD).+269
;;; Returns the input unchanged if not in MM/DD/YYYY format.+270
(define (qbo-date->journal-date date-str)+271
(let ((parts (string-split date-str "/")))+272
(if (= (length parts) 3)+273
(let ((month (list-ref parts 0))+274
(day (list-ref parts 1))+275
(year (list-ref parts 2)))+276
(string-append year "-"+277
(if (= (string-length month) 1)+278
(string-append "0" month)+279
month)+280
"-"+281
(if (= (string-length day) 1)+282
(string-append "0" day)+283
day)))+284
date-str)))+285
+286
;;; Posting amount from a QBO row: debit is positive, credit is negative.+287
(define (qbo-row-amount row)+288
(cond+289
((qbo-row-debit row) (qbo-row-debit row))+290
((qbo-row-credit row) (- (qbo-row-credit row)))+291
(else 0)))+292
+293
(define (qbo-row->posting row account-map)+294
(let ((acct (map-account-name account-map (qbo-row-account row)))+295
(amount (qbo-row-amount row)))+296
(journal-posting+297
account: acct+298
amount: (journal-amount+299
quantity: amount+300
commodity: "$"))))+301
+302
;;; Build a transaction description from a group of QBO rows.+303
;;; Prefers the Name field; falls back to Memo.+304
(define (group-description rows)+305
(let loop ((rest rows))+306
(cond+307
((null? rest) "")+308
((not (string-empty? (qbo-row-name (car rest))))+309
(qbo-row-name (car rest)))+310
((not (string-empty? (qbo-row-memo (car rest))))+311
(qbo-row-memo (car rest)))+312
(else (loop (cdr rest))))))+313
+314
;;; Build a deduplication ref tag from a transaction group.+315
;;; When Num is present, uses type:num. Otherwise falls back to+316
;;; type:date:description for reasonable uniqueness.+317
(define (group-ref-tag rows)+318
(let* ((first (car rows))+319
(num (qbo-row-num first))+320
(type (qbo-row-type first))+321
(date (qbo-row-date first)))+322
(if (not (string-empty? num))+323
(string-append "qbo:" type ":" num)+324
(string-append "qbo:" type ":" date ":"+325
(sanitize-account-name (group-description rows))))))+326
+327
;;; Convert a group of QBO rows (one transaction) to a journal-transaction.+328
(define (qbo-group->transaction rows account-map)+329
(let* ((first (car rows))+330
(date (qbo-date->journal-date (qbo-row-date first)))+331
(desc (group-description rows))+332
(num (qbo-row-num first))+333
(ref (group-ref-tag rows))+334
(postings (map (lambda (r) (qbo-row->posting r account-map))+335
rows)))+336
(journal-transaction+337
date: date+338
status: "!"+339
code: (if (string-empty? num) "" num)+340
description: desc+341
tags: (list (cons "ref" ref))+342
postings: postings)))+343
+344
;;; Convert a full QBO CSV export to a list of journal-transactions.+345
(define (convert-qbo-journal csv-text . rest)+346
(let* ((account-map (if (pair? rest) (car rest) default-account-map))+347
(rows (parse-qbo-csv csv-text))+348
(groups (group-qbo-rows rows)))+349
(map (lambda (g) (qbo-group->transaction g account-map))+350
groups)))+351
+352
;;; Import a QBO CSV file and write hledger journal output.+353
;;; If output-path is provided, writes to that file.+354
;;; Returns the list of journal-transactions.+355
(define (import-qbo-file csv-path . rest)+356
(let* ((output-path (if (pair? rest) (car rest) #f))+357
(account-map (if (and (pair? rest) (pair? (cdr rest)))+358
(cadr rest)+359
default-account-map))+360
(csv-text (read-file-string csv-path))+361
(txns (convert-qbo-journal csv-text account-map)))+362
(when (and output-path (pair? txns))+363
(write-journal output-path txns))+364
txns))+365
+366
))test/fixtures/qbo-transactions.csvadded
@@ -0,0 +1,16 @@
+1
Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit+2
01/15/2026,Invoice,1001,Acme Corp,Web design services,Accounts Receivable,"1,500.00",+3
01/15/2026,Invoice,1001,Acme Corp,Web design services,Services,,"1,500.00"+4
01/20/2026,Payment,1001,Acme Corp,Payment for Invoice 1001,Checking,"1,500.00",+5
01/20/2026,Payment,1001,Acme Corp,Payment for Invoice 1001,Accounts Receivable,,"1,500.00"+6
02/03/2026,Expense,,Office Depot,Printer paper and toner,Office Supplies,85.42,+7
02/03/2026,Expense,,Office Depot,Printer paper and toner,Checking,,85.42+8
02/10/2026,Check,5012,City Power Co,February electric bill,Utilities,142.67,+9
02/10/2026,Check,5012,City Power Co,February electric bill,Checking,,142.67+10
02/14/2026,Credit Card Charge,,Amazon,Server hosting book,Office Supplies,29.99,+11
02/14/2026,Credit Card Charge,,Amazon,Server hosting book,Credit Card,,29.99+12
03/01/2026,Journal Entry,JE-001,,Q1 depreciation adjustment,Depreciation,500.00,+13
03/01/2026,Journal Entry,JE-001,,Q1 depreciation adjustment,Office Supplies,,200.00+14
03/01/2026,Journal Entry,JE-001,,Q1 depreciation adjustment,Utilities,,300.00+15
03/15/2026,Transfer,,,,Checking,,2000.00+16
03/15/2026,Transfer,,,,Savings,"2,000.00",test/test-qbo.sgladded
@@ -0,0 +1,342 @@
+1
;;; Test suite for (tally qbo)+2
;;;+3
;;; Tests CSV parsing, row grouping, account mapping, and journal+4
;;; conversion using fixture data.+5
+6
(import (sigil test)+7
(sigil string)+8
(sigil struct)+9
(sigil fs)+10
(ledger)+11
(tally qbo))+12
+13
;; ========== CSV Parsing ==========+14
+15
(test-group "parse-csv-line"+16
(test "parses simple fields"+17
(let ((fields (parse-csv-line "a,b,c")))+18
(assert-equal 3 (length fields))+19
(assert-equal "a" (list-ref fields 0))+20
(assert-equal "b" (list-ref fields 1))+21
(assert-equal "c" (list-ref fields 2))))+22
+23
(test "handles quoted fields"+24
(let ((fields (parse-csv-line "\"hello, world\",b,c")))+25
(assert-equal 3 (length fields))+26
(assert-equal "hello, world" (list-ref fields 0))))+27
+28
(test "handles escaped quotes"+29
(let ((fields (parse-csv-line "\"say \"\"hello\"\"\",b")))+30
(assert-equal 2 (length fields))+31
(assert-equal "say \"hello\"" (list-ref fields 0))))+32
+33
(test "handles empty fields"+34
(let ((fields (parse-csv-line "a,,c,")))+35
(assert-equal 4 (length fields))+36
(assert-equal "" (list-ref fields 1))+37
(assert-equal "" (list-ref fields 3))))+38
+39
(test "handles amounts with commas in quotes"+40
(let ((fields (parse-csv-line "date,type,num,name,memo,acct,\"1,500.00\",")))+41
(assert-equal 8 (length fields))+42
(assert-equal "1,500.00" (list-ref fields 6)))))+43
+44
;; ========== QBO CSV Parsing ==========+45
+46
(test-group "parse-qbo-csv"+47
(test "parses CSV text into qbo-row records"+48
(let* ((csv (string-append+49
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+50
"01/15/2026,Invoice,1001,Acme Corp,Web design,Accounts Receivable,\"1,500.00\",\n"+51
"01/15/2026,Invoice,1001,Acme Corp,Web design,Services,,\"1,500.00\"\n"))+52
(rows (parse-qbo-csv csv)))+53
(assert-equal 2 (length rows))+54
(let ((first (car rows)))+55
(assert-equal "01/15/2026" (qbo-row-date first))+56
(assert-equal "Invoice" (qbo-row-type first))+57
(assert-equal "1001" (qbo-row-num first))+58
(assert-equal "Acme Corp" (qbo-row-name first))+59
(assert-equal "Web design" (qbo-row-memo first))+60
(assert-equal "Accounts Receivable" (qbo-row-account first))+61
(assert-equal 1500.0 (qbo-row-debit first))+62
(assert-false (qbo-row-credit first)))))+63
+64
(test "skips blank and non-data rows"+65
(let* ((csv (string-append+66
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+67
"\n"+68
"Total,,,,,,\"5,000.00\",\"5,000.00\"\n"+69
"01/15/2026,Invoice,1001,Test,Memo,Account,100.00,\n"))+70
(rows (parse-qbo-csv csv)))+71
(assert-equal 1 (length rows))+72
(assert-equal "01/15/2026" (qbo-row-date (car rows)))))+73
+74
(test "handles parenthesized negative amounts"+75
(let* ((csv (string-append+76
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+77
"01/15/2026,Credit Memo,CM-1,Customer,Refund,Revenue,(500.00),\n"))+78
(rows (parse-qbo-csv csv)))+79
(assert-equal 1 (length rows))+80
(assert-equal -500.0 (qbo-row-debit (car rows))))))+81
+82
;; ========== Row Grouping ==========+83
+84
(test-group "group-qbo-rows"+85
(test "groups rows with same date+type+num"+86
(let* ((rows (list+87
(qbo-row date: "01/15/2026" type: "Invoice" num: "1001"+88
account: "AR" debit: 1500.0)+89
(qbo-row date: "01/15/2026" type: "Invoice" num: "1001"+90
account: "Income" credit: 1500.0)+91
(qbo-row date: "01/20/2026" type: "Payment" num: "1001"+92
account: "Cash" debit: 1500.0)))+93
(groups (group-qbo-rows rows)))+94
(assert-equal 2 (length groups))+95
(assert-equal 2 (length (car groups)))+96
(assert-equal 1 (length (cadr groups)))))+97
+98
(test "groups consecutive rows with empty num"+99
(let* ((rows (list+100
(qbo-row date: "02/03/2026" type: "Expense" num: ""+101
name: "Office Depot" account: "Supplies" debit: 85.42)+102
(qbo-row date: "02/03/2026" type: "Expense" num: ""+103
name: "Office Depot" account: "Checking" credit: 85.42)+104
(qbo-row date: "02/14/2026" type: "Expense" num: ""+105
name: "Amazon" account: "Supplies" debit: 29.99)))+106
(groups (group-qbo-rows rows)))+107
;; First two share date+type+num (all empty), third differs by date+108
(assert-equal 2 (length groups))+109
(assert-equal 2 (length (car groups)))+110
(assert-equal 1 (length (cadr groups)))))+111
+112
(test "handles multi-posting journal entries"+113
(let* ((rows (list+114
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+115
account: "Depreciation" debit: 500.0)+116
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+117
account: "Supplies" credit: 200.0)+118
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+119
account: "Utilities" credit: 300.0)))+120
(groups (group-qbo-rows rows)))+121
(assert-equal 1 (length groups))+122
(assert-equal 3 (length (car groups)))))+123
+124
(test "returns empty list for empty input"+125
(assert-equal '() (group-qbo-rows '()))))+126
+127
;; ========== Account Mapping ==========+128
+129
(test-group "map-account-name"+130
(test "exact match"+131
(assert-equal "assets:bank:checking"+132
(map-account-name default-account-map "Checking")))+133
+134
(test "prefix match appends suffix"+135
(assert-equal "liabilities:credit-card:business-visa"+136
(map-account-name default-account-map "Credit Card Business Visa")))+137
+138
(test "case insensitive matching"+139
(assert-equal "assets:bank:checking"+140
(map-account-name default-account-map "checking")))+141
+142
(test "unmatched accounts are sanitized"+143
(assert-equal "my-custom-account"+144
(map-account-name default-account-map "My Custom Account")))+145
+146
(test "custom map overrides defaults"+147
(let ((custom (qbo-account-map+148
rules: '(("Revenue" . "income:revenue")+149
("Checking" . "assets:bank:primary")))))+150
(assert-equal "assets:bank:primary"+151
(map-account-name custom "Checking"))+152
(assert-equal "income:revenue"+153
(map-account-name custom "Revenue")))))+154
+155
;; ========== Date Conversion ==========+156
+157
(test-group "date conversion"+158
(test "converts MM/DD/YYYY to YYYY-MM-DD"+159
(let* ((rows (list (qbo-row date: "01/15/2026" type: "Invoice" num: "1001"+160
name: "Test" account: "Checking" debit: 100.0)))+161
(groups (group-qbo-rows rows))+162
(txn (qbo-group->transaction (car groups) default-account-map)))+163
(assert-equal "2026-01-15" (journal-transaction-date txn))))+164
+165
(test "pads single-digit month and day"+166
(let* ((rows (list (qbo-row date: "3/5/2026" type: "Expense" num: ""+167
name: "Test" account: "Checking" debit: 10.0)))+168
(groups (group-qbo-rows rows))+169
(txn (qbo-group->transaction (car groups) default-account-map)))+170
(assert-equal "2026-03-05" (journal-transaction-date txn)))))+171
+172
;; ========== Transaction Conversion ==========+173
+174
(test-group "qbo-group->transaction"+175
(test "converts a two-posting group to journal transaction"+176
(let* ((rows (list+177
(qbo-row date: "01/20/2026" type: "Payment" num: "1001"+178
name: "Acme Corp" memo: "Payment for Invoice"+179
account: "Checking" debit: 1500.0)+180
(qbo-row date: "01/20/2026" type: "Payment" num: "1001"+181
name: "Acme Corp" memo: "Payment for Invoice"+182
account: "Accounts Receivable" credit: 1500.0)))+183
(groups (group-qbo-rows rows))+184
(txn (qbo-group->transaction (car groups) default-account-map)))+185
;; Transaction header+186
(assert-equal "2026-01-20" (journal-transaction-date txn))+187
(assert-equal "!" (journal-transaction-status txn))+188
(assert-equal "1001" (journal-transaction-code txn))+189
(assert-equal "Acme Corp" (journal-transaction-description txn))+190
;; Ref tag+191
(assert-equal "qbo:Payment:1001"+192
(cdr (assoc "ref" (journal-transaction-tags txn))))+193
;; Postings+194
(let ((postings (journal-transaction-postings txn)))+195
(assert-equal 2 (length postings))+196
;; First posting: checking debit+197
(let ((p1 (car postings)))+198
(assert-equal "assets:bank:checking" (journal-posting-account p1))+199
(assert-equal 1500.0 (journal-amount-quantity (journal-posting-amount p1)))+200
(assert-equal "$" (journal-amount-commodity (journal-posting-amount p1))))+201
;; Second posting: AR credit+202
(let ((p2 (cadr postings)))+203
(assert-equal "assets:accounts-receivable" (journal-posting-account p2))+204
(assert-equal -1500.0 (journal-amount-quantity (journal-posting-amount p2)))))))+205
+206
(test "converts three-posting journal entry"+207
(let* ((rows (list+208
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+209
memo: "Q1 depreciation" account: "Depreciation" debit: 500.0)+210
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+211
memo: "Q1 depreciation" account: "Office Supplies" credit: 200.0)+212
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+213
memo: "Q1 depreciation" account: "Utilities" credit: 300.0)))+214
(groups (group-qbo-rows rows))+215
(txn (qbo-group->transaction (car groups) default-account-map)))+216
(assert-equal 3 (length (journal-transaction-postings txn)))+217
;; Debit posting+218
(assert-equal 500.0+219
(journal-amount-quantity+220
(journal-posting-amount (car (journal-transaction-postings txn)))))+221
;; Credit postings+222
(assert-equal -200.0+223
(journal-amount-quantity+224
(journal-posting-amount (cadr (journal-transaction-postings txn)))))+225
(assert-equal -300.0+226
(journal-amount-quantity+227
(journal-posting-amount (list-ref (journal-transaction-postings txn) 2))))))+228
+229
(test "uses memo as description when name is empty"+230
(let* ((rows (list+231
(qbo-row date: "03/01/2026" type: "Journal Entry" num: "JE-001"+232
name: "" memo: "Q1 depreciation"+233
account: "Depreciation" debit: 500.0)))+234
(groups (group-qbo-rows rows))+235
(txn (qbo-group->transaction (car groups) default-account-map)))+236
(assert-equal "Q1 depreciation" (journal-transaction-description txn))))+237
+238
(test "marks transactions as pending (!) not cleared"+239
(let* ((rows (list+240
(qbo-row date: "01/15/2026" type: "Invoice" num: "1001"+241
name: "Test" account: "Checking" debit: 100.0)))+242
(groups (group-qbo-rows rows))+243
(txn (qbo-group->transaction (car groups) default-account-map)))+244
(assert-equal "!" (journal-transaction-status txn)))))+245
+246
;; ========== Full Conversion Pipeline ==========+247
+248
(test-group "convert-qbo-journal"+249
(test "converts CSV text to journal transactions"+250
(let* ((csv (string-append+251
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+252
"01/15/2026,Invoice,1001,Acme Corp,Web design,Accounts Receivable,\"1,500.00\",\n"+253
"01/15/2026,Invoice,1001,Acme Corp,Web design,Services,,\"1,500.00\"\n"+254
"02/03/2026,Expense,,Office Depot,Supplies,Office Supplies,85.42,\n"+255
"02/03/2026,Expense,,Office Depot,Supplies,Checking,,85.42\n"))+256
(txns (convert-qbo-journal csv)))+257
(assert-equal 2 (length txns))+258
;; First transaction: Invoice+259
(let ((inv (car txns)))+260
(assert-equal "2026-01-15" (journal-transaction-date inv))+261
(assert-equal "Acme Corp" (journal-transaction-description inv))+262
(assert-equal 2 (length (journal-transaction-postings inv))))+263
;; Second transaction: Expense+264
(let ((exp (cadr txns)))+265
(assert-equal "2026-02-03" (journal-transaction-date exp))+266
(assert-equal "Office Depot" (journal-transaction-description exp)))))+267
+268
(test "accepts custom account map"+269
(let* ((csv (string-append+270
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+271
"01/15/2026,Invoice,1001,Client,Work,Revenue,\"1,000.00\",\n"))+272
(custom (qbo-account-map rules: '(("Revenue" . "income:consulting"))))+273
(txns (convert-qbo-journal csv custom)))+274
(assert-equal 1 (length txns))+275
(assert-equal "income:consulting"+276
(journal-posting-account+277
(car (journal-transaction-postings (car txns))))))))+278
+279
;; ========== Journal Output Format ==========+280
+281
(test-group "formatted output"+282
(test "produces valid journal syntax"+283
(let* ((csv (string-append+284
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+285
"01/20/2026,Payment,1001,Acme Corp,Invoice payment,Checking,\"1,500.00\",\n"+286
"01/20/2026,Payment,1001,Acme Corp,Invoice payment,Accounts Receivable,,\"1,500.00\"\n"))+287
(txns (convert-qbo-journal csv))+288
(formatted (format-transaction (car txns))))+289
;; Should contain the date+290
(assert-true (string-contains? formatted "2026-01-20"))+291
;; Should contain pending status+292
(assert-true (string-contains? formatted "!"))+293
;; Should contain the code+294
(assert-true (string-contains? formatted "1001"))+295
;; Should contain description+296
(assert-true (string-contains? formatted "Acme Corp"))+297
;; Should contain mapped accounts+298
(assert-true (string-contains? formatted "assets:bank:checking"))+299
(assert-true (string-contains? formatted "assets:accounts-receivable"))+300
;; Should contain ref tag+301
(assert-true (string-contains? formatted "ref: qbo:Payment:1001"))))+302
+303
(test "multi-transaction output separates with blank lines"+304
(let* ((csv (string-append+305
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+306
"01/15/2026,Invoice,1001,Client A,Work,Checking,500.00,\n"+307
"01/15/2026,Invoice,1001,Client A,Work,Services,,500.00\n"+308
"02/01/2026,Invoice,1002,Client B,Work,Checking,300.00,\n"+309
"02/01/2026,Invoice,1002,Client B,Work,Services,,300.00\n"))+310
(txns (convert-qbo-journal csv))+311
(formatted (format-transactions txns)))+312
(assert-true (string-contains? formatted "\n\n")))))+313
+314
;; ========== Deduplication Integration ==========+315
+316
(test-group "deduplication with QBO transactions"+317
(test "deduplicates by ref tag"+318
(let* ((existing (list+319
(journal-transaction+320
date: "2026-01-15"+321
status: "!"+322
code: "1001"+323
description: "Acme Corp"+324
tags: (list (cons "ref" "qbo:Invoice:1001"))+325
postings: (list+326
(journal-posting account: "assets:accounts-receivable"+327
amount: (journal-amount quantity: 1500 commodity: "$"))+328
(journal-posting account: "income:services"+329
amount: (journal-amount quantity: -1500 commodity: "$"))))))+330
(csv (string-append+331
"Date,Transaction Type,Num,Name,Memo/Description,Account,Debit,Credit\n"+332
"01/15/2026,Invoice,1001,Acme Corp,Web design,Accounts Receivable,\"1,500.00\",\n"+333
"01/15/2026,Invoice,1001,Acme Corp,Web design,Services,,\"1,500.00\"\n"+334
"02/03/2026,Expense,,Office Depot,Supplies,Office Supplies,85.42,\n"+335
"02/03/2026,Expense,,Office Depot,Supplies,Checking,,85.42\n"))+336
(new-txns (convert-qbo-journal csv))+337
(unique (deduplicate-transactions new-txns existing)))+338
;; Only the expense should remain (invoice already exists)+339
(assert-equal 1 (length unique))+340
(assert-equal "Office Depot" (journal-transaction-description (car unique))))))+341
+342
(run-tests)