AtlatestRepositorytally

tally / tree / src / tallytools.sgl

1;;; (tally tools) - MCP tool definitions and shared helpers for tally.
2;;;
3;;; Provides shared Wise/reporting helpers used by both MCP tools and
4;;; CLI commands, plus MCP tool registration.
5
6(define-library (tally tools)
7 (import (sigil core)
8 (sigil string)
9 (sigil dict)
10 (sigil struct)
11 (sigil json)
12 (sigil time)
13 (sigil error)
14 (sigil mcp server)
15 (wise)
16 (ledger)
17 (ledger report)
18 (tally config)
19 (tally sync))
20 (export register-tally-tools!
21 require-wise-client
22 resolve-profile-id
23 format-transaction-summary
24 format-balance-summary
25 run-hledger-report
26 default-since-date
27 default-until-date
28 tool-categorize)
29 (begin
31 ;;; Compute default since date (January 1 of the current year).
32 ;;; Must be called outside async contexts due to VM yield bug
33 ;;; with current-second.
34 (define (default-since-date)
35 (let ((year (number->string (time-year (current-second)))))
36 (string-append year "-01-01T00:00:00Z")))
38 ;;; Compute default until date (December 31 of the current year).
39 ;;; Must be called outside async contexts due to VM yield bug
40 ;;; with current-second.
41 (define (default-until-date)
42 (let ((year (number->string (time-year (current-second)))))
43 (string-append year "-12-31T23:59:59Z")))
45 ;;; Build a wise-client from config, raising if no token.
46 (define (require-wise-client config)
47 (let ((token (tally-config-wise-token config)))
48 (unless token
49 (error "WISE_API_TOKEN is not set"))
50 (wise-client token: token)))
52 ;;; Find the profile matching the configured type (personal/business).
53 ;;; Falls back to the first available profile if no exact match.
54 (define (resolve-profile-id client profile-type)
55 (let ((profiles (wise-profiles client)))
56 (let ((match (find (lambda (p)
57 (string-ci=? (wise-profile-type p) profile-type))
58 profiles)))
59 (if match
60 (wise-profile-id match)
61 (if (pair? profiles)
62 (wise-profile-id (car profiles))
63 (error "No Wise profiles found"))))))
65 ;;; Format a wise-transaction as a summary string with description and ref.
66 (define (format-transaction-summary txn)
67 (let* ((details (wise-transaction-details txn))
68 (desc (transaction-description details))
69 (ref (wise-transaction-reference-number txn)))
70 (string-append
71 (wise-transaction-date txn) " "
72 (wise-transaction-type txn) " "
73 (number->string (wise-transaction-amount txn)) " "
74 (wise-transaction-currency txn) " "
75 desc
76 (if (and ref (not (string-empty? ref)))
77 (string-append " [" ref "]")
78 ""))))
80 ;;; Format a wise-balance as a display string with optional reserved amount.
81 (define (format-balance-summary bal)
82 (string-append
83 (wise-balance-currency bal) ": "
84 (number->string (wise-balance-amount bal))
85 (let ((reserved (wise-balance-reserved-amount bal)))
86 (if (and reserved (> reserved 0))
87 (string-append " (reserved: " (number->string reserved) ")")
88 ""))))
90 ;;; Run an hledger report by type string.
91 ;;; Dispatches "bal", "reg", "is" to the appropriate hledger function.
92 (define (run-hledger-report report-type journal query-args)
93 (cond
94 ((string=? report-type "bal")
95 (apply hledger-balance journal query-args))
96 ((string=? report-type "reg")
97 (apply hledger-register journal query-args))
98 ((string=? report-type "is")
99 (apply hledger-income-statement journal query-args))
100 (else
101 (hledger-command report-type file: journal args: query-args))))
103 ;; MCP tool handlers
105 (define (tool-pull config params default-since default-until)
106 (guard (exn (else
107 (error (string-append "Failed to fetch transactions: "
108 (format-exception exn)))))
109 (let* ((client (require-wise-client config))
110 (since (dict-ref params since: default-since))
111 (until (dict-ref params until: default-until))
112 (profile-type (dict-ref params profile:
113 (tally-config-profile-type config)))
114 (profile-id (resolve-profile-id client profile-type))
115 (txns (tally-pull-transactions client profile-id since until))
116 (count (length txns)))
117 (string-append
118 "Pulled " (number->string count) " transactions.\n\n"
119 (if (= count 0) ""
120 (string-join (map format-transaction-summary txns) "\n"))))))
122 (define (tool-import config params default-since default-until)
123 (guard (exn (else
124 (error (string-append "Failed to import transactions: "
125 (format-exception exn)))))
126 (let* ((client (require-wise-client config))
127 (journal (dict-ref params journal:
128 (tally-config-journal-path config)))
129 (since (dict-ref params since: default-since))
130 (until (dict-ref params until: default-until))
131 (profile-type (dict-ref params profile:
132 (tally-config-profile-type config)))
133 (profile-id (resolve-profile-id client profile-type))
134 (count (tally-import client profile-id since until journal)))
135 (string-append
136 "Imported " (number->string count) " new transactions to " journal "."))))
138 (define (tool-balance config params)
139 (guard (exn (else
140 (error (string-append "Failed to fetch balances: "
141 (format-exception exn)))))
142 (let* ((client (require-wise-client config))
143 (profile-type (dict-ref params profile:
144 (tally-config-profile-type config)))
145 (profile-id (resolve-profile-id client profile-type))
146 (balances (wise-balances client profile-id)))
147 (if (null? balances)
148 "No balances found."
149 (string-join (map format-balance-summary balances) "\n")))))
151 (define (tool-report config params)
152 (guard (exn (else
153 (error (string-append "Failed to run report: "
154 (format-exception exn)))))
155 (let* ((journal (dict-ref params journal:
156 (tally-config-journal-path config)))
157 (report-type (dict-ref params type: "bal"))
158 (query (dict-ref params query: #f))
159 (args (if query (list query) '())))
160 (run-hledger-report report-type journal args))))
162 (define (tool-categorize config params)
163 (let* ((journal-path (dict-ref params journal:
164 (tally-config-journal-path config)))
165 (txns (guard (exn (else '()))
166 (read-journal journal-path)))
167 (uncategorized
168 (filter (lambda (txn)
169 (find (lambda (p)
170 (let ((acct (journal-posting-account p)))
171 (or (string-contains? acct "uncategorized")
172 (string-contains? acct "unknown"))))
173 (journal-transaction-postings txn)))
174 txns)))
175 (if (null? uncategorized)
176 "No uncategorized transactions found."
177 (string-append
178 (number->string (length uncategorized))
179 " uncategorized transactions:\n\n"
180 (string-join
181 (map (lambda (txn)
182 (string-append
183 (journal-transaction-date txn) " "
184 (or (journal-transaction-code txn) "") " "
185 (journal-transaction-description txn)))
186 uncategorized)
187 "\n")))))
189 ;;; Register all tally MCP tools on the given server.
190 ;;; Precomputes default dates here (before async context) to avoid
191 ;;; the VM yield bug with current-second in async goroutines.
192 (define (register-tally-tools! server config)
193 (let ((since-default (default-since-date))
194 (until-default (default-until-date)))
196 (mcp-server-register-tool! server
197 "fetch-transactions"
198 "Pull recent transactions from Wise API. Returns a summary of transactions found."
199 #{ type: "object"
200 properties: #{
201 since: #{ type: "string"
202 description: "Start date in ISO 8601 format" }
203 until: #{ type: "string"
204 description: "End date in ISO 8601 format" }
205 profile: #{ type: "string"
206 description: "Wise profile type: personal or business" } } }
207 (lambda (params) (tool-pull config params since-default until-default)))
209 (mcp-server-register-tool! server
210 "import-transactions"
211 "Pull transactions from Wise and write new ones to the hledger journal file. Deduplicates against existing entries automatically."
212 #{ type: "object"
213 properties: #{
214 journal: #{ type: "string"
215 description: "Path to journal file (default: from TALLY_JOURNAL env)" }
216 since: #{ type: "string"
217 description: "Start date in ISO 8601 format" }
218 until: #{ type: "string"
219 description: "End date in ISO 8601 format" }
220 profile: #{ type: "string"
221 description: "Wise profile type: personal or business" } } }
222 (lambda (params) (tool-import config params since-default until-default)))
224 (mcp-server-register-tool! server
225 "wise-balances"
226 "Show current Wise account balances across all currencies."
227 #{ type: "object"
228 properties: #{
229 profile: #{ type: "string"
230 description: "Wise profile type: personal or business" } } }
231 (lambda (params) (tool-balance config params)))
233 (mcp-server-register-tool! server
234 "journal-report"
235 "Run an hledger report on the journal file. Supports balance (bal), register (reg), and income statement (is)."
236 #{ type: "object"
237 properties: #{
238 journal: #{ type: "string"
239 description: "Path to journal file" }
240 type: #{ type: "string"
241 description: "Report type: bal, reg, or is (default: bal)" }
242 query: #{ type: "string"
243 description: "Optional hledger query (e.g. 'assets', 'expenses:food')" } } }
244 (lambda (params) (tool-report config params)))
246 (mcp-server-register-tool! server
247 "uncategorized-transactions"
248 "List uncategorized transactions in the journal. Use this to find transactions that need category assignment."
249 #{ type: "object"
250 properties: #{
251 journal: #{ type: "string"
252 description: "Path to journal file" } } }
253 (lambda (params) (tool-categorize config params)))))
255 ))