From 1d68a0acf98bc509397698f6d468bb2ae5c0c7d3 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 14:34:34 -0500 Subject: [PATCH 01/27] feat(gc-sync1): toward category sync from GnuCash menu item --- packages/gc-sync1/Makefile | 4 ++++ packages/gc-sync1/README.md | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 packages/gc-sync1/Makefile create mode 100644 packages/gc-sync1/README.md diff --git a/packages/gc-sync1/Makefile b/packages/gc-sync1/Makefile new file mode 100644 index 0000000..d403606 --- /dev/null +++ b/packages/gc-sync1/Makefile @@ -0,0 +1,4 @@ +SQLITE3 = sqlite3 + +simple-checkbook.sql: simple-checkbook.gnucash + $(SQLITE3) $< .dump >$@ || (rm $@; exit 1) diff --git a/packages/gc-sync1/README.md b/packages/gc-sync1/README.md new file mode 100644 index 0000000..04b276a --- /dev/null +++ b/packages/gc-sync1/README.md @@ -0,0 +1,3 @@ +# gc-sync1 - Categorize Splits from GnuCash UI + +see also https://github.com/dckc/finquick/issues/51 From 822a962be18cedd954f64af8b0537ad80e9d2cac Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 14:35:31 -0500 Subject: [PATCH 02/27] test(gc-sync1): simple-checkbook fixture --- packages/gc-sync1/.gitignore | 1 + packages/gc-sync1/simple-checkbook.sql | 76 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/gc-sync1/.gitignore create mode 100644 packages/gc-sync1/simple-checkbook.sql diff --git a/packages/gc-sync1/.gitignore b/packages/gc-sync1/.gitignore new file mode 100644 index 0000000..b17b8c6 --- /dev/null +++ b/packages/gc-sync1/.gitignore @@ -0,0 +1 @@ +simple-checkbook.gnucash diff --git a/packages/gc-sync1/simple-checkbook.sql b/packages/gc-sync1/simple-checkbook.sql new file mode 100644 index 0000000..56f4bc4 --- /dev/null +++ b/packages/gc-sync1/simple-checkbook.sql @@ -0,0 +1,76 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE gnclock ( Hostname varchar(255), PID int ); +INSERT INTO gnclock VALUES('bldbox',773715); +CREATE TABLE versions(table_name text(50) PRIMARY KEY NOT NULL, table_version integer NOT NULL); +INSERT INTO versions VALUES('Gnucash',4000008); +INSERT INTO versions VALUES('Gnucash-Resave',19920); +INSERT INTO versions VALUES('books',1); +INSERT INTO versions VALUES('commodities',1); +INSERT INTO versions VALUES('accounts',1); +INSERT INTO versions VALUES('budgets',1); +INSERT INTO versions VALUES('budget_amounts',1); +INSERT INTO versions VALUES('prices',3); +INSERT INTO versions VALUES('transactions',4); +INSERT INTO versions VALUES('splits',5); +INSERT INTO versions VALUES('slots',4); +INSERT INTO versions VALUES('recurrences',2); +INSERT INTO versions VALUES('schedxactions',1); +INSERT INTO versions VALUES('lots',2); +INSERT INTO versions VALUES('billterms',2); +INSERT INTO versions VALUES('customers',2); +INSERT INTO versions VALUES('employees',2); +INSERT INTO versions VALUES('entries',4); +INSERT INTO versions VALUES('invoices',4); +INSERT INTO versions VALUES('jobs',1); +INSERT INTO versions VALUES('orders',1); +INSERT INTO versions VALUES('taxtables',2); +INSERT INTO versions VALUES('taxtable_entries',3); +INSERT INTO versions VALUES('vendors',1); +CREATE TABLE books(guid text(32) PRIMARY KEY NOT NULL, root_account_guid text(32) NOT NULL, root_template_guid text(32) NOT NULL); +INSERT INTO books VALUES('c732dff174484eeeb1b3baabe05edd16','aba44add598e462f9dc10937be1bb72b','8ed484f757d24820a1283c2b74e3c127'); +CREATE TABLE commodities(guid text(32) PRIMARY KEY NOT NULL, namespace text(2048) NOT NULL, mnemonic text(2048) NOT NULL, fullname text(2048), cusip text(2048), fraction integer NOT NULL, quote_flag integer NOT NULL, quote_source text(2048), quote_tz text(2048)); +INSERT INTO commodities VALUES('07eec0750dcc461687208f33ceae5022','CURRENCY','USD','US Dollar','840',100,1,'currency',''); +CREATE TABLE accounts(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, account_type text(2048) NOT NULL, commodity_guid text(32), commodity_scu integer NOT NULL, non_std_scu integer NOT NULL, parent_guid text(32), code text(2048), description text(2048), hidden integer, placeholder integer); +INSERT INTO accounts VALUES('aba44add598e462f9dc10937be1bb72b','Root Account','ROOT','07eec0750dcc461687208f33ceae5022',100,0,NULL,'','',0,0); +INSERT INTO accounts VALUES('6d4ed96d03d74632959fe0b04bec7280','Assets','ASSET','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Assets',0,1); +INSERT INTO accounts VALUES('5add42c3affe4b27b55fabe1892b3968','Current Assets','ASSET','07eec0750dcc461687208f33ceae5022',100,0,'6d4ed96d03d74632959fe0b04bec7280','','Current Assets',0,1); +INSERT INTO accounts VALUES('c54efcecd14444d3b332537e19bbf773','Checking Account','BANK','07eec0750dcc461687208f33ceae5022',100,0,'5add42c3affe4b27b55fabe1892b3968','','Checking Account',0,0); +INSERT INTO accounts VALUES('2a9693d8210648bbad01adef84b260d9','Income','INCOME','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Income',0,0); +INSERT INTO accounts VALUES('5aa9099417ee48218b1677b2d2420426','Expenses','EXPENSE','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Expenses',0,0); +INSERT INTO accounts VALUES('fb4f394e1bf74b77942a645f9d9bc18e','Equity','EQUITY','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Equity',0,1); +INSERT INTO accounts VALUES('ea375d587dcb4116b517728deefaa1e8','Opening Balances','EQUITY','07eec0750dcc461687208f33ceae5022',100,0,'fb4f394e1bf74b77942a645f9d9bc18e','','Opening Balances',0,0); +INSERT INTO accounts VALUES('8ed484f757d24820a1283c2b74e3c127','Template Root','ROOT',NULL,0,0,NULL,'','',0,0); +CREATE TABLE budgets(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, description text(2048), num_periods integer NOT NULL); +CREATE TABLE budget_amounts(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, budget_guid text(32) NOT NULL, account_guid text(32) NOT NULL, period_num integer NOT NULL, amount_num bigint NOT NULL, amount_denom bigint NOT NULL); +CREATE TABLE prices(guid text(32) PRIMARY KEY NOT NULL, commodity_guid text(32) NOT NULL, currency_guid text(32) NOT NULL, date text(19) NOT NULL, source text(2048), type text(2048), value_num bigint NOT NULL, value_denom bigint NOT NULL); +CREATE TABLE transactions(guid text(32) PRIMARY KEY NOT NULL, currency_guid text(32) NOT NULL, num text(2048) NOT NULL, post_date text(19), enter_date text(19), description text(2048)); +CREATE TABLE splits(guid text(32) PRIMARY KEY NOT NULL, tx_guid text(32) NOT NULL, account_guid text(32) NOT NULL, memo text(2048) NOT NULL, action text(2048) NOT NULL, reconcile_state text(1) NOT NULL, reconcile_date text(19), value_num bigint NOT NULL, value_denom bigint NOT NULL, quantity_num bigint NOT NULL, quantity_denom bigint NOT NULL, lot_guid text(32)); +CREATE TABLE slots(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, obj_guid text(32) NOT NULL, name text(4096) NOT NULL, slot_type integer NOT NULL, int64_val bigint, string_val text(4096), double_val float8, timespec_val text(19), guid_val text(32), numeric_val_num bigint, numeric_val_denom bigint, gdate_val text(8)); +INSERT INTO slots VALUES(1,'c732dff174484eeeb1b3baabe05edd16','counter_formats',9,0,NULL,NULL,'1970-01-01 00:00:00','a3f89a60ab4e449495bd7d5f981721c8',0,1,NULL); +INSERT INTO slots VALUES(2,'c732dff174484eeeb1b3baabe05edd16','options',9,0,NULL,NULL,'1970-01-01 00:00:00','9518d982bf434d0ea9051f542c6d4f2f',0,1,NULL); +INSERT INTO slots VALUES(3,'9518d982bf434d0ea9051f542c6d4f2f','options/Budgeting',9,0,NULL,NULL,'1970-01-01 00:00:00','71affa53d47444f38e5802b295edd5ba',0,1,NULL); +INSERT INTO slots VALUES(4,'6d4ed96d03d74632959fe0b04bec7280','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(5,'5add42c3affe4b27b55fabe1892b3968','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(6,'fb4f394e1bf74b77942a645f9d9bc18e','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(7,'ea375d587dcb4116b517728deefaa1e8','equity-type',4,0,'opening-balance',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +CREATE TABLE recurrences(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, obj_guid text(32) NOT NULL, recurrence_mult integer NOT NULL, recurrence_period_type text(2048) NOT NULL, recurrence_period_start text(8) NOT NULL, recurrence_weekend_adjust text(2048) NOT NULL); +CREATE TABLE schedxactions(guid text(32) PRIMARY KEY NOT NULL, name text(2048), enabled integer NOT NULL, start_date text(8), end_date text(8), last_occur text(8), num_occur integer NOT NULL, rem_occur integer NOT NULL, auto_create integer NOT NULL, auto_notify integer NOT NULL, adv_creation integer NOT NULL, adv_notify integer NOT NULL, instance_count integer NOT NULL, template_act_guid text(32) NOT NULL); +CREATE TABLE lots(guid text(32) PRIMARY KEY NOT NULL, account_guid text(32), is_closed integer NOT NULL); +CREATE TABLE billterms(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, description text(2048) NOT NULL, refcount integer NOT NULL, invisible integer NOT NULL, parent text(32), type text(2048) NOT NULL, duedays integer, discountdays integer, discount_num bigint, discount_denom bigint, cutoff integer); +CREATE TABLE customers(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, active integer NOT NULL, discount_num bigint NOT NULL, discount_denom bigint NOT NULL, credit_num bigint NOT NULL, credit_denom bigint NOT NULL, currency text(32) NOT NULL, tax_override integer NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256), shipaddr_name text(1024), shipaddr_addr1 text(1024), shipaddr_addr2 text(1024), shipaddr_addr3 text(1024), shipaddr_addr4 text(1024), shipaddr_phone text(128), shipaddr_fax text(128), shipaddr_email text(256), terms text(32), tax_included integer, taxtable text(32)); +CREATE TABLE employees(guid text(32) PRIMARY KEY NOT NULL, username text(2048) NOT NULL, id text(2048) NOT NULL, language text(2048) NOT NULL, acl text(2048) NOT NULL, active integer NOT NULL, currency text(32) NOT NULL, ccard_guid text(32), workday_num bigint NOT NULL, workday_denom bigint NOT NULL, rate_num bigint NOT NULL, rate_denom bigint NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256)); +CREATE TABLE entries(guid text(32) PRIMARY KEY NOT NULL, date text(19) NOT NULL, date_entered text(19), description text(2048), action text(2048), notes text(2048), quantity_num bigint, quantity_denom bigint, i_acct text(32), i_price_num bigint, i_price_denom bigint, i_discount_num bigint, i_discount_denom bigint, invoice text(32), i_disc_type text(2048), i_disc_how text(2048), i_taxable integer, i_taxincluded integer, i_taxtable text(32), b_acct text(32), b_price_num bigint, b_price_denom bigint, bill text(32), b_taxable integer, b_taxincluded integer, b_taxtable text(32), b_paytype integer, billable integer, billto_type integer, billto_guid text(32), order_guid text(32)); +CREATE TABLE invoices(guid text(32) PRIMARY KEY NOT NULL, id text(2048) NOT NULL, date_opened text(19), date_posted text(19), notes text(2048) NOT NULL, active integer NOT NULL, currency text(32) NOT NULL, owner_type integer, owner_guid text(32), terms text(32), billing_id text(2048), post_txn text(32), post_lot text(32), post_acc text(32), billto_type integer, billto_guid text(32), charge_amt_num bigint, charge_amt_denom bigint); +CREATE TABLE jobs(guid text(32) PRIMARY KEY NOT NULL, id text(2048) NOT NULL, name text(2048) NOT NULL, reference text(2048) NOT NULL, active integer NOT NULL, owner_type integer, owner_guid text(32)); +CREATE TABLE orders(guid text(32) PRIMARY KEY NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, reference text(2048) NOT NULL, active integer NOT NULL, date_opened text(19) NOT NULL, date_closed text(19) NOT NULL, owner_type integer NOT NULL, owner_guid text(32) NOT NULL); +CREATE TABLE taxtables(guid text(32) PRIMARY KEY NOT NULL, name text(50) NOT NULL, refcount bigint NOT NULL, invisible integer NOT NULL, parent text(32)); +CREATE TABLE taxtable_entries(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, taxtable text(32) NOT NULL, account text(32) NOT NULL, amount_num bigint NOT NULL, amount_denom bigint NOT NULL, type integer NOT NULL); +CREATE TABLE vendors(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, currency text(32) NOT NULL, active integer NOT NULL, tax_override integer NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256), terms text(32), tax_inc text(2048), tax_table text(32)); +DELETE FROM sqlite_sequence; +INSERT INTO sqlite_sequence VALUES('slots',7); +CREATE INDEX tx_post_date_index ON transactions(post_date); +CREATE INDEX splits_tx_guid_index ON splits(tx_guid); +CREATE INDEX splits_account_guid_index ON splits(account_guid); +CREATE INDEX slots_guid_index ON slots(obj_guid); +COMMIT; From 97fb736073d3b852ac4d249e43a961f50e6b6c24 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 17:31:09 -0500 Subject: [PATCH 03/27] feat(gnc-sync1): find uncategorized transactions --- packages/gc-sync1/.gitignore | 1 + packages/gc-sync1/Makefile | 17 +++++++ packages/gc-sync1/sync-uncat.scm | 83 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 packages/gc-sync1/sync-uncat.scm diff --git a/packages/gc-sync1/.gitignore b/packages/gc-sync1/.gitignore index b17b8c6..8052860 100644 --- a/packages/gc-sync1/.gitignore +++ b/packages/gc-sync1/.gitignore @@ -1 +1,2 @@ simple-checkbook.gnucash +*.gnucash.*.log diff --git a/packages/gc-sync1/Makefile b/packages/gc-sync1/Makefile index d403606..fe403b1 100644 --- a/packages/gc-sync1/Makefile +++ b/packages/gc-sync1/Makefile @@ -1,4 +1,21 @@ SQLITE3 = sqlite3 +# determined with +# gnucash --debug --extra +# per https://wiki.gnucash.org/wiki/Python_shell +PYINIT=/usr/share/gnucash/python/init.py +# +# tested with +# https://packages.ubuntu.com/jammy/gnucash 1:4.8-1build2 + +CONFIG=$(HOME)/.config/gnucash/config-user.scm +# (gnc-build-userdata-path "...") +USERDATA=$(HOME)/.local/share/gnucash/ + simple-checkbook.sql: simple-checkbook.gnucash $(SQLITE3) $< .dump >$@ || (rm $@; exit 1) + +# dead end. no way to add menu item to run python code +pyshell-enable: + echo change if False: to if True: + EDITOR=gedit sudoedit $(PYINIT) \ No newline at end of file diff --git a/packages/gc-sync1/sync-uncat.scm b/packages/gc-sync1/sync-uncat.scm new file mode 100644 index 0000000..bc89b0d --- /dev/null +++ b/packages/gc-sync1/sync-uncat.scm @@ -0,0 +1,83 @@ +;; sync-uncat.scm +;; Synchronize uncategorized splits from external data + +;; Debugging +;; gnucash --debug --log gnc.scm=debug +;; per https://wiki.gnucash.org/wiki/Custom_Reports#Debugging_your_report + +(use-modules (gnucash engine)) ; For ACCT-TYPE-INCOME +(use-modules (gnucash app-utils)) ; For gnc:message, if it works for logging. +(use-modules (gnucash core-utils)) ; For N_ +(use-modules (gnucash utilities)) ; for gnc:msg etc. +(use-modules (gnucash gnome-utils)) ; for gnc:gui-msg etc. +(use-modules (gnucash report report-utilities)) ; for gnc:strify + +;; ;; Define a logging function that tries GnuCash's internal message system first +;; (define (log-message msg) +;; (display (string-append "SCRIPT LOG: " msg "\n")) +;; (force-output)) + +;; ;; This is the function that defines the action for your menu item +;; (define (run-get-book-info window) ; The lambda in make-menu-item receives the window object +;; (let ((book (gnc-get-current-book))) +;; (if book +;; (let ((filename (gnc-book-get-filename book))) ; <<< TRY THIS: gnc-book-get-filename +;; (if filename +;; (log-message (string-append "Current GnuCash file: " filename)) +;; (log-message "Current GnuCash file name could not be retrieved (maybe not saved?)."))) +;; (log-message "No GnuCash file is currently open.")))) + +(format #t "ACCT-TYPE-INCOME: ~a\n" ACCT-TYPE-INCOME) + +(define (show-acct acct) + `((name . ,(xaccAccountGetName acct)) + (GUID . ,(gncAccountGetGUID acct)) + )) + +(define (show-split split) + `((date . ,(xaccTransGetDate (xaccSplitGetParent split))) + (GUID . ,(gncSplitGetGUID split)) + )) + + +;; TODO: lookup by code? +(define* (uncat-splits root #:key (name "Imbalance-USD")) + (let* ((acct-uncat (gnc-account-lookup-by-name root name)) + (splits-uncat (xaccAccountGetSplitList acct-uncat))) + `((account . ,(gnc:strify acct-uncat)) + (splits . ,(map gnc:strify splits-uncat))) +)) + +(define (run-sync-uncat-tx window) + (display "hi from run-sync-uncat-tx\n") + (gnc:gui-msg "XXX unused?" "about to get uncat splits\n") + (let* ( + (book (gnc-get-current-book)) + (root (gnc-get-current-root-account)) + (accts (gnc-account-get-children-sorted root)) + (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) + ) + (display (format #f "book: ~a\n" book)) + (display (format #f "root: ~a\n" root)) + (display (format #f "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts))) + (display (format #f "uncat: ~a\n" (show-acct acct-uncat))) + (display (format #f "uncat splits: ~a\n" (uncat-splits root))) + ) + (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") + ) + +;; these don't seem to work. +(gnc:debug "debug\n") +(gnc:msg "message\n") +(gnc:warn "warn\n") + +;; ;; Register the menu item +(gnc-add-scm-extension + (gnc:make-menu-item + (N_ "Sync Uncat Tx") ; Name that appears in the menu + "0d9fe0a6-de1b-4de5-a27c-1919cd9fe484" + (N_ "Synchronize uncategorized splits from external data") ; Tooltip/Description + (list (N_ "Tools")) ; Path: "Tools" menu + (lambda (window) ; The action function when clicked + (run-sync-uncat-tx window)) ; Call your defined action function + )) \ No newline at end of file From e73866b469499187528c8604d40f2051b326dbe1 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 17:37:41 -0500 Subject: [PATCH 04/27] feat(sync-uncat): assign category to 1 split --- packages/gc-sync1/sync-uncat.scm | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/gc-sync1/sync-uncat.scm b/packages/gc-sync1/sync-uncat.scm index bc89b0d..cb582b1 100644 --- a/packages/gc-sync1/sync-uncat.scm +++ b/packages/gc-sync1/sync-uncat.scm @@ -48,6 +48,13 @@ (splits . ,(map gnc:strify splits-uncat))) )) +(define (sync1 root) + (let* ((uncat (gnc-account-lookup-by-name root "Imbalance-USD")) + (cat (gnc-account-lookup-by-name root "Discretionary")) + (haystack (xaccAccountGetSplitList uncat)) + (needle (first haystack))) + (xaccSplitSetAccount needle cat))) + (define (run-sync-uncat-tx window) (display "hi from run-sync-uncat-tx\n") (gnc:gui-msg "XXX unused?" "about to get uncat splits\n") @@ -62,9 +69,10 @@ (display (format #f "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts))) (display (format #f "uncat: ~a\n" (show-acct acct-uncat))) (display (format #f "uncat splits: ~a\n" (uncat-splits root))) - ) - (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") - ) + (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") + (sync1 root) + (gnc:gui-msg "XXX unused?" "didn't crash: sync1\n") + )) ;; these don't seem to work. (gnc:debug "debug\n") From 5fa3129aa4ab50e6b6423c0c7ee6a716df3cce50 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 18:35:40 -0500 Subject: [PATCH 05/27] chore(sync-uncat): fetch via HTTP during menu action --- packages/gc-sync1/sync-uncat.scm | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/gc-sync1/sync-uncat.scm b/packages/gc-sync1/sync-uncat.scm index cb582b1..ec9b872 100644 --- a/packages/gc-sync1/sync-uncat.scm +++ b/packages/gc-sync1/sync-uncat.scm @@ -11,6 +11,8 @@ (use-modules (gnucash utilities)) ; for gnc:msg etc. (use-modules (gnucash gnome-utils)) ; for gnc:gui-msg etc. (use-modules (gnucash report report-utilities)) ; for gnc:strify +(use-modules (srfi srfi-71)) ; for let* +(use-modules (web client)) ; for http-request ;; ;; Define a logging function that tries GnuCash's internal message system first ;; (define (log-message msg) @@ -39,6 +41,17 @@ (GUID . ,(gncSplitGetGUID split)) )) +(define cups-home "http://localhost:631") ; HTTP server that happens to be handy + +;; XXX ambient net access. TODO: inject +(define* (fetch-stuff #:key (url cups-home)) + (let* ((response body (http-request url))) + ;; TODO: error handling + ;; (if (not (eql (response-code response) 200)) + ;; (error (response-reason-phrase response))) + body + ) +) ;; TODO: lookup by code? (define* (uncat-splits root #:key (name "Imbalance-USD")) @@ -70,6 +83,8 @@ (display (format #f "uncat: ~a\n" (show-acct acct-uncat))) (display (format #f "uncat splits: ~a\n" (uncat-splits root))) (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") + (let ((body (fetch-stuff))) + (gnc:gui-msg "XXX" (format #f "HTTP GET: length: ~a" (string-length body)))) (sync1 root) (gnc:gui-msg "XXX unused?" "didn't crash: sync1\n") )) From 861df418c0febc8ab301791d07efbd5cf704ad91 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 18:39:02 -0500 Subject: [PATCH 06/27] chore: update sql dump --- packages/gc-sync1/simple-checkbook.sql | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/gc-sync1/simple-checkbook.sql b/packages/gc-sync1/simple-checkbook.sql index 56f4bc4..aa480a0 100644 --- a/packages/gc-sync1/simple-checkbook.sql +++ b/packages/gc-sync1/simple-checkbook.sql @@ -1,7 +1,7 @@ PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE gnclock ( Hostname varchar(255), PID int ); -INSERT INTO gnclock VALUES('bldbox',773715); +INSERT INTO gnclock VALUES('bldbox',1033466); CREATE TABLE versions(table_name text(50) PRIMARY KEY NOT NULL, table_version integer NOT NULL); INSERT INTO versions VALUES('Gnucash',4000008); INSERT INTO versions VALUES('Gnucash-Resave',19920); @@ -41,19 +41,37 @@ INSERT INTO accounts VALUES('5aa9099417ee48218b1677b2d2420426','Expenses','EXPEN INSERT INTO accounts VALUES('fb4f394e1bf74b77942a645f9d9bc18e','Equity','EQUITY','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Equity',0,1); INSERT INTO accounts VALUES('ea375d587dcb4116b517728deefaa1e8','Opening Balances','EQUITY','07eec0750dcc461687208f33ceae5022',100,0,'fb4f394e1bf74b77942a645f9d9bc18e','','Opening Balances',0,0); INSERT INTO accounts VALUES('8ed484f757d24820a1283c2b74e3c127','Template Root','ROOT',NULL,0,0,NULL,'','',0,0); +INSERT INTO accounts VALUES('fc27e99a2a294f4c80013ebbdc7f448d','Discretionary','EXPENSE','07eec0750dcc461687208f33ceae5022',100,0,'5aa9099417ee48218b1677b2d2420426','','',0,0); +INSERT INTO accounts VALUES('ca3e6da80599482b9c87f226649f2956','Imbalance-USD','BANK','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','',0,0); CREATE TABLE budgets(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, description text(2048), num_periods integer NOT NULL); CREATE TABLE budget_amounts(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, budget_guid text(32) NOT NULL, account_guid text(32) NOT NULL, period_num integer NOT NULL, amount_num bigint NOT NULL, amount_denom bigint NOT NULL); CREATE TABLE prices(guid text(32) PRIMARY KEY NOT NULL, commodity_guid text(32) NOT NULL, currency_guid text(32) NOT NULL, date text(19) NOT NULL, source text(2048), type text(2048), value_num bigint NOT NULL, value_denom bigint NOT NULL); CREATE TABLE transactions(guid text(32) PRIMARY KEY NOT NULL, currency_guid text(32) NOT NULL, num text(2048) NOT NULL, post_date text(19), enter_date text(19), description text(2048)); +INSERT INTO transactions VALUES('0540b8bd52f746e19f7c0fb8ed105804','07eec0750dcc461687208f33ceae5022','','2025-05-26 10:59:00','2025-05-26 19:59:49','Dime Store'); +INSERT INTO transactions VALUES('dbc07db225df4958810a21b3b07cb04d','07eec0750dcc461687208f33ceae5022','','2025-05-01 10:59:00','2025-05-26 20:00:44','Factory'); +INSERT INTO transactions VALUES('78e0f219767a4f538b228165f8944efd','07eec0750dcc461687208f33ceae5022','','2025-05-26 10:59:00','2025-05-26 21:02:24','Burger Place'); CREATE TABLE splits(guid text(32) PRIMARY KEY NOT NULL, tx_guid text(32) NOT NULL, account_guid text(32) NOT NULL, memo text(2048) NOT NULL, action text(2048) NOT NULL, reconcile_state text(1) NOT NULL, reconcile_date text(19), value_num bigint NOT NULL, value_denom bigint NOT NULL, quantity_num bigint NOT NULL, quantity_denom bigint NOT NULL, lot_guid text(32)); +INSERT INTO splits VALUES('763369e1d87241a9a78b585cd11b4d36','0540b8bd52f746e19f7c0fb8ed105804','5aa9099417ee48218b1677b2d2420426','','','n','1970-01-01 00:00:00',1000,100,1000,100,NULL); +INSERT INTO splits VALUES('5b54138912c54a4d80d2a805649dfeb3','0540b8bd52f746e19f7c0fb8ed105804','c54efcecd14444d3b332537e19bbf773','','','n','1970-01-01 00:00:00',-1000,100,-1000,100,NULL); +INSERT INTO splits VALUES('6578409bddbd4184a52130886f13a1c6','dbc07db225df4958810a21b3b07cb04d','c54efcecd14444d3b332537e19bbf773','','','n','1970-01-01 00:00:00',10000,100,10000,100,NULL); +INSERT INTO splits VALUES('2900d6c6962c41e1ab680e8a241825fc','dbc07db225df4958810a21b3b07cb04d','2a9693d8210648bbad01adef84b260d9','','','n','1970-01-01 00:00:00',-10000,100,-10000,100,NULL); +INSERT INTO splits VALUES('ef2050a1fc8d4a5580dcb322181f6e13','78e0f219767a4f538b228165f8944efd','c54efcecd14444d3b332537e19bbf773','','','n','1970-01-01 00:00:00',-1200,100,-1200,100,NULL); +INSERT INTO splits VALUES('39cedc6b8c654eea851f03d410957498','78e0f219767a4f538b228165f8944efd','fc27e99a2a294f4c80013ebbdc7f448d','','','n','1970-01-01 00:00:00',1200,100,1200,100,NULL); CREATE TABLE slots(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, obj_guid text(32) NOT NULL, name text(4096) NOT NULL, slot_type integer NOT NULL, int64_val bigint, string_val text(4096), double_val float8, timespec_val text(19), guid_val text(32), numeric_val_num bigint, numeric_val_denom bigint, gdate_val text(8)); -INSERT INTO slots VALUES(1,'c732dff174484eeeb1b3baabe05edd16','counter_formats',9,0,NULL,NULL,'1970-01-01 00:00:00','a3f89a60ab4e449495bd7d5f981721c8',0,1,NULL); -INSERT INTO slots VALUES(2,'c732dff174484eeeb1b3baabe05edd16','options',9,0,NULL,NULL,'1970-01-01 00:00:00','9518d982bf434d0ea9051f542c6d4f2f',0,1,NULL); -INSERT INTO slots VALUES(3,'9518d982bf434d0ea9051f542c6d4f2f','options/Budgeting',9,0,NULL,NULL,'1970-01-01 00:00:00','71affa53d47444f38e5802b295edd5ba',0,1,NULL); INSERT INTO slots VALUES(4,'6d4ed96d03d74632959fe0b04bec7280','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); INSERT INTO slots VALUES(5,'5add42c3affe4b27b55fabe1892b3968','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); INSERT INTO slots VALUES(6,'fb4f394e1bf74b77942a645f9d9bc18e','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); INSERT INTO slots VALUES(7,'ea375d587dcb4116b517728deefaa1e8','equity-type',4,0,'opening-balance',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(13,'c732dff174484eeeb1b3baabe05edd16','counter_formats',9,0,NULL,NULL,'1970-01-01 00:00:00','3435a86e684b463c8cb73606a277f7b3',0,1,NULL); +INSERT INTO slots VALUES(14,'c732dff174484eeeb1b3baabe05edd16','features',9,0,NULL,NULL,'1970-01-01 00:00:00','1770b480378a4f368ff2e086caf49edf',0,1,NULL); +INSERT INTO slots VALUES(15,'1770b480378a4f368ff2e086caf49edf','features/ISO-8601 formatted date strings in SQLite3 databases.',4,0,'Use ISO formatted date-time strings in SQLite3 databases (requires at least GnuCash 2.6.20)',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(16,'1770b480378a4f368ff2e086caf49edf','features/Register sort and filter settings stored in .gcm file',4,0,'Store the register sort and filter settings in .gcm metadata file (requires at least GnuCash 3.3)',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(17,'c732dff174484eeeb1b3baabe05edd16','options',9,0,NULL,NULL,'1970-01-01 00:00:00','1a5b524359dc4cf48a7cbd9b91426854',0,1,NULL); +INSERT INTO slots VALUES(18,'1a5b524359dc4cf48a7cbd9b91426854','options/Budgeting',9,0,NULL,NULL,'1970-01-01 00:00:00','38c32ecd53e9485f92db2ec7de359bbc',0,1,NULL); +INSERT INTO slots VALUES(19,'c732dff174484eeeb1b3baabe05edd16','remove-color-not-set-slots',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(20,'0540b8bd52f746e19f7c0fb8ed105804','date-posted',10,0,NULL,NULL,'1970-01-01 00:00:00',NULL,0,1,'20250526'); +INSERT INTO slots VALUES(21,'dbc07db225df4958810a21b3b07cb04d','date-posted',10,0,NULL,NULL,'1970-01-01 00:00:00',NULL,0,1,'20250501'); +INSERT INTO slots VALUES(22,'78e0f219767a4f538b228165f8944efd','date-posted',10,0,NULL,NULL,'1970-01-01 00:00:00',NULL,0,1,'20250526'); CREATE TABLE recurrences(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, obj_guid text(32) NOT NULL, recurrence_mult integer NOT NULL, recurrence_period_type text(2048) NOT NULL, recurrence_period_start text(8) NOT NULL, recurrence_weekend_adjust text(2048) NOT NULL); CREATE TABLE schedxactions(guid text(32) PRIMARY KEY NOT NULL, name text(2048), enabled integer NOT NULL, start_date text(8), end_date text(8), last_occur text(8), num_occur integer NOT NULL, rem_occur integer NOT NULL, auto_create integer NOT NULL, auto_notify integer NOT NULL, adv_creation integer NOT NULL, adv_notify integer NOT NULL, instance_count integer NOT NULL, template_act_guid text(32) NOT NULL); CREATE TABLE lots(guid text(32) PRIMARY KEY NOT NULL, account_guid text(32), is_closed integer NOT NULL); @@ -68,7 +86,7 @@ CREATE TABLE taxtables(guid text(32) PRIMARY KEY NOT NULL, name text(50) NOT NUL CREATE TABLE taxtable_entries(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, taxtable text(32) NOT NULL, account text(32) NOT NULL, amount_num bigint NOT NULL, amount_denom bigint NOT NULL, type integer NOT NULL); CREATE TABLE vendors(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, currency text(32) NOT NULL, active integer NOT NULL, tax_override integer NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256), terms text(32), tax_inc text(2048), tax_table text(32)); DELETE FROM sqlite_sequence; -INSERT INTO sqlite_sequence VALUES('slots',7); +INSERT INTO sqlite_sequence VALUES('slots',22); CREATE INDEX tx_post_date_index ON transactions(post_date); CREATE INDEX splits_tx_guid_index ON splits(tx_guid); CREATE INDEX splits_account_guid_index ON splits(account_guid); From 7edc85f8b279be4085e2ab3657f2c8a76ddbeee1 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 29 May 2025 00:08:07 -0500 Subject: [PATCH 07/27] feat(sync26): HTTP handlers for sync --- packages/sync26/syncSvc.js | 271 +++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 packages/sync26/syncSvc.js diff --git a/packages/sync26/syncSvc.js b/packages/sync26/syncSvc.js new file mode 100644 index 0000000..818fe2b --- /dev/null +++ b/packages/sync26/syncSvc.js @@ -0,0 +1,271 @@ +/** + * syncSvc -- HTTP handlers for synchronizing + * GnuCash transactions with a Google Sheets in SheetSync format. + * + * @see {doPost} for pushing uncategorized transactions + * @see {doGet} for pulling categories + */ +// @ts-check + +// --- Configuration --- +const config = { + sheetUncat: { + name: 'GnuCash_Uncat', + hd: ['date', 'description', 'amount', 'tx_guid', 'uploaded'], + }, + /** Name of your SheetSync Transactions sheet */ + sheetTx: 'Transactions (2)', + // see also getProperty(...) below + idProp: 'GOOGLE_SHEET_ID', +}; + +/** + * Expected format for the POST request body for `doPost`. + */ +const EXPECTED_POST_BODY_FORMAT = { + transactions: [ + { + guid: 'deadbeef...', + date: '2020-01-02', + description: 'payee etc.', + amount: 123.45, + }, + ], +}; + +/** + * @typedef {typeof EXPECTED_POST_BODY_FORMAT} UncategorizedTransaction + */ + +/** + * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet + * @param {string[]} hd + * @param {(string|number|Date|boolean)[][]} rows + * @param {number} [hdRow] + * @param {number} [detailRow] + */ +function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { + sheet.getRange(hdRow, 1, 1, hd.length).setValues([hd]); + sheet.getRange(detailRow, 1, rows.length, hd.length).setValues(rows); +} + +/** + * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet + * @param {string} name + */ +function getColumnNumber(sheet, name) { + const [hd] = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues(); + const colIx = hd.indexOf(name); + assert(colIx >= 0, `no such column: ${name}`); + return colIx + 1; +} + +/** + * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet + * @param {string} name + */ +function getColumn(sheet, name) { + const column = getColumnNumber(sheet, name); + const rows = sheet + .getRange(2, column, sheet.getLastRow(), column) + .getValues(); + return rows.map(([val]) => val); +} + +/** + * @param {unknown} requestBody + * @param {object} io + * @param {GoogleAppsScript.Spreadsheet.Sheet} io.sheetUncat + * @param {() => number} io.now + * @param {(...args: any[]) => void} [io.log] + */ +const saveUploaded = ( + requestBody, + { sheetUncat, now, log = (...args) => {} }, +) => { + assert(requestBody); + assert.typeof(requestBody, 'object'); + /** @type {UncategorizedTransaction['transactions']} */ + const uncategorizedTransactions = requestBody.transactions; + + if ( + !uncategorizedTransactions || + !Array.isArray(uncategorizedTransactions) || + uncategorizedTransactions.length === 0 + ) { + throw Error('No transactions provided in the request body.'); + } + + log( + `Received ${uncategorizedTransactions.length} uncategorized transactions from GnuCash.`, + ); + + const ts = new Date(now()); // Timestamp for when it was received + + const rows = uncategorizedTransactions.map(tx => [ + tx.date, + tx.description, + tx.amount, + tx.guid, + ts, + ]); + + setRange(sheetUncat, config.sheetUncat.hd, rows); + log(`Saved`, rows.length, `transactions to sheet`, sheetUncat.getName()); + return rows.length; +}; + +/** + * Handles HTTP POST requests. + * This function receives uncategorized transactions from the GnuCash extension. + * It ONLY saves the transactions to the temporary sheet. + * + * Expected request body (JSON): {@link UncategorizedTransaction} + * + * @param {Pick} e The event object from the POST request. + * @returns {GoogleAppsScript.Content.TextOutput} A JSON response indicating success or failure. + */ +function doPost(e, io = {}) { + try { + const { + now = Date.now, + props = PropertiesService.getScriptProperties(), + sheetId = NonNullish( + props.getProperty(config.idProp), + 'GOOGLE_SHEET_ID not set in Project Properties.', + ), + spreadsheet = SpreadsheetApp.openById(sheetId), + sheetUncat = spreadsheet.getSheetByName(config.sheetUncat.name), + } = io; + + // Parse the JSON payload from the request body + const requestBody = JSON.parse(e.postData.contents); + assert(sheetUncat); + const saved = saveUploaded(requestBody, { sheetUncat, now }); + return createJsonResponse({ code: 200, saved }); + } catch (error) { + console.error('Error in doPost: ', error); + return createJsonResponse({ code: 500, message: `Internal error.` }); + } +} + +function doPostTest() { + const contents = JSON.stringify(EXPECTED_POST_BODY_FORMAT); + doPost({ + postData: { contents, length: contents.length, name: '?', type: '?/?' }, + }); +} + +/** + * Handles HTTP GET requests. + * This function returns a list of transaction GUIDs and their categories + * from the Main Transactions sheet. + * + * @param {GoogleAppsScript.Events.DoGet} _e The event object from the GET request. + * @param {object} [io] + * @param {GoogleAppsScript.Properties.Properties} [io.props] + * @param {string} [io.idProp] + * @param {string} [io.sheetId] + * @param {GoogleAppsScript.Spreadsheet.Spreadsheet} [io.spreadsheet] + * @param {GoogleAppsScript.Spreadsheet.Sheet} [io.sheetUncat] + * @param {GoogleAppsScript.Spreadsheet.Sheet} [io.sheetTx] + * @returns {GoogleAppsScript.Content.TextOutput} A JSON response containing categories. + */ +function doGet(_e, io = {}) { + try { + const { + props = PropertiesService.getScriptProperties(), + sheetId = NonNullish( + props.getProperty(config.idProp), + `no such project property: ${config.idProp}`, + ), + spreadsheet = SpreadsheetApp.openById(sheetId), + sheetUncat = NonNullish( + spreadsheet.getSheetByName(config.sheetUncat.name), + `no such sheet: ${config.sheetUncat.name}`, + ), + sheetTx = NonNullish( + spreadsheet.getSheetByName(config.sheetTx), + `no such sheet: ${config.sheetTx}`, + ), + } = io; + + const uncatGuids = getColumn(sheetUncat, 'tx_guid').filter(g => g > ''); + const txData = sheetTx.getDataRange().getValues(); + const txHd = txData[0]; + const txRows = txData.slice(1); + const guidIx = getColumnNumber(sheetTx, 'tx_guid') - 1; + const wanted = txRows + .filter(row => uncatGuids.includes(row[guidIx])) + .map(([v0, dt, ...rest]) => [v0, dt.toISOString(), ...rest]); + const result = { hd: txHd, rows: wanted }; + + console.log({ + uncatGuids: uncatGuids.length, + txRows: txRows.length, + wanted: wanted.length, + }); + return createJsonResponse({ code: 200, ...result }); + } catch (error) { + console.error('Error in doGet: ', error); + return createJsonResponse({ code: 500, message: `Internal error` }); + } +} + +/** + * Helper function to create a JSON response. + * @param {object} data The data to include in the JSON response. + * @returns {GoogleAppsScript.Content.TextOutput} A ContentService TextOutput object. + */ +function createJsonResponse(data) { + const output = ContentService.createTextOutput(JSON.stringify(data)); + output.setMimeType(ContentService.MimeType.JSON); + return output; +} + +const typeofToType = /** @type {const} */ ({ + undefined: undefined, + boolean: true, + number: 123, + string: 'abc', + object: /** @type {Record|null} */ ({}), +}); + +/** + * @type {((cond: unknown, msg?: string) => asserts cond) & { + * typeof: (specimen: unknown, ty: K, msg?: string) => asserts specimen is typeof typeofToType[K] + * }} + */ +const assert = (() => { + const a0 = (cond, msg) => { + if (!cond) throw Error(msg || 'condition failed'); + }; + /** + * + * @template {keyof typeof typeofToType} K + * @param {unknown} specimen + * @param {K} ty + * @param {string} [msg] + * @returns {asserts specimen is typeof typeofToType[K]} + */ + const ty = (specimen, ty, msg) => { + const actual = typeof specimen; + if (actual !== ty) throw Error(msg || `expected ${ty}; got ${actual}`); + }; + const { assign, freeze } = Object; + const it = assign(a0, { typeof: ty }); + freeze(it); + return it; +})(); + +/** + * @template T + * @param {T | null | undefined} val + * @param {string} [optDetails] + * @returns {T} + */ +const NonNullish = (val, optDetails = `unexpected ${val}`) => { + assert(val, optDetails); + return val; +}; From de87e395bbfd1dc69f739bb4dfcf4a8b8072c424 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 29 May 2025 01:05:04 -0500 Subject: [PATCH 08/27] test(gc-sync1): run code with guile, outside gnucash --- packages/gc-sync1/Makefile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/gc-sync1/Makefile b/packages/gc-sync1/Makefile index fe403b1..32412e6 100644 --- a/packages/gc-sync1/Makefile +++ b/packages/gc-sync1/Makefile @@ -6,6 +6,9 @@ PYINIT=/usr/share/gnucash/python/init.py # # tested with # https://packages.ubuntu.com/jammy/gnucash 1:4.8-1build2 +SITE_GUILE=/usr/share/guile/site/3.0/ +GUILE=guile +SO_LIBS=/usr/lib/x86_64-linux-gnu/gnucash:/usr/lib/x86_64-linux-gnu/gnucash/gnucash:/usr/lib/x86_64-linux-gnu CONFIG=$(HOME)/.config/gnucash/config-user.scm # (gnc-build-userdata-path "...") @@ -18,4 +21,10 @@ simple-checkbook.sql: simple-checkbook.gnucash # dead end. no way to add menu item to run python code pyshell-enable: echo change if False: to if True: - EDITOR=gedit sudoedit $(PYINIT) \ No newline at end of file + EDITOR=gedit sudoedit $(PYINIT) + +repl: + LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) + +test: sync-uncat.scm + LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) $< \ No newline at end of file From 635f1819dcad6778fcb15c3dd0ec664e9e267626 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 00:06:42 -0500 Subject: [PATCH 09/27] feat: format uncategorized splits in JSON --- packages/gc-sync1/sync-uncat-lib.scm | 138 +++++++++++++++++++++++++++ packages/gc-sync1/sync-uncat.scm | 121 +++++------------------ 2 files changed, 162 insertions(+), 97 deletions(-) create mode 100644 packages/gc-sync1/sync-uncat-lib.scm diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm new file mode 100644 index 0000000..53eadd5 --- /dev/null +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -0,0 +1,138 @@ +;; sync-uncat.scm +;; Synchronize uncategorized splits from external data + +;; Debugging +;; gnucash --debug --log gnc.scm=debug +;; per https://wiki.gnucash.org/wiki/Custom_Reports#Debugging_your_report + +(define-module (sync-uncat-lib)) + +(use-modules (srfi srfi-71)) ; for let* +(use-modules (srfi srfi-1)) ; for remove +(use-modules (gnucash engine)) ; For ACCT-TYPE-INCOME +(use-modules (gnucash app-utils)) ; For gnc:message, if it works for logging. +(use-modules (gnucash core-utils)) ; For N_ +(use-modules (gnucash utilities)) ; for gnc:msg etc. +(use-modules (gnucash gnome-utils)) ; for gnc:gui-msg etc. +(use-modules (gnucash report report-utilities)) ; for gnc:strify +(use-modules (web client)) ; for http-request +(use-modules (gnucash json)) + +(export run-push-tx-ids) +(export run-pull-categories) + +;; ;; Define a logging function that tries GnuCash's internal message system first +;; (define (log-message msg) +;; (display (string-append "SCRIPT LOG: " msg "\n")) +;; (force-output)) + +;; ;; This is the function that defines the action for your menu item +;; (define (run-get-book-info window) ; The lambda in make-menu-item receives the window object +;; (let ((book (gnc-get-current-book))) +;; (if book +;; (let ((filename (gnc-book-get-filename book))) ; <<< TRY THIS: gnc-book-get-filename +;; (if filename +;; (log-message (string-append "Current GnuCash file: " filename)) +;; (log-message "Current GnuCash file name could not be retrieved (maybe not saved?)."))) +;; (log-message "No GnuCash file is currently open.")))) + +(format #t "ACCT-TYPE-INCOME: ~a~%" ACCT-TYPE-INCOME) + + +(define (valid-transaction? obj) + (and (list? obj) + (every pair? obj) + (let ((guid (assoc "guid" obj)) + (date (assoc "date" obj)) + (description (assoc "description" obj)) + (amount (assoc "amount" obj))) + (and guid (string? (cdr guid)) + date (string? (cdr date)) + description (string? (cdr description)) + amount (number? (cdr amount)))))) + +(define (split-record split) + (let* ((parent (xaccSplitGetParent split)) + (acct (xaccSplitGetAccount split)) + (amount (xaccSplitGetAmount split)) + (time64 (xaccTransGetDate parent)) + (datetime-str (gnc-print-time64 time64 "%Y-%m-%d %H:%M:%S")) + ) + ;; JSON builder object + `(("date" . ,datetime-str) + ("description" . ,(xaccTransGetDescription parent)) + ;; exact->inexact is a little scary + ;; how about #(,(numerator amount) "/" ,(denominator amount))? + ("amount" . ,amount) + ("guid" . ,(gncTransGetGUID parent)) + ))) + +(define cups-home "http://localhost:631") ; HTTP server that happens to be handy + +;; XXX ambient net access. TODO: inject +(define* (fetch-stuff #:key (url cups-home)) + (let* ((response body (http-request url))) + ;; TODO: error handling + ;; (if (not (eql (response-code response) 200)) + ;; (error (response-reason-phrase response))) + body + ) + ) + +;; TODO: lookup by code? +(define* (uncat-splits root #:key (name "Imbalance-USD")) + (xaccAccountGetSplitList (gnc-account-lookup-by-name root name))) + +(define (sync1 root) + (let* ((uncat (gnc-account-lookup-by-name root "Imbalance-USD")) + (cat (gnc-account-lookup-by-name root "Discretionary")) + (haystack (xaccAccountGetSplitList uncat))) + (unless (null? haystack) + (let ((needle (car haystack))) + (xaccSplitSetAccount needle cat))))) + +(define (run-push-tx-ids window) + (display "hi from run-push-tx-ids\n") +;; (gnc:gui-msg "XXX unused?" "about to get uncat splits\n") + (let* ( + (book (gnc-get-current-book)) + (root (gnc-get-current-root-account)) + (accts (gnc-account-get-children-sorted root)) + (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) + ) + (display (format #f "book: ~a\n" book)) + (display (format #f "root: ~a\n" root)) + (display (format #f "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts))) + (display (format #f "uncat: ~a\n" (gnc:strify acct-uncat))) + (display (format #f "uncat splits: ~a\n" + (map gnc:strify (uncat-splits root)))) + (let* ((records (map split-record (uncat-splits root))) + (invalid-records (remove valid-transaction? records)) + (data (list->vector records))) + (unless (null? invalid-records) + (error "bad records:" invalid-records)) + (display (format #f "uncat records sexp: ~%~a~%" records)) + (display (format #f "uncat records JSON: ~%~a~%" (scm->json-string data #:pretty #t)))) + (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") + (format #f "HTTP post in JSON") + )) + + +(define (run-pull-categories window) + (display "hi from run-pull-categories\n") + (let* ( + (root (gnc-get-current-root-account)) + (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) + ) + (display (format #f "root: ~a\n" root)) + (display (format #f "uncat: ~a\n" (gnc:strify acct-uncat))) + (let ((body (fetch-stuff))) + (gnc:gui-msg "XXX" (format #f "HTTP GET: length: ~a" (string-length body)))) + (sync1 root) + (gnc:gui-msg "XXX unused?" "didn't crash: sync1\n") + )) + +;; these don't seem to work. +(gnc:debug "debug\n") +(gnc:msg "message\n") +(gnc:warn "warn\n") diff --git a/packages/gc-sync1/sync-uncat.scm b/packages/gc-sync1/sync-uncat.scm index ec9b872..f6e8157 100644 --- a/packages/gc-sync1/sync-uncat.scm +++ b/packages/gc-sync1/sync-uncat.scm @@ -1,106 +1,33 @@ ;; sync-uncat.scm ;; Synchronize uncategorized splits from external data -;; Debugging -;; gnucash --debug --log gnc.scm=debug -;; per https://wiki.gnucash.org/wiki/Custom_Reports#Debugging_your_report +(load (gnc-build-userdata-path "sync-uncat-lib.scm")) +(use-modules (sync-uncat-lib)) -(use-modules (gnucash engine)) ; For ACCT-TYPE-INCOME -(use-modules (gnucash app-utils)) ; For gnc:message, if it works for logging. -(use-modules (gnucash core-utils)) ; For N_ -(use-modules (gnucash utilities)) ; for gnc:msg etc. -(use-modules (gnucash gnome-utils)) ; for gnc:gui-msg etc. -(use-modules (gnucash report report-utilities)) ; for gnc:strify -(use-modules (srfi srfi-71)) ; for let* -(use-modules (web client)) ; for http-request +(define (handle-exn key . args) + ;; Handle or log the error + (format (current-error-port) "Error in extension: ~a ~a~%" key args) + ;; Optionally show GUI feedback + (gnc:gui-msg "?" (format #f "Extension Failed: ~a" args))) -;; ;; Define a logging function that tries GnuCash's internal message system first -;; (define (log-message msg) -;; (display (string-append "SCRIPT LOG: " msg "\n")) -;; (force-output)) - -;; ;; This is the function that defines the action for your menu item -;; (define (run-get-book-info window) ; The lambda in make-menu-item receives the window object -;; (let ((book (gnc-get-current-book))) -;; (if book -;; (let ((filename (gnc-book-get-filename book))) ; <<< TRY THIS: gnc-book-get-filename -;; (if filename -;; (log-message (string-append "Current GnuCash file: " filename)) -;; (log-message "Current GnuCash file name could not be retrieved (maybe not saved?)."))) -;; (log-message "No GnuCash file is currently open.")))) - -(format #t "ACCT-TYPE-INCOME: ~a\n" ACCT-TYPE-INCOME) - -(define (show-acct acct) - `((name . ,(xaccAccountGetName acct)) - (GUID . ,(gncAccountGetGUID acct)) - )) - -(define (show-split split) - `((date . ,(xaccTransGetDate (xaccSplitGetParent split))) - (GUID . ,(gncSplitGetGUID split)) - )) - -(define cups-home "http://localhost:631") ; HTTP server that happens to be handy - -;; XXX ambient net access. TODO: inject -(define* (fetch-stuff #:key (url cups-home)) - (let* ((response body (http-request url))) - ;; TODO: error handling - ;; (if (not (eql (response-code response) 200)) - ;; (error (response-reason-phrase response))) - body - ) -) - -;; TODO: lookup by code? -(define* (uncat-splits root #:key (name "Imbalance-USD")) - (let* ((acct-uncat (gnc-account-lookup-by-name root name)) - (splits-uncat (xaccAccountGetSplitList acct-uncat))) - `((account . ,(gnc:strify acct-uncat)) - (splits . ,(map gnc:strify splits-uncat))) -)) - -(define (sync1 root) - (let* ((uncat (gnc-account-lookup-by-name root "Imbalance-USD")) - (cat (gnc-account-lookup-by-name root "Discretionary")) - (haystack (xaccAccountGetSplitList uncat)) - (needle (first haystack))) - (xaccSplitSetAccount needle cat))) - -(define (run-sync-uncat-tx window) - (display "hi from run-sync-uncat-tx\n") - (gnc:gui-msg "XXX unused?" "about to get uncat splits\n") - (let* ( - (book (gnc-get-current-book)) - (root (gnc-get-current-root-account)) - (accts (gnc-account-get-children-sorted root)) - (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) - ) - (display (format #f "book: ~a\n" book)) - (display (format #f "root: ~a\n" root)) - (display (format #f "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts))) - (display (format #f "uncat: ~a\n" (show-acct acct-uncat))) - (display (format #f "uncat splits: ~a\n" (uncat-splits root))) - (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") - (let ((body (fetch-stuff))) - (gnc:gui-msg "XXX" (format #f "HTTP GET: length: ~a" (string-length body)))) - (sync1 root) - (gnc:gui-msg "XXX unused?" "didn't crash: sync1\n") - )) - -;; these don't seem to work. -(gnc:debug "debug\n") -(gnc:msg "message\n") -(gnc:warn "warn\n") - -;; ;; Register the menu item +;; Register the menu items (gnc-add-scm-extension (gnc:make-menu-item - (N_ "Sync Uncat Tx") ; Name that appears in the menu + (N_ "Push Uncat Txs") ; Name that appears in the menu "0d9fe0a6-de1b-4de5-a27c-1919cd9fe484" - (N_ "Synchronize uncategorized splits from external data") ; Tooltip/Description + (N_ "Push uncategorized splits to SheetSync") ; Tooltip/Description (list (N_ "Tools")) ; Path: "Tools" menu - (lambda (window) ; The action function when clicked - (run-sync-uncat-tx window)) ; Call your defined action function - )) \ No newline at end of file + (lambda (window) + (catch #t (lambda () (run-push-tx-ids window)) handle-exn)) + )) + +(gnc-add-scm-extension + (gnc:make-menu-item + (N_ "Pull Categories") + "c397d0f6-7876-4efd-a2b2-8eabd51ab63a" + (N_ "Pull categories from SheetSync") + (list (N_ "Tools")) + (lambda (window) + (catch #t (lambda () (run-pull-categories window)) handle-exn)) + )) + From 874f6c9833c4e6a6c7cb7a63769befed39acb45a Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 00:43:56 -0500 Subject: [PATCH 10/27] docs(gc-sync1): trace imports to modules - tweak make test to compile -lib - move debugging note to Makefile --- packages/gc-sync1/Makefile | 9 ++++-- packages/gc-sync1/sync-uncat-lib.scm | 48 +++++++++------------------- 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/packages/gc-sync1/Makefile b/packages/gc-sync1/Makefile index 32412e6..f850ea2 100644 --- a/packages/gc-sync1/Makefile +++ b/packages/gc-sync1/Makefile @@ -26,5 +26,10 @@ pyshell-enable: repl: LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) -test: sync-uncat.scm - LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) $< \ No newline at end of file +test: sync-uncat-lib.scm + LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) $< + +# Debugging +# gnucash --debug --log gnc.scm=debug +# per https://wiki.gnucash.org/wiki/Custom_Reports#Debugging_your_report + diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm index 53eadd5..1ff2ede 100644 --- a/packages/gc-sync1/sync-uncat-lib.scm +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -1,44 +1,26 @@ ;; sync-uncat.scm ;; Synchronize uncategorized splits from external data -;; Debugging -;; gnucash --debug --log gnc.scm=debug -;; per https://wiki.gnucash.org/wiki/Custom_Reports#Debugging_your_report - (define-module (sync-uncat-lib)) - -(use-modules (srfi srfi-71)) ; for let* -(use-modules (srfi srfi-1)) ; for remove -(use-modules (gnucash engine)) ; For ACCT-TYPE-INCOME -(use-modules (gnucash app-utils)) ; For gnc:message, if it works for logging. -(use-modules (gnucash core-utils)) ; For N_ -(use-modules (gnucash utilities)) ; for gnc:msg etc. -(use-modules (gnucash gnome-utils)) ; for gnc:gui-msg etc. -(use-modules (gnucash report report-utilities)) ; for gnc:strify -(use-modules (web client)) ; for http-request -(use-modules (gnucash json)) +(use-modules ((srfi srfi-71) #:select (let*))) +(use-modules ((srfi srfi-1) #:select (remove every))) +(use-modules ((gnucash core-utils) #:select (N_))) +(use-modules ((gnucash utilities) #:select + (gnc:msg gnc:debug gnc:warn gnc:gui-msg))) +(use-modules ((gnucash report report-utilities) #:select (gnc:strify))) +(use-modules ((web client) #:select (http-request))) +(use-modules ((gnucash json builder) #:select (scm->json-string))) +;; #:select doesn't work for xaccTransGetDate etc. +;; maybe due to FFI magic in (gnucash engine)? +(use-modules (gnucash engine)) ;; #select ( +;; xaccSplitSetAccount xaccSplitGet* xaccTransGet* xaccAccountGet* +;; gnc-account-lookup-by-name gnc-account-get-children-sorted gnc-print-time64) +;; (load-and-reexport (sw_app_utils) ...) +(use-modules (gnucash app-utils)) ;; #:select (gnc-get-current-book gnc-get-current-root-account) (export run-push-tx-ids) (export run-pull-categories) -;; ;; Define a logging function that tries GnuCash's internal message system first -;; (define (log-message msg) -;; (display (string-append "SCRIPT LOG: " msg "\n")) -;; (force-output)) - -;; ;; This is the function that defines the action for your menu item -;; (define (run-get-book-info window) ; The lambda in make-menu-item receives the window object -;; (let ((book (gnc-get-current-book))) -;; (if book -;; (let ((filename (gnc-book-get-filename book))) ; <<< TRY THIS: gnc-book-get-filename -;; (if filename -;; (log-message (string-append "Current GnuCash file: " filename)) -;; (log-message "Current GnuCash file name could not be retrieved (maybe not saved?)."))) -;; (log-message "No GnuCash file is currently open.")))) - -(format #t "ACCT-TYPE-INCOME: ~a~%" ACCT-TYPE-INCOME) - - (define (valid-transaction? obj) (and (list? obj) (every pair? obj) From a1ab30332cc4890eebf461922c18d1f670ca482e Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 01:04:50 -0500 Subject: [PATCH 11/27] refactor(gc-sync1): misc code clean-up --- packages/gc-sync1/sync-uncat-lib.scm | 73 +++++++++++++--------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm index 1ff2ede..3cd7750 100644 --- a/packages/gc-sync1/sync-uncat-lib.scm +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -21,6 +21,8 @@ (export run-push-tx-ids) (export run-pull-categories) +;; Uncategorized transaction data structure for use with scm->json +;; See also EXPECTED_POST_BODY_FORMAT in syncSvc.js (define (valid-transaction? obj) (and (list? obj) (every pair? obj) @@ -49,10 +51,38 @@ ("guid" . ,(gncTransGetGUID parent)) ))) +(define* (uncat-splits root #:key (name "Imbalance-USD")) + (xaccAccountGetSplitList (gnc-account-lookup-by-name root name))) + +(define (explore-gnucash-api) + (let* ((book (gnc-get-current-book)) + (root (gnc-get-current-root-account)) + (accts (gnc-account-get-children-sorted root)) + (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD"))) + ;; (format #t ...) would likely obviate (display ...) + (format #t "book: ~a\n" book) + (format #t "root: ~a\n" root) + (format #t "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts)) + (format #t "uncat: ~a\n" (gnc:strify acct-uncat)))) + +(define (run-push-tx-ids window) + ;; (display "hi from run-push-tx-ids\n") + (explore-gnucash-api) + (let* ((root (gnc-get-current-root-account)) + (records (map split-record (uncat-splits root))) + (invalid-records (remove valid-transaction? records)) + (data (list->vector records))) + (unless (null? invalid-records) + (error "bad records:" invalid-records)) + (format #t "uncat records sexp: ~%~a~%" records) + (format #t "uncat records JSON: ~%~a~%" (scm->json-string data #:pretty #t)) + (gnc:gui-msg "?" (format #f "found ~a uncategorized transactions; TODO: POST" (length records))))) + + (define cups-home "http://localhost:631") ; HTTP server that happens to be handy -;; XXX ambient net access. TODO: inject (define* (fetch-stuff #:key (url cups-home)) + (gnc:warn "XXX ambient net access. TODO: inject\n") (let* ((response body (http-request url))) ;; TODO: error handling ;; (if (not (eql (response-code response) 200)) @@ -61,10 +91,7 @@ ) ) -;; TODO: lookup by code? -(define* (uncat-splits root #:key (name "Imbalance-USD")) - (xaccAccountGetSplitList (gnc-account-lookup-by-name root name))) - +;; experimentally verify that we can sert the "Category" of 1 split. (define (sync1 root) (let* ((uncat (gnc-account-lookup-by-name root "Imbalance-USD")) (cat (gnc-account-lookup-by-name root "Discretionary")) @@ -73,48 +100,16 @@ (let ((needle (car haystack))) (xaccSplitSetAccount needle cat))))) -(define (run-push-tx-ids window) - (display "hi from run-push-tx-ids\n") -;; (gnc:gui-msg "XXX unused?" "about to get uncat splits\n") - (let* ( - (book (gnc-get-current-book)) - (root (gnc-get-current-root-account)) - (accts (gnc-account-get-children-sorted root)) - (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) - ) - (display (format #f "book: ~a\n" book)) - (display (format #f "root: ~a\n" root)) - (display (format #f "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts))) - (display (format #f "uncat: ~a\n" (gnc:strify acct-uncat))) - (display (format #f "uncat splits: ~a\n" - (map gnc:strify (uncat-splits root)))) - (let* ((records (map split-record (uncat-splits root))) - (invalid-records (remove valid-transaction? records)) - (data (list->vector records))) - (unless (null? invalid-records) - (error "bad records:" invalid-records)) - (display (format #f "uncat records sexp: ~%~a~%" records)) - (display (format #f "uncat records JSON: ~%~a~%" (scm->json-string data #:pretty #t)))) - (gnc:gui-msg "XXX unused?" "didn't crash: get uncat splits\n") - (format #f "HTTP post in JSON") - )) - - (define (run-pull-categories window) (display "hi from run-pull-categories\n") (let* ( (root (gnc-get-current-root-account)) (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) ) - (display (format #f "root: ~a\n" root)) - (display (format #f "uncat: ~a\n" (gnc:strify acct-uncat))) + (format #t "root: ~a\n" root) + (format #t "uncat: ~a\n" (gnc:strify acct-uncat)) (let ((body (fetch-stuff))) (gnc:gui-msg "XXX" (format #f "HTTP GET: length: ~a" (string-length body)))) (sync1 root) (gnc:gui-msg "XXX unused?" "didn't crash: sync1\n") )) - -;; these don't seem to work. -(gnc:debug "debug\n") -(gnc:msg "message\n") -(gnc:warn "warn\n") From 25b80384e0a130c633ee82cfbea6c9a6a721b0a7 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 02:38:41 -0500 Subject: [PATCH 12/27] SQUASHME --- packages/gc-sync1/sync-uncat-lib.scm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm index 3cd7750..4d40497 100644 --- a/packages/gc-sync1/sync-uncat-lib.scm +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -2,8 +2,8 @@ ;; Synchronize uncategorized splits from external data (define-module (sync-uncat-lib)) -(use-modules ((srfi srfi-71) #:select (let*))) (use-modules ((srfi srfi-1) #:select (remove every))) +(use-modules ((srfi srfi-71) #:select (let*))) (use-modules ((gnucash core-utils) #:select (N_))) (use-modules ((gnucash utilities) #:select (gnc:msg gnc:debug gnc:warn gnc:gui-msg))) From d6635493404310303a45ef7e138c7f00062cfe0b Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 02:39:16 -0500 Subject: [PATCH 13/27] feat(gc-sync1): POST uncat txs works in 1 case --- packages/gc-sync1/sync-uncat-lib.scm | 35 ++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm index 4d40497..2c0aeb0 100644 --- a/packages/gc-sync1/sync-uncat-lib.scm +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -3,12 +3,14 @@ (define-module (sync-uncat-lib)) (use-modules ((srfi srfi-1) #:select (remove every))) +(use-modules ((srfi srfi-11) #:select (let-values))) (use-modules ((srfi srfi-71) #:select (let*))) (use-modules ((gnucash core-utils) #:select (N_))) (use-modules ((gnucash utilities) #:select (gnc:msg gnc:debug gnc:warn gnc:gui-msg))) (use-modules ((gnucash report report-utilities) #:select (gnc:strify))) (use-modules ((web client) #:select (http-request))) +(use-modules ((web response) #:select (response-code response-headers))) (use-modules ((gnucash json builder) #:select (scm->json-string))) ;; #:select doesn't work for xaccTransGetDate etc. ;; maybe due to FFI magic in (gnucash engine)? @@ -65,18 +67,41 @@ (format #t "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts)) (format #t "uncat: ~a\n" (gnc:strify acct-uncat)))) +(define (logged label x) + (format #t label x) + x) + +(define* (http-post* url #:key (method 'POST) (body #f) (headers '())) + (let-values (((resp resp-body) + (http-request url #:method method #:headers headers #:body body))) + (if (memq (response-code (logged "resp ~a~%" resp)) '(301 302 303)) + (let ((url2 (cdr (assoc 'location (response-headers resp))))) + ;; switch to GET on redirect + (http-request (logged "redirect to ~a~%" url2) + #:method 'GET #:headers headers #:body body)) + (values resp body)))) + (define (run-push-tx-ids window) ;; (display "hi from run-push-tx-ids\n") (explore-gnucash-api) (let* ((root (gnc-get-current-root-account)) - (records (map split-record (uncat-splits root))) + (records (logged "uncat records sexp: ~%~a~%" (map split-record (uncat-splits root)))) (invalid-records (remove valid-transaction? records)) - (data (list->vector records))) + (data `(("transactions" . ,(list->vector records))))) (unless (null? invalid-records) (error "bad records:" invalid-records)) - (format #t "uncat records sexp: ~%~a~%" records) - (format #t "uncat records JSON: ~%~a~%" (scm->json-string data #:pretty #t)) - (gnc:gui-msg "?" (format #f "found ~a uncategorized transactions; TODO: POST" (length records))))) + (gnc:gui-msg "?" (format #f "found ~a uncategorized transactions to POST" (length records))) + (let-values (((response response-body) + (http-post* (logged "XXX ambient env URL: ~a~%" (getenv "FINSYNC")) + #:method 'POST + #:headers `((content-type . (application/json))) + #:body (logged "uncat records JSON: ~%~a~%" + (scm->json-string data #:pretty #t))))) + (unless (eqv? (response-code response) 200) + (format #t "error body: ~a~%" response-body) + (error "unexpected response" response))) + (gnc:gui-msg "?" "POSTed") + )) (define cups-home "http://localhost:631") ; HTTP server that happens to be handy From d5bf4c0f8acf44b5b2d45ae06e48fb00615bd170 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 03:03:05 -0500 Subject: [PATCH 14/27] feat(sync26): include account code in GnuCash_Uncat --- packages/sync26/syncSvc.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sync26/syncSvc.js b/packages/sync26/syncSvc.js index 818fe2b..7d79bcf 100644 --- a/packages/sync26/syncSvc.js +++ b/packages/sync26/syncSvc.js @@ -11,7 +11,7 @@ const config = { sheetUncat: { name: 'GnuCash_Uncat', - hd: ['date', 'description', 'amount', 'tx_guid', 'uploaded'], + hd: ['date', 'account', 'description', 'amount', 'tx_guid', 'uploaded'], }, /** Name of your SheetSync Transactions sheet */ sheetTx: 'Transactions (2)', @@ -27,6 +27,7 @@ const EXPECTED_POST_BODY_FORMAT = { { guid: 'deadbeef...', date: '2020-01-02', + account: '1234', // account code description: 'payee etc.', amount: 123.45, }, @@ -104,6 +105,7 @@ const saveUploaded = ( const rows = uncategorizedTransactions.map(tx => [ tx.date, + tx.account, tx.description, tx.amount, tx.guid, From cb23ab75eb84f6d113d55b1ac16978935dffaecd Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 30 May 2025 03:03:36 -0500 Subject: [PATCH 15/27] feat(gc-sync1): include account code in push-tx-ids --- packages/gc-sync1/sync-uncat-lib.scm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm index 2c0aeb0..a2fe5e1 100644 --- a/packages/gc-sync1/sync-uncat-lib.scm +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -30,22 +30,26 @@ (every pair? obj) (let ((guid (assoc "guid" obj)) (date (assoc "date" obj)) + (account (assoc "account" obj)) (description (assoc "description" obj)) (amount (assoc "amount" obj))) (and guid (string? (cdr guid)) date (string? (cdr date)) + account (string? (cdr account)) description (string? (cdr description)) amount (number? (cdr amount)))))) (define (split-record split) (let* ((parent (xaccSplitGetParent split)) - (acct (xaccSplitGetAccount split)) + (other (xaccSplitGetOtherSplit split)) + (account-code (xaccAccountGetCode (xaccSplitGetAccount other))) (amount (xaccSplitGetAmount split)) (time64 (xaccTransGetDate parent)) (datetime-str (gnc-print-time64 time64 "%Y-%m-%d %H:%M:%S")) ) ;; JSON builder object `(("date" . ,datetime-str) + ("account" . ,account-code) ("description" . ,(xaccTransGetDescription parent)) ;; exact->inexact is a little scary ;; how about #(,(numerator amount) "/" ,(denominator amount))? From 6ff5eba8baf10016addcccd8926125397fc316b6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 28 May 2025 22:56:52 -0500 Subject: [PATCH 16/27] WIP: package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 286cddb..c69a22c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@google/clasp": "^2.4.2", "@jessie.js/eslint-plugin": "^0.4.0", "@types/better-sqlite3": "^7.6.1", - "@types/google-apps-script": "^1.0.83", + "@types/google-apps-script": "^1.0.97", "@types/node": "^14.14.7", "@typescript-eslint/parser": "^4.21.0", "eslint-config-airbnb-base": "^14.2.1", From bca4c847d4d7443414c187a68c64f9d3da689a03 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 31 May 2025 02:59:48 -0500 Subject: [PATCH 17/27] WIP: packages/gc-sync1/Makefile --- packages/gc-sync1/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/gc-sync1/Makefile b/packages/gc-sync1/Makefile index f850ea2..a98eb55 100644 --- a/packages/gc-sync1/Makefile +++ b/packages/gc-sync1/Makefile @@ -26,7 +26,10 @@ pyshell-enable: repl: LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) -test: sync-uncat-lib.scm +SITE_GUILE=/usr/share/guile/site/3.0/ +GUILE=guile +SO_LIBS=/usr/lib/x86_64-linux-gnu/gnucash:/usr/lib/x86_64-linux-gnu/gnucash/gnucash:/usr/lib/x86_64-linux-gnu +check: sync-uncat-lib.scm LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) $< # Debugging From ef507bec98f64947c35f24791d41063e6698ae50 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 31 May 2025 02:32:13 -0500 Subject: [PATCH 18/27] WIP: packages/gc-sync1/sync-uncat-lib.scm --- packages/gc-sync1/sync-uncat-lib.scm | 45 +++++++++++++++------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm index a2fe5e1..cb6222f 100644 --- a/packages/gc-sync1/sync-uncat-lib.scm +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -1,5 +1,9 @@ ;; sync-uncat.scm ;; Synchronize uncategorized splits from external data +;; +;; Copyright (C) 2025 by Dan Connolly +;; SPDX-License-Identifier: Apache-2.0 +;; Share and Enjoy. (define-module (sync-uncat-lib)) (use-modules ((srfi srfi-1) #:select (remove every))) @@ -7,7 +11,7 @@ (use-modules ((srfi srfi-71) #:select (let*))) (use-modules ((gnucash core-utils) #:select (N_))) (use-modules ((gnucash utilities) #:select - (gnc:msg gnc:debug gnc:warn gnc:gui-msg))) + (gnc:msg gnc:debug gnc:warn gnc:gui-msg))) (use-modules ((gnucash report report-utilities) #:select (gnc:strify))) (use-modules ((web client) #:select (http-request))) (use-modules ((web response) #:select (response-code response-headers))) @@ -41,12 +45,12 @@ (define (split-record split) (let* ((parent (xaccSplitGetParent split)) - (other (xaccSplitGetOtherSplit split)) - (account-code (xaccAccountGetCode (xaccSplitGetAccount other))) - (amount (xaccSplitGetAmount split)) - (time64 (xaccTransGetDate parent)) - (datetime-str (gnc-print-time64 time64 "%Y-%m-%d %H:%M:%S")) - ) + (other (xaccSplitGetOtherSplit split)) + (account-code (xaccAccountGetCode (xaccSplitGetAccount other))) + (amount (xaccSplitGetAmount split)) + (time64 (xaccTransGetDate parent)) + (datetime-str (gnc-print-time64 time64 "%Y-%m-%d %H:%M:%S")) + ) ;; JSON builder object `(("date" . ,datetime-str) ("account" . ,account-code) @@ -79,11 +83,11 @@ (let-values (((resp resp-body) (http-request url #:method method #:headers headers #:body body))) (if (memq (response-code (logged "resp ~a~%" resp)) '(301 302 303)) - (let ((url2 (cdr (assoc 'location (response-headers resp))))) - ;; switch to GET on redirect - (http-request (logged "redirect to ~a~%" url2) - #:method 'GET #:headers headers #:body body)) - (values resp body)))) + (let ((url2 (cdr (assoc 'location (response-headers resp))))) + ;; switch to GET on redirect + (http-request (logged "redirect to ~a~%" url2) + #:method 'GET #:headers headers #:body body)) + (values resp body)))) (define (run-push-tx-ids window) ;; (display "hi from run-push-tx-ids\n") @@ -93,21 +97,20 @@ (invalid-records (remove valid-transaction? records)) (data `(("transactions" . ,(list->vector records))))) (unless (null? invalid-records) - (error "bad records:" invalid-records)) + (error "bad records:" invalid-records)) (gnc:gui-msg "?" (format #f "found ~a uncategorized transactions to POST" (length records))) (let-values (((response response-body) (http-post* (logged "XXX ambient env URL: ~a~%" (getenv "FINSYNC")) - #:method 'POST - #:headers `((content-type . (application/json))) - #:body (logged "uncat records JSON: ~%~a~%" - (scm->json-string data #:pretty #t))))) - (unless (eqv? (response-code response) 200) - (format #t "error body: ~a~%" response-body) - (error "unexpected response" response))) + #:method 'POST + #:headers `((content-type . (application/json))) + #:body (logged "uncat records JSON: ~%~a~%" + (scm->json-string data #:pretty #t))))) + (unless (eqv? (response-code response) 200) + (format #t "error body: ~a~%" response-body) + (error "unexpected response" response))) (gnc:gui-msg "?" "POSTed") )) - (define cups-home "http://localhost:631") ; HTTP server that happens to be handy (define* (fetch-stuff #:key (url cups-home)) From 8d2f5012dd965a14d458efd956d1b2bd493b2578 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 28 May 2025 22:50:31 -0500 Subject: [PATCH 19/27] WIP: packages/sync26/sheetTools.js --- packages/sync26/sheetTools.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/sync26/sheetTools.js b/packages/sync26/sheetTools.js index 26b6436..2d7504e 100644 --- a/packages/sync26/sheetTools.js +++ b/packages/sync26/sheetTools.js @@ -4,7 +4,14 @@ function GetAllSheetNames() { return sheets.map(sheet => sheet.getName()); } -function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { +/** + * @param {GoogleAppsScript.Spreadsheet.Spreadsheet} sheet + * @param {string[]} hd + * @param {string[][]} rows + * @param {number} [hdRow] + * @param {number} [detailRow] + */ +export function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { sheet.getRange(hdRow, 1, 1, hd.length).setValues([hd]); sheet.getRange(detailRow, 1, rows.length, hd.length).setValues(rows); } From 40ddfed1ee3d0f690cac5e1c590c1156363301ca Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 28 May 2025 22:56:52 -0500 Subject: [PATCH 20/27] WIP: yarn.lock --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 910b4a1..ebf00be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -347,10 +347,10 @@ __metadata: languageName: node linkType: hard -"@types/google-apps-script@npm:^1.0.83": - version: 1.0.83 - resolution: "@types/google-apps-script@npm:1.0.83" - checksum: 10c0/57ee29f3dd252859435dfbb254b6f1098c4abc71ae9f296075b4e8e487de19e98785328671a07397394c88f2fa348d60bdbbf560182dfea5e384fdbd7756c8f9 +"@types/google-apps-script@npm:^1.0.97": + version: 1.0.97 + resolution: "@types/google-apps-script@npm:1.0.97" + checksum: 10c0/c4f6e36c89ee70bda2662c0c84616f95b9a1882dfb81f264a04a8a226770e13a3be342179f743d5dea2128257654982e226cc5fec18affee394dc34a7832f6dd languageName: node linkType: hard @@ -4798,7 +4798,7 @@ __metadata: "@google/clasp": "npm:^2.4.2" "@jessie.js/eslint-plugin": "npm:^0.4.0" "@types/better-sqlite3": "npm:^7.6.1" - "@types/google-apps-script": "npm:^1.0.83" + "@types/google-apps-script": "npm:^1.0.97" "@types/node": "npm:^14.14.7" "@typescript-eslint/parser": "npm:^4.21.0" eslint-config-airbnb-base: "npm:^14.2.1" From 3fd127d7c6f6fae233962ccecf3dc4bb8c3196a6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Mon, 26 May 2025 16:11:22 -0500 Subject: [PATCH 21/27] WIP: packages/gc-sync1/config-user.scm --- packages/gc-sync1/config-user.scm | 1 + 1 file changed, 1 insertion(+) create mode 120000 packages/gc-sync1/config-user.scm diff --git a/packages/gc-sync1/config-user.scm b/packages/gc-sync1/config-user.scm new file mode 120000 index 0000000..0070b6d --- /dev/null +++ b/packages/gc-sync1/config-user.scm @@ -0,0 +1 @@ +/home/connolly/.config/gnucash/config-user.scm \ No newline at end of file From 1f5da85f904d163e7c84e0617509836686e983ac Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 20 Nov 2025 20:26:34 -0600 Subject: [PATCH 22/27] docs(gc-sync1): loading / deployment reconstructing what I knew back at the end of May --- packages/gc-sync1/README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/gc-sync1/README.md b/packages/gc-sync1/README.md index 04b276a..3b589e4 100644 --- a/packages/gc-sync1/README.md +++ b/packages/gc-sync1/README.md @@ -1,3 +1,30 @@ # gc-sync1 - Categorize Splits from GnuCash UI -see also https://github.com/dckc/finquick/issues/51 +**STATUS: WIP**. see [finquick issue +#51](https://github.com/dckc/finquick/issues/51). + +Provided GnuCash is configured as below, we add these menu items: + + - Push Uncat Txs - select uncategorized transactions and POST to a + Google Sheet for categorization (_working_). + - Pull Categories - GET categories and apply them (_not working_). + +## Load GnuCash Menu Handlers + +Following conventions for [loading a custom +report](https://wiki.gnucash.org/wiki/Custom_Reports#Loading_Your_Report), +put something like this in `~/.config/gnucash/config-user.scm`: + +```scm +(load (gnc-build-userdata-path "sync-uncat.scm")) +``` + +Then symlink `~/.local/share/gnucash/sync-uncat.scm` and +`~/.local/share/gnucash/sync-uncat-lib.scm` to the code in this dir. + +## Deploy Google Sheet web service + +Add `../sync26/syncSvc.js` to your spreadsheet and [create a +deployment](https://developers.google.com/apps-script/concepts/deployments). +This produces a URL like `https://script.google.com/macros/s/as;ldkflsd...`. +Assign that to the `$FINSYNC` environment variable before starting `gnucash`. From 0b51f29b834df1b70320fa3088f14401949ed65b Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 22 Nov 2025 02:01:46 -0600 Subject: [PATCH 23/27] chore(sheetTools): no export --- packages/sync26/sheetTools.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sync26/sheetTools.js b/packages/sync26/sheetTools.js index 2d7504e..8ca28a4 100644 --- a/packages/sync26/sheetTools.js +++ b/packages/sync26/sheetTools.js @@ -11,20 +11,20 @@ function GetAllSheetNames() { * @param {number} [hdRow] * @param {number} [detailRow] */ -export function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { +/* export */ function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { sheet.getRange(hdRow, 1, 1, hd.length).setValues([hd]); sheet.getRange(detailRow, 1, rows.length, hd.length).setValues(rows); } const zip = (xs, ys) => xs.map((x, ix) => [x, ys[ix]]); -/* export */ function getRowRecord(sheet, row, headings) { +function getRowRecord(sheet, row, headings) { const [values] = sheet.getRange(row, 1, 1, headings.length).getValues(); const entries = zip(headings, values); return Object.fromEntries(entries); } -/* export */ function getHeading(sheet) { +function getHeading(sheet, col1 = 1) { const hd = []; for ( let col = 1, name; From 01a5b6a52d3e881d18109c7501dc9d8a95fce9f6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 22 Nov 2025 02:02:08 -0600 Subject: [PATCH 24/27] feat(sheetTools): skip cols when getting records --- packages/sync26/sheetTools.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/sync26/sheetTools.js b/packages/sync26/sheetTools.js index 8ca28a4..78044ef 100644 --- a/packages/sync26/sheetTools.js +++ b/packages/sync26/sheetTools.js @@ -27,7 +27,7 @@ function getRowRecord(sheet, row, headings) { function getHeading(sheet, col1 = 1) { const hd = []; for ( - let col = 1, name; + let col = col1, name; (name = sheet.getRange(1, col).getValue()) > ''; col += 1 ) { @@ -36,9 +36,11 @@ function getHeading(sheet, col1 = 1) { return hd; } -function getSheetRecords(sheet) { - const hd = getHeading(sheet); - const data = sheet.getRange(2, 1, sheet.getLastRow(), hd.length).getValues(); +function getSheetRecords(sheet, col1 = 1) { + const hd = getHeading(sheet, col1); + const data = sheet + .getRange(2, col1, sheet.getLastRow(), hd.length) + .getValues(); const records = []; for (const values of data) { const entries = zip(hd, values); From 71c2175d50be2f760c0c1202c458042bb7fc2252 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 22 Nov 2025 02:03:36 -0600 Subject: [PATCH 25/27] test(txRules): getSheetRecords test (WIP) --- packages/sync26/txRules.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/sync26/txRules.js b/packages/sync26/txRules.js index 59fca13..09de2ed 100644 --- a/packages/sync26/txRules.js +++ b/packages/sync26/txRules.js @@ -67,3 +67,13 @@ function ApplyRules() { if (!sel) return; applyRulesToRange(doc, sel); } + +function TestGetSelection(_nonce, io = { sheetName: 'Accounts' }) { + const { + doc = SpreadsheetApp.getActive(), + sheetName = Rules.sheetName, + sheet = doc.getSheetByName(sheetName), + } = io; + const { records } = getSheetRecords(sheet, 2); + console.log(records.slice(0, 3)); +} From 01dc4ac1f7ca4283d86b4a928ba40bfe68a34866 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 22 Nov 2025 02:07:16 -0600 Subject: [PATCH 26/27] feat: fetch polyfill (SQUASHME w/cosmos API?) --- packages/sync26/polyfill/fetch.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/sync26/polyfill/fetch.js diff --git a/packages/sync26/polyfill/fetch.js b/packages/sync26/polyfill/fetch.js new file mode 100644 index 0000000..a6b1d39 --- /dev/null +++ b/packages/sync26/polyfill/fetch.js @@ -0,0 +1,16 @@ +'use strict'; + +function makeFetch() { + const { freeze } = Object; + + console.warn('AMBIENT: UrlFetchApp'); + const app = UrlFetchApp; + + const fetch = async url => { + const content = app.fetch(url); + return freeze({ + json: async () => JSON.parse(content), + }); + }; + return fetch; +} From 333d651b6f59b91dfab0b1f9135ed0469644e7d6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Sat, 22 Nov 2025 02:08:42 -0600 Subject: [PATCH 27/27] feat: find plaid txs matching gnucash uncat --- packages/sync26/gncSync.js | 194 +++++++++++++++++++++++++++++++++++++ packages/sync26/menu.js | 1 + 2 files changed, 195 insertions(+) create mode 100644 packages/sync26/gncSync.js diff --git a/packages/sync26/gncSync.js b/packages/sync26/gncSync.js new file mode 100644 index 0000000..0d3497e --- /dev/null +++ b/packages/sync26/gncSync.js @@ -0,0 +1,194 @@ +const sheetsConfig = { + plaid: { + name: 'Transactions (2)', + cols: { + gncIdTarget: 'tx_guid', + }, + }, + gnucash: { + name: 'GnuCash_Uncat', + cols: { + plaidIdTarget: 'Transaction ID', + }, + }, + accounts: { + name: 'Accounts', + }, +}; + +/** + * @typedef {{ + * Date: Date, + * Amount: number, + * 'Account #': string, + * tx_guid: string, + * 'Transaction ID': string, + * Account: string, + * }} PlaidTransactionRecord + */ + +/** + * @typedef {{ + * date: Date, + * account: string, + * amount: number, + * tx_guid: string, + * plaid_id?: string, + * }} GnuCashTransactionRecord + */ + +/** + * @typedef {{ + * code: string, + * Account: string, + * }} AccountRecord + */ + +/** + * @typedef {{ + * gncRecord: GnuCashTransactionRecord, + * plaidMatch: PlaidTransactionRecord, + * gncIndex: number, // 0-based index in the recordsToProcess (first 10) array + * plaidIndex: number, // 0-based index in the full plaidRecords array + * }} MatchResult + */ + +/** + * Main controller function. Fetches data, orchestrates the matching process, and writes back the IDs. + * + * @param {object} io Injected dependencies for testability/I/O. + */ +const SyncGnuCashPlaidTxns = (_nonce, io = {}) => { + const { + doc = SpreadsheetApp.getActive(), + activeRange = SpreadsheetApp.getActiveRange(), + gncSheet = doc.getSheetByName(sheetsConfig.gnucash.name), + plaidSheet = doc.getSheetByName(sheetsConfig.plaid.name), + accountsSheet = doc.getSheetByName(sheetsConfig.accounts.name), + } = io; + + // Get Sheets and All Records + if (!gncSheet || !plaidSheet || !accountsSheet) { + throw Error('Required sheets not found. Check sheet names in config.'); + } + const { records: plaidRecords } = getSheetRecords(plaidSheet, 2); + const { records: accountRecords } = getSheetRecords(accountsSheet, 2); + const { records: gncRecords } = getSheetRecords(gncSheet); + + /** @type {Map} */ + const codeToPlaidAcctNameMap = new Map( + accountRecords + .map(record => { + /** @type {AccountRecord} */ + const aRecord = /** @type {AccountRecord} */ (record); + return [String(aRecord.code), aRecord.Account]; + }) + .filter(([, plaidAcctName]) => plaidAcctName), + ); + + // 1-indexed, including header + const [lo, hi] = [activeRange.getRow(), activeRange.getLastRow()]; + const recordsToProcess = gncRecords.slice(lo - 2, hi - 1); + + const matches = findTransactionMatches( + recordsToProcess, + plaidRecords, + codeToPlaidAcctNameMap, + ); + + // Write Back Results + const gncPlaidIdCol = getColumnNumber( + gncSheet, + sheetsConfig.gnucash.cols.plaidIdTarget, + ); + const plaidGncIdCol = getColumnNumber( + plaidSheet, + sheetsConfig.plaid.cols.gncIdTarget, + ); + for (const match of matches) { + // Calculate 1-based row numbers, based on activeRange + const gncRowNum = match.gncIndex + lo; + const plaidRowNum = match.plaidIndex + 2; + + const plaidTxId = match.plaidMatch['Transaction ID']; + + gncSheet.getRange(gncRowNum, gncPlaidIdCol).setValue(plaidTxId); + plaidSheet + .getRange(plaidRowNum, plaidGncIdCol) + .setValue(match.gncRecord.tx_guid); + } + + // Final Status Report + console.log( + `Successfully synced ${matches.length} out of ${recordsToProcess.length}`, + ); +}; + +const SyncGnuCashPlaidTxnsTest = (nonce, io = {}) => { + const { + doc = SpreadsheetApp.getActive(), + gncSheet = doc.getSheetByName(sheetsConfig.gnucash.name), + activeRange = gncSheet.getRange('A3:G5'), + } = io; + return SyncGnuCashPlaidTxns(nonce, { ...io, doc, gncSheet, activeRange }); +}; + +/** + * Checks if two Date objects are within a specified number of days of each other. + * + * @param {Date} date1 + * @param {Date} date2 + * @param {number} maxDays - The maximum allowable difference in days (e.g., 2 for +/- 2 days). + */ +const fuzzyDateMatch = (date1, date2, maxDays) => { + const diffTime = Math.abs(date1.getTime() - date2.getTime()); + return diffTime <= maxDays * DAY; +}; + +/** + * Core matching engine. Finds corresponding Plaid transactions for a list of GnuCash transactions. + * + * @param {GnuCashTransactionRecord[]} gncRecordsToProcess - The GnuCash records to iterate over. + * @param {PlaidTransactionRecord[]} allPlaidRecords - All Plaid records to search against. + * @param {Map} codeToPlaidAcctNameMap - Map from GnuCash code to Plaid Account Name. + * @returns {MatchResult[]} An array of objects containing the matched records and their original indices. + */ +const findTransactionMatches = ( + gncRecordsToProcess, + allPlaidRecords, + codeToPlaidAcctNameMap, +) => { + /** @type {MatchResult[]} */ + const matches = []; + + for (let gncIndex = 0; gncIndex < gncRecordsToProcess.length; gncIndex++) { + const gncRecord = gncRecordsToProcess[gncIndex]; + if (gncRecord['Transaction Id']) continue; // already matched + + const requiredPlaidAcctName = codeToPlaidAcctNameMap.get( + String(gncRecord.account), + ); + if (!requiredPlaidAcctName) continue; + + const foundMatch = allPlaidRecords.find(plaidRecord => { + if (plaidRecord.tx_guid) return false; // already matched + if (plaidRecord.Account !== requiredPlaidAcctName) return false; + if (gncRecord.amount !== -plaidRecord.Amount) return false; + return fuzzyDateMatch(plaidRecord.Date, gncRecord.date, 2.67); + }); + + // Record Match and Indices + if (foundMatch) { + const plaidIndex = allPlaidRecords.indexOf(foundMatch); + + matches.push({ + gncRecord, + plaidMatch: foundMatch, + gncIndex, + plaidIndex, + }); + } + } + + return matches; +}; diff --git a/packages/sync26/menu.js b/packages/sync26/menu.js index b6c753a..b0de1c1 100644 --- a/packages/sync26/menu.js +++ b/packages/sync26/menu.js @@ -9,6 +9,7 @@ function onOpen() { ui.createMenu('Family Finances') .addItem('Tx Lookup', 'TxLookup') .addItem('Tx: Apply Rules', 'ApplyRules') + .addItem('Gnc: Sync w/Plaid', 'SyncGnuCashPlaidTxns') .addItem('Load Trade Accounting', 'loadTradeAccountingMessages') .addItem('Share Just In Case', 'shareJustInCase') .addSubMenu(