AtlatestRepositorytally
1
;;; Test suite for (tally qbo)2
;;;3
;;; Tests CSV parsing, row grouping, account mapping, and journal4
;;; conversion using fixture data.6
(import (sigil test)7
(sigil string)8
(sigil struct)9
(sigil fs)10
(ledger)11
(tally qbo))13
;; ========== CSV Parsing ==========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))))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))))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))))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))))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)))))44
;; ========== QBO CSV Parsing ==========46
(test-group "parse-qbo-csv"47
(test "parses CSV text into qbo-row records"48
(let* ((csv (string-append49
"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)))))64
(test "skips blank and non-data rows"65
(let* ((csv (string-append66
"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)))))74
(test "handles parenthesized negative amounts"75
(let* ((csv (string-append76
"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))))))82
;; ========== Row Grouping ==========84
(test-group "group-qbo-rows"85
(test "groups rows with same date+type+num"86
(let* ((rows (list87
(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)))))98
(test "groups consecutive rows with empty num"99
(let* ((rows (list100
(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 date108
(assert-equal 2 (length groups))109
(assert-equal 2 (length (car groups)))110
(assert-equal 1 (length (cadr groups)))))112
(test "handles multi-posting journal entries"113
(let* ((rows (list114
(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)))))124
(test "returns empty list for empty input"125
(assert-equal '() (group-qbo-rows '()))))127
;; ========== Account Mapping ==========129
(test-group "map-account-name"130
(test "exact match"131
(assert-equal "assets:bank:checking"132
(map-account-name default-account-map "Checking")))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")))138
(test "case insensitive matching"139
(assert-equal "assets:bank:checking"140
(map-account-name default-account-map "checking")))142
(test "unmatched accounts are sanitized"143
(assert-equal "my-custom-account"144
(map-account-name default-account-map "My Custom Account")))146
(test "custom map overrides defaults"147
(let ((custom (qbo-account-map148
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")))))155
;; ========== Date Conversion ==========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))))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)))))172
;; ========== Transaction Conversion ==========174
(test-group "qbo-group->transaction"175
(test "converts a two-posting group to journal transaction"176
(let* ((rows (list177
(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 header186
(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 tag191
(assert-equal "qbo:Payment:1001"192
(cdr (assoc "ref" (journal-transaction-tags txn))))193
;; Postings194
(let ((postings (journal-transaction-postings txn)))195
(assert-equal 2 (length postings))196
;; First posting: checking debit197
(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 credit202
(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)))))))206
(test "converts three-posting journal entry"207
(let* ((rows (list208
(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 posting218
(assert-equal 500.0219
(journal-amount-quantity220
(journal-posting-amount (car (journal-transaction-postings txn)))))221
;; Credit postings222
(assert-equal -200.0223
(journal-amount-quantity224
(journal-posting-amount (cadr (journal-transaction-postings txn)))))225
(assert-equal -300.0226
(journal-amount-quantity227
(journal-posting-amount (list-ref (journal-transaction-postings txn) 2))))))229
(test "uses memo as description when name is empty"230
(let* ((rows (list231
(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))))238
(test "marks transactions as pending (!) not cleared"239
(let* ((rows (list240
(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)))))246
;; ========== Full Conversion Pipeline ==========248
(test-group "convert-qbo-journal"249
(test "converts CSV text to journal transactions"250
(let* ((csv (string-append251
"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: Invoice259
(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: Expense264
(let ((exp (cadr txns)))265
(assert-equal "2026-02-03" (journal-transaction-date exp))266
(assert-equal "Office Depot" (journal-transaction-description exp)))))268
(test "accepts custom account map"269
(let* ((csv (string-append270
"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-account277
(car (journal-transaction-postings (car txns))))))))279
;; ========== Journal Output Format ==========281
(test-group "formatted output"282
(test "produces valid journal syntax"283
(let* ((csv (string-append284
"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 date290
(assert-true (string-contains? formatted "2026-01-20"))291
;; Should contain pending status292
(assert-true (string-contains? formatted "!"))293
;; Should contain the code294
(assert-true (string-contains? formatted "1001"))295
;; Should contain description296
(assert-true (string-contains? formatted "Acme Corp"))297
;; Should contain mapped accounts298
(assert-true (string-contains? formatted "assets:bank:checking"))299
(assert-true (string-contains? formatted "assets:accounts-receivable"))300
;; Should contain ref tag301
(assert-true (string-contains? formatted "ref: qbo:Payment:1001"))))303
(test "multi-transaction output separates with blank lines"304
(let* ((csv (string-append305
"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")))))314
;; ========== Deduplication Integration ==========316
(test-group "deduplication with QBO transactions"317
(test "deduplicates by ref tag"318
(let* ((existing (list319
(journal-transaction320
date: "2026-01-15"321
status: "!"322
code: "1001"323
description: "Acme Corp"324
tags: (list (cons "ref" "qbo:Invoice:1001"))325
postings: (list326
(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-append331
"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))))))342
(run-tests)