sqlite-export-for-ynab 1.6.2__tar.gz → 2.1.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.1.0/PKG-INFO +476 -0
  2. sqlite_export_for_ynab-2.1.0/README.md +456 -0
  3. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/pyproject.toml +12 -0
  4. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/setup.cfg +3 -3
  5. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/_main.py +66 -62
  6. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/ddl/create-relations.sql +77 -37
  7. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +1 -1
  8. sqlite_export_for_ynab-2.1.0/sqlite_export_for_ynab.egg-info/PKG-INFO +476 -0
  9. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/testing/fixtures.py +74 -10
  10. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/tests/_main_test.py +133 -63
  11. sqlite_export_for_ynab-1.6.2/PKG-INFO +0 -206
  12. sqlite_export_for_ynab-1.6.2/README.md +0 -186
  13. sqlite_export_for_ynab-1.6.2/sqlite_export_for_ynab.egg-info/PKG-INFO +0 -206
  14. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/LICENSE +0 -0
  15. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/setup.py +0 -0
  16. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/__init__.py +0 -0
  17. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/__main__.py +0 -0
  18. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  19. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab/py.typed +0 -0
  20. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  21. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  22. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  23. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab.egg-info/requires.txt +0 -0
  24. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  25. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/testing/__init__.py +0 -0
  26. {sqlite_export_for_ynab-1.6.2 → sqlite_export_for_ynab-2.1.0}/tests/__init__.py +0 -0
@@ -0,0 +1,476 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite_export_for_ynab
3
+ Version: 2.1.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
+
55
+ <a id="db-path"></a>You can specify the DB path with the following options
56
+ 1. The `--db` flag.
57
+ 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`.
58
+ 1. If neither is set, the DB is saved in `~/.local/share/sqlite-export-for-ynab/db.sqlite`.
59
+
60
+ ### Library
61
+
62
+ The library exposes the package `sqlite_export_for_ynab` and two functions - `default_db_path` and `sync`. You can use them as follows:
63
+
64
+ ```python
65
+ import asyncio
66
+ import os
67
+
68
+ from sqlite_export_for_ynab import default_db_path
69
+ from sqlite_export_for_ynab import sync
70
+
71
+ db = default_db_path()
72
+ token = os.environ["YNAB_PERSONAL_ACCESS_TOKEN"]
73
+ full_refresh = False
74
+
75
+ asyncio.run(sync(token, db, full_refresh))
76
+ ```
77
+
78
+ ## Relations
79
+
80
+ 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:
81
+
82
+ 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).
83
+ 1. Foreign keys are added as needed (ex: plan ID, transaction ID) so data across plans remains separate.
84
+ 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.
85
+
86
+ ## Querying
87
+
88
+ You can issue queries with typical SQLite tools. *`sqlite-export-for-ynab` deliberately does not implement a SQL REPL.*
89
+
90
+ ### Sample Queries
91
+
92
+ You can run the queries from this README using a tool like [`mdq`](https://github.com/yshavit/mdq). For example:
93
+
94
+ ```console
95
+ $ mdq '```sql dupes' path/to/sqlite-export-for-ynab/README.md -o plain \
96
+ | sqlite3 path/to/sqlite-export-for-ynab/db.sqlite
97
+ ```
98
+
99
+ The DB path is documented [above](#db-path).
100
+
101
+ To get the top 5 payees by spending per plan, you could do:
102
+
103
+ ```sql
104
+ WITH ranked_payees AS (
105
+ SELECT
106
+ pl.name AS plan_name
107
+ , t.payee_name AS payee
108
+ , SUM(t.amount_currency) AS net_spent
109
+ , ROW_NUMBER()
110
+ OVER (PARTITION BY pl.id ORDER BY SUM(t.amount) ASC)
111
+ AS rnk
112
+ FROM flat_transactions AS t INNER JOIN plans AS pl ON t.plan_id = pl.id
113
+ WHERE
114
+ t.payee_name != 'Starting Balance' AND t.transfer_account_id IS NULL
115
+ GROUP BY pl.id, t.payee_id
116
+ )
117
+
118
+ SELECT
119
+ plan_name
120
+ , payee
121
+ , net_spent
122
+ FROM ranked_payees
123
+ WHERE rnk <= 5
124
+ ORDER BY plan_name ASC, net_spent DESC
125
+ ;
126
+ ```
127
+
128
+ To get duplicate payees, or payees with no transactions:
129
+
130
+ ```sql
131
+ WITH used_payees AS (
132
+ SELECT
133
+ plan_id
134
+ , payee_id
135
+ FROM transactions
136
+ WHERE
137
+ TRUE
138
+ AND payee_id IS NOT NULL
139
+ AND NOT deleted
140
+ UNION
141
+ SELECT
142
+ plan_id
143
+ , payee_id
144
+ FROM subtransactions
145
+ WHERE
146
+ TRUE
147
+ AND payee_id IS NOT NULL
148
+ AND NOT deleted
149
+ UNION
150
+ SELECT
151
+ plan_id
152
+ , payee_id
153
+ FROM scheduled_transactions
154
+ WHERE
155
+ TRUE
156
+ AND payee_id IS NOT NULL
157
+ AND NOT deleted
158
+ UNION
159
+ SELECT
160
+ plan_id
161
+ , payee_id
162
+ FROM scheduled_subtransactions
163
+ WHERE
164
+ TRUE
165
+ AND payee_id IS NOT NULL
166
+ AND NOT deleted
167
+ )
168
+
169
+ SELECT
170
+ pl.name AS "plan"
171
+ , dupes.name AS payee
172
+ FROM (
173
+ SELECT
174
+ p.plan_id
175
+ , p.name
176
+ FROM payees AS p
177
+ LEFT JOIN used_payees AS up ON p.plan_id = up.plan_id AND p.id = up.payee_id
178
+ WHERE
179
+ TRUE
180
+ AND up.payee_id IS NULL
181
+ AND p.transfer_account_id IS NULL
182
+ AND p.name != 'Reconciliation Balance Adjustment'
183
+ AND p.name != 'Manual Balance Adjustment'
184
+ AND NOT p.deleted
185
+ UNION
186
+ SELECT
187
+ plan_id
188
+ , name
189
+ FROM payees
190
+ WHERE NOT deleted
191
+ GROUP BY plan_id, name
192
+ HAVING COUNT(*) > 1
193
+ ) AS dupes
194
+ INNER JOIN plans AS pl ON dupes.plan_id = pl.id
195
+ ORDER BY "plan", payee
196
+ ;
197
+ ```
198
+
199
+ To count the spend for a category (ex: "Apps") between this month and the next 11 months (inclusive):
200
+
201
+ ```sql
202
+ SELECT
203
+ plan_id
204
+ , SUM(amount_currency) AS amount_currency
205
+ FROM (
206
+ SELECT
207
+ plan_id
208
+ , amount_currency
209
+ FROM flat_transactions
210
+ WHERE
211
+ category_name = 'Apps'
212
+ AND SUBSTR("date", 1, 7) = SUBSTR(DATE(), 1, 7)
213
+ UNION ALL
214
+ SELECT
215
+ plan_id
216
+ , amount_currency * (
217
+ CASE
218
+ WHEN frequency = 'monthly' THEN 11
219
+ ELSE 1 -- assumes yearly
220
+ END
221
+ ) AS amount_currency
222
+ FROM scheduled_flat_transactions
223
+ WHERE
224
+ category_name = 'Apps'
225
+ AND SUBSTR(date_next, 1, 7) < SUBSTR(DATE('now', '+1 year'), 1, 7)
226
+ )
227
+ ;
228
+ ```
229
+
230
+ To estimate taxable interest for a given year[^1]:
231
+
232
+ ```sql
233
+ -- Parameters expected by this query:
234
+ -- @tax_rate
235
+ -- @year
236
+ -- @plan_id (optional, defaults to output for all plans)
237
+ -- @estimated_additional_interest (optional,
238
+ -- estimated interest not in YNAB such as investment income)
239
+ -- @interest_reporting_threshold (optional, defaults to the $10
240
+ -- common threshold, but confirm with actual documents)
241
+ -- @interest_payee_name (optional, defaults to Interest)
242
+ --
243
+ -- Example with only required params:
244
+ -- sqlite3 -header -box path/to/db.sqlite \
245
+ -- -cmd '.parameter init' \
246
+ -- -cmd ".parameter set @tax_rate 0.25" \
247
+ -- -cmd ".parameter set @year 2025" \
248
+ -- < query.sql
249
+ --
250
+ -- Example with all params:
251
+ -- -cmd ".parameter set @tax_rate 0.25" \
252
+ -- -cmd ".parameter set @year 2025" \
253
+ -- -cmd ".parameter set @estimated_additional_interest 250.00" \
254
+ -- -cmd ".parameter set @interest_reporting_threshold 10" \
255
+ -- -cmd ".parameter set @interest_payee_name Interest" \
256
+ -- -cmd ".parameter set @plan_id your-plan-id" \
257
+ -- < query.sql
258
+
259
+ WITH interest_by_account AS (
260
+ SELECT
261
+ plan_id
262
+ , account_name
263
+ , SUM(-amount_currency) AS total
264
+ FROM flat_transactions
265
+ WHERE
266
+ TRUE
267
+ AND payee_name = COALESCE(NULLIF(@interest_payee_name, ''), 'Interest')
268
+ AND SUBSTR("date", 1, 4) = CAST(@year AS TEXT)
269
+ AND (COALESCE(@plan_id, '') = '' OR plan_id = @plan_id)
270
+ GROUP BY plan_id, account_name
271
+ HAVING total >= CAST(COALESCE(@interest_reporting_threshold, 10) AS REAL)
272
+ )
273
+
274
+ , interest_by_plan AS (
275
+ SELECT
276
+ plans.id AS plan_id
277
+ , plans.name AS plan_name
278
+ , COALESCE(SUM(interest_by_account.total), 0) AS interest_in_ynab
279
+ FROM plans
280
+ LEFT JOIN interest_by_account ON plans.id = interest_by_account.plan_id
281
+ WHERE COALESCE(@plan_id, '') = '' OR plans.id = @plan_id
282
+ GROUP BY plan_id, plan_name
283
+ )
284
+
285
+ , ranked_interest AS (
286
+ SELECT
287
+ plan_id
288
+ , plan_name
289
+ , interest_in_ynab
290
+ , interest_in_ynab
291
+ + CAST(COALESCE(@estimated_additional_interest, 0) AS REAL)
292
+ AS interest_with_estimate
293
+ , ROW_NUMBER() OVER (ORDER BY plan_name, plan_id) AS row_num
294
+ FROM interest_by_plan
295
+ )
296
+
297
+ , estimated_interest AS (
298
+ SELECT
299
+ plan_id
300
+ , plan_name
301
+ , interest_in_ynab
302
+ -- Additional interest is per-tax-return not per-YNAB-plan. Only add
303
+ -- additional interest to one plan's output to avoid double counting.
304
+ , CASE
305
+ WHEN row_num != 1 THEN interest_in_ynab
306
+ WHEN
307
+ interest_with_estimate
308
+ < CAST(COALESCE(@interest_reporting_threshold, 10) AS REAL)
309
+ THEN 0
310
+ ELSE interest_with_estimate
311
+ END AS estimated_total_taxable_interest
312
+ FROM ranked_interest
313
+ )
314
+
315
+ SELECT
316
+ plan_name AS "plan"
317
+ , PRINTF('%.2f', interest_in_ynab) AS interest_in_ynab
318
+ , PRINTF('%.2f', estimated_total_taxable_interest)
319
+ AS estimated_total_taxable_interest
320
+ , PRINTF(
321
+ '%.2f'
322
+ , estimated_total_taxable_interest * CAST(NULLIF(@tax_rate, '') AS REAL)
323
+ ) AS estimated_tax_liability
324
+ FROM estimated_interest
325
+ ORDER BY plan_name, plan_id
326
+ ;
327
+ ```
328
+
329
+ To compare assigned category values to a given account's balance:
330
+
331
+ ```sql
332
+ -- Parameters expected by this query:
333
+ -- @account_name_like (required, the account name to match against)
334
+ -- @plan_id (optional, defaults to output for all matching plans)
335
+ -- @include_category_groups
336
+ -- (optional, comma-separated category-group names to include;
337
+ -- exclusive with @exclude_category_groups)
338
+ -- @exclude_category_groups
339
+ -- (optional, comma-separated category-group names to exclude;
340
+ -- exclusive with @include_category_groups)
341
+ --
342
+ -- Example:
343
+ -- sqlite -header -box path/to/db.sqlite \
344
+ -- -cmd '.parameter init' \
345
+ -- -cmd ".parameter set @account_name_like %Savings%" \
346
+ -- -cmd ".parameter set @include_category_groups 'Home,Food'" \
347
+ -- < query.sql
348
+ WITH params AS (
349
+ SELECT
350
+ TRIM(COALESCE(@account_name_like, '')) AS account_name_like
351
+ , TRIM(COALESCE(@include_category_groups, ''))
352
+ AS include_category_groups
353
+ , TRIM(COALESCE(@exclude_category_groups, ''))
354
+ AS exclude_category_groups
355
+ )
356
+
357
+ , validation AS (
358
+ SELECT 'Set @account_name_like' AS error
359
+ FROM params AS p
360
+ WHERE p.account_name_like = ''
361
+ UNION ALL
362
+ SELECT
363
+ 'Set only one of @include_category_groups'
364
+ || ' or @exclude_category_groups' AS error
365
+ FROM params AS p
366
+ WHERE p.include_category_groups != '' AND p.exclude_category_groups != ''
367
+ )
368
+
369
+ SELECT v.error AS error_message
370
+ FROM validation AS v
371
+ WHERE v.error IS NOT NULL
372
+ ;
373
+
374
+ WITH params AS (
375
+ SELECT
376
+ TRIM(COALESCE(@account_name_like, '')) AS account_name_like
377
+ , TRIM(COALESCE(@include_category_groups, ''))
378
+ AS include_category_groups
379
+ , TRIM(COALESCE(@exclude_category_groups, ''))
380
+ AS exclude_category_groups
381
+ )
382
+
383
+ , validation AS (
384
+ SELECT
385
+ p.account_name_like
386
+ , p.include_category_groups
387
+ , p.exclude_category_groups
388
+ FROM params AS p
389
+ )
390
+
391
+ , validation_errors AS (
392
+ SELECT 'Set @account_name_like' AS error
393
+ FROM validation AS v
394
+ WHERE v.account_name_like = ''
395
+ UNION ALL
396
+ SELECT
397
+ 'Set only one of @include_category_groups'
398
+ || ' or @exclude_category_groups' AS error
399
+ FROM validation AS v
400
+ WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
401
+ )
402
+
403
+ , valid_params AS (
404
+ SELECT
405
+ v.account_name_like
406
+ , v.include_category_groups
407
+ , v.exclude_category_groups
408
+ FROM validation AS v
409
+ WHERE NOT EXISTS (
410
+ SELECT 1
411
+ FROM validation_errors
412
+ )
413
+ )
414
+
415
+ , matched_accounts AS (
416
+ SELECT
417
+ p.id AS plan_id
418
+ , p.name AS plan_name
419
+ , a.name AS account_name
420
+ , a.cleared_balance / 1000.0 AS account_amount
421
+ FROM plans AS p
422
+ INNER JOIN accounts AS a ON p.id = a.plan_id
423
+ CROSS JOIN valid_params AS v
424
+ WHERE
425
+ TRUE
426
+ AND NOT a.deleted
427
+ AND a.name LIKE v.account_name_like
428
+ AND (COALESCE(@plan_id, '') = '' OR p.id = @plan_id)
429
+ )
430
+
431
+ , category_totals AS (
432
+ SELECT
433
+ c.plan_id
434
+ , COALESCE(SUM(c.balance), 0) / 1000.0 AS total
435
+ FROM categories AS c CROSS JOIN valid_params AS v
436
+ WHERE
437
+ TRUE
438
+ AND NOT c.deleted
439
+ AND c.category_group_name != 'Credit Card Payments'
440
+ AND c.category_group_name != 'Internal Master Category'
441
+ AND (
442
+ v.include_category_groups = ''
443
+ OR INSTR(
444
+ ','
445
+ || LOWER(REPLACE(v.include_category_groups, ', ', ','))
446
+ || ','
447
+ , ',' || LOWER(c.category_group_name) || ','
448
+ )
449
+ > 0
450
+ )
451
+ AND (
452
+ v.exclude_category_groups = ''
453
+ OR INSTR(
454
+ ','
455
+ || LOWER(REPLACE(v.exclude_category_groups, ', ', ','))
456
+ || ','
457
+ , ',' || LOWER(c.category_group_name) || ','
458
+ )
459
+ = 0
460
+ )
461
+ AND (COALESCE(@plan_id, '') = '' OR c.plan_id = @plan_id)
462
+ GROUP BY c.plan_id
463
+ )
464
+
465
+ SELECT
466
+ ma.plan_name AS "plan"
467
+ , ma.account_name AS account
468
+ , PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
469
+ , PRINTF('%.2f', COALESCE(ct.total, 0) - ma.account_amount) AS excess
470
+ FROM matched_accounts AS ma
471
+ LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
472
+ ORDER BY "plan", account
473
+ ;
474
+ ```
475
+
476
+ [^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.