sqlite-export-for-ynab 2.0.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. sqlite_export_for_ynab-2.2.0/PKG-INFO +589 -0
  2. sqlite_export_for_ynab-2.2.0/README.md +569 -0
  3. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/pyproject.toml +12 -0
  4. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/setup.cfg +1 -1
  5. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/_main.py +92 -38
  6. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/create-relations.sql +48 -10
  7. sqlite_export_for_ynab-2.2.0/sqlite_export_for_ynab.egg-info/PKG-INFO +589 -0
  8. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/testing/fixtures.py +67 -4
  9. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/tests/_main_test.py +162 -9
  10. sqlite_export_for_ynab-2.0.0/PKG-INFO +0 -207
  11. sqlite_export_for_ynab-2.0.0/README.md +0 -187
  12. sqlite_export_for_ynab-2.0.0/sqlite_export_for_ynab.egg-info/PKG-INFO +0 -207
  13. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/LICENSE +0 -0
  14. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/setup.py +0 -0
  15. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__init__.py +0 -0
  16. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__main__.py +0 -0
  17. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  18. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  19. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/py.typed +0 -0
  20. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  21. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  22. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  23. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/requires.txt +0 -0
  24. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  25. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/testing/__init__.py +0 -0
  26. {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/tests/__init__.py +0 -0
@@ -0,0 +1,589 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite_export_for_ynab
3
+ Version: 2.2.0
4
+ Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
5
+ Home-page: https://github.com/mxr/sqlite-export-for-ynab
6
+ Author: Max R
7
+ Author-email: maxr@outlook.com
8
+ License: MIT
9
+ Keywords: ynab,sqlite,sql,budget,plan,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: Implementation :: CPython
13
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: aiohttp>=3
18
+ Requires-Dist: tldm
19
+ Dynamic: license-file
20
+
21
+ # sqlite-export-for-ynab
22
+
23
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/mxr/sqlite-export-for-ynab/main.svg)](https://results.pre-commit.ci/latest/github/mxr/sqlite-export-for-ynab/main) [![codecov](https://codecov.io/github/mxr/sqlite-export-for-ynab/graph/badge.svg?token=NVCP6RDKSH)](https://codecov.io/github/mxr/sqlite-export-for-ynab)
24
+
25
+ SQLite Export for YNAB - Export YNAB Budget Data to SQLite
26
+
27
+ ## What This Does
28
+
29
+ Export all your [YNAB](https://ynab.com/) plans to a local [SQLite](https://www.sqlite.org/) DB. Then you can query your data with any tools compatible with SQLite.
30
+
31
+ ## Installation
32
+
33
+ ```console
34
+ $ pip install sqlite-export-for-ynab
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### CLI
40
+
41
+ Provision a [YNAB Personal Access Token](https://api.ynab.com/#personal-access-tokens) and save it as an environment variable.
42
+
43
+ ```console
44
+ $ export YNAB_PERSONAL_ACCESS_TOKEN="..."
45
+ ```
46
+
47
+ Run the tool from the terminal to download your plans:
48
+
49
+ ```console
50
+ $ sqlite-export-for-ynab
51
+ ```
52
+
53
+ Running it again will pull only data that changed since the last pull (this is done with [Delta Requests](https://api.ynab.com/#deltas)). If you want to wipe the DB and pull all data again use the `--full-refresh` flag.
54
+ Pass `--quiet` to suppress all CLI output, including progress bars.
55
+
56
+ <a id="db-path"></a>You can specify the DB path with the following options
57
+ 1. The `--db` flag.
58
+ 1. The `XDG_DATA_HOME` variable (see the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/index.html)). In that case the DB is saved in `"${XDG_DATA_HOME}"/sqlite-export-for-ynab/db.sqlite`.
59
+ 1. If neither is set, the DB is saved in `~/.local/share/sqlite-export-for-ynab/db.sqlite`.
60
+
61
+ ### Library
62
+
63
+ The library exposes the package `sqlite_export_for_ynab` and two functions - `default_db_path` and `sync`. You can use them as follows:
64
+
65
+ ```python
66
+ import asyncio
67
+ import os
68
+
69
+ from sqlite_export_for_ynab import default_db_path
70
+ from sqlite_export_for_ynab import sync
71
+
72
+ db = default_db_path()
73
+ token = os.environ["YNAB_PERSONAL_ACCESS_TOKEN"]
74
+ full_refresh = False
75
+
76
+ asyncio.run(sync(token, db, full_refresh))
77
+ ```
78
+
79
+ ## Relations
80
+
81
+ The relations are defined in [create-relations.sql](sqlite_export_for_ynab/ddl/create-relations.sql). They are 1:1 with [YNAB's OpenAPI Spec](https://api.ynab.com/papi/open_api_spec.yaml) (ex: transactions, accounts, etc) with some additions:
82
+
83
+ 1. Some objects are pulled out into their own tables so they can be more cleanly modeled in SQLite (ex: subtransactions, loan account periodic values).
84
+ 1. Foreign keys are added as needed (ex: plan ID, transaction ID) so data across plans remains separate.
85
+ 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted transactions/subtransactions and project payee/category fields to make querying more ergonomic.
86
+
87
+ ## Querying
88
+
89
+ You can issue queries with typical SQLite tools. *`sqlite-export-for-ynab` deliberately does not implement a SQL REPL.*
90
+
91
+ ### Sample Queries
92
+
93
+ You can run the queries from this README using a tool like [`mdq`](https://github.com/yshavit/mdq). For example:
94
+
95
+ ```console
96
+ $ mdq '```sql dupes' path/to/sqlite-export-for-ynab/README.md -o plain \
97
+ | sqlite3 path/to/sqlite-export-for-ynab/db.sqlite
98
+ ```
99
+
100
+ The DB path is documented [above](#db-path).
101
+
102
+ To get the top 5 payees by spending per plan, you could do:
103
+
104
+ ```sql
105
+ WITH ranked_payees AS (
106
+ SELECT
107
+ pl.name AS plan_name
108
+ , t.payee_name AS payee
109
+ , SUM(t.amount_currency) AS net_spent
110
+ , ROW_NUMBER()
111
+ OVER (PARTITION BY pl.id ORDER BY SUM(t.amount) ASC)
112
+ AS rnk
113
+ FROM flat_transactions AS t INNER JOIN plans AS pl ON t.plan_id = pl.id
114
+ WHERE
115
+ t.payee_name != 'Starting Balance' AND t.transfer_account_id IS NULL
116
+ GROUP BY pl.id, t.payee_id
117
+ )
118
+
119
+ SELECT
120
+ plan_name
121
+ , payee
122
+ , net_spent
123
+ FROM ranked_payees
124
+ WHERE rnk <= 5
125
+ ORDER BY plan_name ASC, net_spent DESC
126
+ ;
127
+ ```
128
+
129
+ To get duplicate payees, or payees with no transactions:
130
+
131
+ ```sql
132
+ WITH used_payees AS (
133
+ SELECT
134
+ plan_id
135
+ , payee_id
136
+ FROM transactions
137
+ WHERE
138
+ TRUE
139
+ AND payee_id IS NOT NULL
140
+ AND NOT deleted
141
+ UNION
142
+ SELECT
143
+ plan_id
144
+ , payee_id
145
+ FROM subtransactions
146
+ WHERE
147
+ TRUE
148
+ AND payee_id IS NOT NULL
149
+ AND NOT deleted
150
+ UNION
151
+ SELECT
152
+ plan_id
153
+ , payee_id
154
+ FROM scheduled_transactions
155
+ WHERE
156
+ TRUE
157
+ AND payee_id IS NOT NULL
158
+ AND NOT deleted
159
+ UNION
160
+ SELECT
161
+ plan_id
162
+ , payee_id
163
+ FROM scheduled_subtransactions
164
+ WHERE
165
+ TRUE
166
+ AND payee_id IS NOT NULL
167
+ AND NOT deleted
168
+ )
169
+
170
+ SELECT
171
+ pl.name AS "plan"
172
+ , dupes.name AS payee
173
+ FROM (
174
+ SELECT
175
+ p.plan_id
176
+ , p.name
177
+ FROM payees AS p
178
+ LEFT JOIN used_payees AS up ON p.plan_id = up.plan_id AND p.id = up.payee_id
179
+ WHERE
180
+ TRUE
181
+ AND up.payee_id IS NULL
182
+ AND p.transfer_account_id IS NULL
183
+ AND p.name != 'Reconciliation Balance Adjustment'
184
+ AND p.name != 'Manual Balance Adjustment'
185
+ AND NOT p.deleted
186
+ UNION
187
+ SELECT
188
+ plan_id
189
+ , name
190
+ FROM payees
191
+ WHERE NOT deleted
192
+ GROUP BY plan_id, name
193
+ HAVING COUNT(*) > 1
194
+ ) AS dupes
195
+ INNER JOIN plans AS pl ON dupes.plan_id = pl.id
196
+ ORDER BY "plan", payee
197
+ ;
198
+ ```
199
+
200
+ To count the spend for a category (ex: "Apps") between this month and the next 11 months (inclusive):
201
+
202
+ ```sql
203
+ SELECT
204
+ plan_id
205
+ , SUM(amount_currency) AS amount_currency
206
+ FROM (
207
+ SELECT
208
+ plan_id
209
+ , amount_currency
210
+ FROM flat_transactions
211
+ WHERE
212
+ category_name = 'Apps'
213
+ AND SUBSTR("date", 1, 7) = SUBSTR(DATE(), 1, 7)
214
+ UNION ALL
215
+ SELECT
216
+ plan_id
217
+ , amount_currency * (
218
+ CASE
219
+ WHEN frequency = 'monthly' THEN 11
220
+ ELSE 1 -- assumes yearly
221
+ END
222
+ ) AS amount_currency
223
+ FROM scheduled_flat_transactions
224
+ WHERE
225
+ category_name = 'Apps'
226
+ AND SUBSTR(date_next, 1, 7) < SUBSTR(DATE('now', '+1 year'), 1, 7)
227
+ )
228
+ ;
229
+ ```
230
+
231
+ To estimate taxable interest for a given year[^1]:
232
+
233
+ ```sql
234
+ -- Parameters expected by this query:
235
+ -- @tax_rate
236
+ -- @year
237
+ -- @plan_id (optional, defaults to output for all plans)
238
+ -- @estimated_additional_interest (optional,
239
+ -- estimated interest not in YNAB such as investment income)
240
+ -- @interest_reporting_threshold (optional, defaults to the $10
241
+ -- common threshold, but confirm with actual documents)
242
+ -- @interest_payee_name (optional, defaults to Interest)
243
+ --
244
+ -- Example with only required params:
245
+ -- sqlite3 -header -box path/to/db.sqlite \
246
+ -- -cmd '.parameter init' \
247
+ -- -cmd ".parameter set @tax_rate 0.25" \
248
+ -- -cmd ".parameter set @year 2025" \
249
+ -- < query.sql
250
+ --
251
+ -- Example with all params:
252
+ -- -cmd ".parameter set @tax_rate 0.25" \
253
+ -- -cmd ".parameter set @year 2025" \
254
+ -- -cmd ".parameter set @estimated_additional_interest 250.00" \
255
+ -- -cmd ".parameter set @interest_reporting_threshold 10" \
256
+ -- -cmd ".parameter set @interest_payee_name Interest" \
257
+ -- -cmd ".parameter set @plan_id your-plan-id" \
258
+ -- < query.sql
259
+
260
+ WITH interest_by_account AS (
261
+ SELECT
262
+ plan_id
263
+ , account_name
264
+ , SUM(-amount_currency) AS total
265
+ FROM flat_transactions
266
+ WHERE
267
+ TRUE
268
+ AND payee_name = COALESCE(NULLIF(@interest_payee_name, ''), 'Interest')
269
+ AND SUBSTR("date", 1, 4) = CAST(@year AS TEXT)
270
+ AND (COALESCE(@plan_id, '') = '' OR plan_id = @plan_id)
271
+ GROUP BY plan_id, account_name
272
+ HAVING total >= CAST(COALESCE(@interest_reporting_threshold, 10) AS REAL)
273
+ )
274
+
275
+ , interest_by_plan AS (
276
+ SELECT
277
+ plans.id AS plan_id
278
+ , plans.name AS plan_name
279
+ , COALESCE(SUM(interest_by_account.total), 0) AS interest_in_ynab
280
+ FROM plans
281
+ LEFT JOIN interest_by_account ON plans.id = interest_by_account.plan_id
282
+ WHERE COALESCE(@plan_id, '') = '' OR plans.id = @plan_id
283
+ GROUP BY plan_id, plan_name
284
+ )
285
+
286
+ , ranked_interest AS (
287
+ SELECT
288
+ plan_id
289
+ , plan_name
290
+ , interest_in_ynab
291
+ , interest_in_ynab
292
+ + CAST(COALESCE(@estimated_additional_interest, 0) AS REAL)
293
+ AS interest_with_estimate
294
+ , ROW_NUMBER() OVER (ORDER BY plan_name, plan_id) AS row_num
295
+ FROM interest_by_plan
296
+ )
297
+
298
+ , estimated_interest AS (
299
+ SELECT
300
+ plan_id
301
+ , plan_name
302
+ , interest_in_ynab
303
+ -- Additional interest is per-tax-return not per-YNAB-plan. Only add
304
+ -- additional interest to one plan's output to avoid double counting.
305
+ , CASE
306
+ WHEN row_num != 1 THEN interest_in_ynab
307
+ WHEN
308
+ interest_with_estimate
309
+ < CAST(COALESCE(@interest_reporting_threshold, 10) AS REAL)
310
+ THEN 0
311
+ ELSE interest_with_estimate
312
+ END AS estimated_total_taxable_interest
313
+ FROM ranked_interest
314
+ )
315
+
316
+ SELECT
317
+ plan_name AS "plan"
318
+ , PRINTF('%.2f', interest_in_ynab) AS interest_in_ynab
319
+ , PRINTF('%.2f', estimated_total_taxable_interest)
320
+ AS estimated_total_taxable_interest
321
+ , PRINTF(
322
+ '%.2f'
323
+ , estimated_total_taxable_interest * CAST(NULLIF(@tax_rate, '') AS REAL)
324
+ ) AS estimated_tax_liability
325
+ FROM estimated_interest
326
+ ORDER BY plan_name, plan_id
327
+ ;
328
+ ```
329
+
330
+ To compare assigned category values to a given account's balance:
331
+
332
+ ```sql
333
+ -- Parameters expected by this query:
334
+ -- @account_name_like (required, the account name to match against)
335
+ -- @plan_id (optional, defaults to output for all matching plans)
336
+ -- @include_category_groups
337
+ -- (optional, comma-separated category-group names to include;
338
+ -- exclusive with @exclude_category_groups)
339
+ -- @exclude_category_groups
340
+ -- (optional, comma-separated category-group names to exclude;
341
+ -- exclusive with @include_category_groups)
342
+ --
343
+ -- Example:
344
+ -- sqlite -header -box path/to/db.sqlite \
345
+ -- -cmd '.parameter init' \
346
+ -- -cmd ".parameter set @account_name_like %Savings%" \
347
+ -- -cmd ".parameter set @include_category_groups 'Home,Food'" \
348
+ -- < query.sql
349
+ CREATE TEMP TABLE excess_query_results AS
350
+ WITH params AS (
351
+ SELECT
352
+ TRIM(COALESCE(@account_name_like, '')) AS account_name_like
353
+ , TRIM(COALESCE(@plan_id, '')) AS plan_id
354
+ , TRIM(COALESCE(@include_category_groups, ''))
355
+ AS include_category_groups
356
+ , TRIM(COALESCE(@exclude_category_groups, ''))
357
+ AS exclude_category_groups
358
+ )
359
+
360
+ , scoped_plans AS (
361
+ SELECT
362
+ p.id
363
+ , p.name
364
+ FROM plans AS p
365
+ CROSS JOIN params AS prm
366
+ WHERE prm.plan_id = '' OR p.id = prm.plan_id
367
+ )
368
+
369
+ , split_include_category_groups (value, rest) AS (
370
+ SELECT
371
+ ''
372
+ , prm.include_category_groups || ','
373
+ FROM params AS prm
374
+ UNION ALL
375
+ SELECT
376
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
377
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
378
+ FROM split_include_category_groups
379
+ WHERE rest != ''
380
+ )
381
+
382
+ , include_category_groups AS (
383
+ SELECT value AS name
384
+ FROM split_include_category_groups
385
+ WHERE value != ''
386
+ )
387
+
388
+ , split_exclude_category_groups (value, rest) AS (
389
+ SELECT
390
+ ''
391
+ , prm.exclude_category_groups || ','
392
+ FROM params AS prm
393
+ UNION ALL
394
+ SELECT
395
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
396
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
397
+ FROM split_exclude_category_groups
398
+ WHERE rest != ''
399
+ )
400
+
401
+ , exclude_category_groups AS (
402
+ SELECT value AS name
403
+ FROM split_exclude_category_groups
404
+ WHERE value != ''
405
+ )
406
+
407
+ , matching_accounts AS (
408
+ SELECT
409
+ sp.id AS plan_id
410
+ , sp.name AS plan_name
411
+ , COUNT(*) AS matches
412
+ FROM scoped_plans AS sp
413
+ INNER JOIN accounts AS a ON sp.id = a.plan_id
414
+ CROSS JOIN params AS prm
415
+ WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
416
+ GROUP BY sp.id, sp.name
417
+ )
418
+
419
+ , validation AS (
420
+ SELECT
421
+ p.account_name_like
422
+ , p.plan_id
423
+ , p.include_category_groups
424
+ , p.exclude_category_groups
425
+ FROM params AS p
426
+ )
427
+
428
+ , validation_errors AS (
429
+ SELECT 'Set @account_name_like' AS error
430
+ FROM validation AS v
431
+ WHERE v.account_name_like = ''
432
+ UNION ALL
433
+ SELECT
434
+ 'Set only one of @include_category_groups'
435
+ || ' or @exclude_category_groups' AS error
436
+ FROM validation AS v
437
+ WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
438
+ UNION ALL
439
+ SELECT 'No plan matched @plan_id' AS error
440
+ FROM validation AS v
441
+ WHERE
442
+ v.plan_id != '' AND NOT EXISTS (
443
+ SELECT 1
444
+ FROM scoped_plans
445
+ )
446
+ UNION ALL
447
+ SELECT 'No account names matched @account_name_like' AS error
448
+ FROM validation AS v
449
+ WHERE
450
+ v.account_name_like != '' AND NOT EXISTS (
451
+ SELECT 1
452
+ FROM matching_accounts
453
+ )
454
+ UNION ALL
455
+ SELECT
456
+ 'Matched more than 1 account in plan: '
457
+ || ma.plan_name AS error
458
+ FROM matching_accounts AS ma
459
+ WHERE ma.matches > 1
460
+ UNION ALL
461
+ SELECT
462
+ 'Unknown include category group in plan '
463
+ || sp.name
464
+ || ': '
465
+ || icg.name AS error
466
+ FROM scoped_plans AS sp
467
+ CROSS JOIN include_category_groups AS icg
468
+ LEFT JOIN category_groups AS cg
469
+ ON
470
+ sp.id = cg.plan_id
471
+ AND NOT COALESCE(cg.deleted, 0)
472
+ AND LOWER(cg.name) = LOWER(icg.name)
473
+ WHERE cg.id IS NULL
474
+ UNION ALL
475
+ SELECT
476
+ 'Unknown exclude category group in plan '
477
+ || sp.name
478
+ || ': '
479
+ || ecg.name AS error
480
+ FROM scoped_plans AS sp
481
+ CROSS JOIN exclude_category_groups AS ecg
482
+ LEFT JOIN category_groups AS cg
483
+ ON
484
+ sp.id = cg.plan_id
485
+ AND NOT COALESCE(cg.deleted, 0)
486
+ AND LOWER(cg.name) = LOWER(ecg.name)
487
+ WHERE cg.id IS NULL
488
+ )
489
+
490
+ , valid_params AS (
491
+ SELECT
492
+ v.account_name_like
493
+ , v.plan_id
494
+ , v.include_category_groups
495
+ , v.exclude_category_groups
496
+ FROM validation AS v
497
+ WHERE NOT EXISTS (
498
+ SELECT 1
499
+ FROM validation_errors
500
+ )
501
+ )
502
+
503
+ , matched_accounts AS (
504
+ SELECT
505
+ p.id AS plan_id
506
+ , p.name AS plan_name
507
+ , a.name AS account_name
508
+ , a.cleared_balance / 1000.0 AS account_amount
509
+ FROM plans AS p
510
+ INNER JOIN accounts AS a ON p.id = a.plan_id
511
+ CROSS JOIN valid_params AS v
512
+ WHERE
513
+ TRUE
514
+ AND NOT a.deleted
515
+ AND a.name LIKE v.account_name_like
516
+ AND (v.plan_id = '' OR p.id = v.plan_id)
517
+ )
518
+
519
+ , category_totals AS (
520
+ SELECT
521
+ c.plan_id
522
+ , COALESCE(SUM(c.balance), 0) / 1000.0 AS total
523
+ FROM categories AS c CROSS JOIN valid_params AS v
524
+ WHERE
525
+ TRUE
526
+ AND NOT c.deleted
527
+ AND c.category_group_name != 'Credit Card Payments'
528
+ AND c.category_group_name != 'Internal Master Category'
529
+ AND (
530
+ v.include_category_groups = ''
531
+ OR EXISTS (
532
+ SELECT 1
533
+ FROM include_category_groups AS icg
534
+ WHERE LOWER(icg.name) = LOWER(c.category_group_name)
535
+ )
536
+ )
537
+ AND (
538
+ v.exclude_category_groups = ''
539
+ OR NOT EXISTS (
540
+ SELECT 1
541
+ FROM exclude_category_groups AS ecg
542
+ WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
543
+ )
544
+ )
545
+ AND (v.plan_id = '' OR c.plan_id = v.plan_id)
546
+ GROUP BY c.plan_id
547
+ )
548
+
549
+ SELECT
550
+ ve.error AS error_message
551
+ , NULL AS "plan"
552
+ , NULL AS account
553
+ , NULL AS total
554
+ , NULL AS excess
555
+ FROM validation_errors AS ve
556
+
557
+ UNION ALL
558
+
559
+ SELECT
560
+ NULL AS error_message
561
+ , ma.plan_name AS "plan"
562
+ , ma.account_name AS account
563
+ , PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
564
+ , PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
565
+ FROM matched_accounts AS ma
566
+ LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
567
+ ;
568
+
569
+ SELECT error_message
570
+ FROM excess_query_results
571
+ WHERE error_message IS NOT NULL
572
+ ;
573
+
574
+ SELECT
575
+ eqr."plan"
576
+ , eqr.account
577
+ , eqr.total
578
+ , eqr.excess
579
+ FROM excess_query_results AS eqr
580
+ WHERE
581
+ NOT EXISTS (
582
+ SELECT 1
583
+ FROM excess_query_results AS eqr_errors
584
+ WHERE eqr_errors.error_message IS NOT NULL
585
+ )
586
+ ;
587
+ ```
588
+
589
+ [^1]: This query is a rough estimate based on YNAB data and optional user inputs. It is not financial advice, tax advice, or a substitute for Forms 1099-INT, brokerage statements, bank records, or guidance from a qualified professional.