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.
- sqlite_export_for_ynab-2.2.0/PKG-INFO +589 -0
- sqlite_export_for_ynab-2.2.0/README.md +569 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/pyproject.toml +12 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/setup.cfg +1 -1
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/_main.py +92 -38
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/create-relations.sql +48 -10
- sqlite_export_for_ynab-2.2.0/sqlite_export_for_ynab.egg-info/PKG-INFO +589 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/testing/fixtures.py +67 -4
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/tests/_main_test.py +162 -9
- sqlite_export_for_ynab-2.0.0/PKG-INFO +0 -207
- sqlite_export_for_ynab-2.0.0/README.md +0 -187
- sqlite_export_for_ynab-2.0.0/sqlite_export_for_ynab.egg-info/PKG-INFO +0 -207
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/requires.txt +0 -0
- {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
- {sqlite_export_for_ynab-2.0.0 → sqlite_export_for_ynab-2.2.0}/testing/__init__.py +0 -0
- {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
|
+
[](https://results.pre-commit.ci/latest/github/mxr/sqlite-export-for-ynab/main) [](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.
|