daftarche 0.2.0__py3-none-any.whl
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.
- daftarche/__init__.py +8 -0
- daftarche/__main__.py +7 -0
- daftarche/app_info.py +35 -0
- daftarche/backup.py +41 -0
- daftarche/chart_utils.py +271 -0
- daftarche/data/icons/app_icon.png +0 -0
- daftarche/data/icons/splash.png +0 -0
- daftarche/db.py +1824 -0
- daftarche/main.py +135 -0
- daftarche/paths.py +46 -0
- daftarche/style.py +593 -0
- daftarche/tabs/__init__.py +0 -0
- daftarche/tabs/accounts.py +971 -0
- daftarche/tabs/charts.py +986 -0
- daftarche/tabs/dashboard.py +709 -0
- daftarche/tabs/import_tab.py +1069 -0
- daftarche/tabs/manage.py +1065 -0
- daftarche/tabs/transactions.py +1636 -0
- daftarche/widgets.py +1131 -0
- daftarche/window.py +349 -0
- daftarche-0.2.0.dist-info/METADATA +100 -0
- daftarche-0.2.0.dist-info/RECORD +26 -0
- daftarche-0.2.0.dist-info/WHEEL +5 -0
- daftarche-0.2.0.dist-info/entry_points.txt +2 -0
- daftarche-0.2.0.dist-info/licenses/LICENSE +21 -0
- daftarche-0.2.0.dist-info/top_level.txt +1 -0
daftarche/db.py
ADDED
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, date as _date
|
|
6
|
+
import calendar
|
|
7
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
8
|
+
from daftarche.style import TAB20_COLORS
|
|
9
|
+
|
|
10
|
+
TRANSFER_MATCHED = "matched"
|
|
11
|
+
TRANSFER_UNMATCHED = "unmatched"
|
|
12
|
+
|
|
13
|
+
UNCATEGORIZED_CAT = "Uncategorized"
|
|
14
|
+
UNCATEGORIZED_SUB = "Uncategorized"
|
|
15
|
+
|
|
16
|
+
CAT_INTERNAL_TRANSFER = "Internal Transfer"
|
|
17
|
+
CAT_EXTERNAL_TRANSFER = "External Transfer"
|
|
18
|
+
SUB_MATCHED = "✅ Matched"
|
|
19
|
+
SUB_UNMATCHED = "⚠️ Unmatched"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def round2(value):
|
|
23
|
+
"""Round to 2 decimal places using standard rounding (0.5 rounds up)."""
|
|
24
|
+
return float(Decimal(str(value)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
MONTHS_ORDER = [
|
|
28
|
+
"January", "February", "March", "April", "May", "June",
|
|
29
|
+
"July", "August", "September", "October", "November", "December"
|
|
30
|
+
]
|
|
31
|
+
MONTH_NUM = {m: i + 1 for i, m in enumerate(MONTHS_ORDER)}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ymd_to_date_str(year, month, day):
|
|
35
|
+
"""Convert year (int), month (name str), day (int) -> 'YYYY-MM-DD'."""
|
|
36
|
+
mn = MONTH_NUM.get(month, 1)
|
|
37
|
+
return f"{int(year):04d}-{mn:02d}-{int(day):02d}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _date_str_to_parts(date_str):
|
|
41
|
+
"""Convert 'YYYY-MM-DD' -> (year int, month_name str, day int)."""
|
|
42
|
+
if not date_str:
|
|
43
|
+
return None, None, None
|
|
44
|
+
try:
|
|
45
|
+
d = _date.fromisoformat(date_str)
|
|
46
|
+
return d.year, MONTHS_ORDER[d.month - 1], d.day
|
|
47
|
+
except Exception:
|
|
48
|
+
return None, None, None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Database:
|
|
52
|
+
def __init__(self, path):
|
|
53
|
+
self.path = path
|
|
54
|
+
self.conn = sqlite3.connect(path, check_same_thread=False)
|
|
55
|
+
self.conn.row_factory = sqlite3.Row
|
|
56
|
+
self.conn.execute("PRAGMA foreign_keys = ON")
|
|
57
|
+
self._create_tables()
|
|
58
|
+
self._migrate()
|
|
59
|
+
|
|
60
|
+
# ── Schema ────────────────────────────────────────────────────
|
|
61
|
+
def _create_tables(self):
|
|
62
|
+
self.conn.executescript("""
|
|
63
|
+
CREATE TABLE IF NOT EXISTS types
|
|
64
|
+
(
|
|
65
|
+
id
|
|
66
|
+
INTEGER
|
|
67
|
+
PRIMARY
|
|
68
|
+
KEY
|
|
69
|
+
AUTOINCREMENT,
|
|
70
|
+
name
|
|
71
|
+
TEXT
|
|
72
|
+
UNIQUE
|
|
73
|
+
NOT
|
|
74
|
+
NULL
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS categories
|
|
78
|
+
(
|
|
79
|
+
id
|
|
80
|
+
INTEGER
|
|
81
|
+
PRIMARY
|
|
82
|
+
KEY
|
|
83
|
+
AUTOINCREMENT,
|
|
84
|
+
name
|
|
85
|
+
TEXT
|
|
86
|
+
NOT
|
|
87
|
+
NULL,
|
|
88
|
+
type_id
|
|
89
|
+
INTEGER
|
|
90
|
+
REFERENCES
|
|
91
|
+
types
|
|
92
|
+
(
|
|
93
|
+
id
|
|
94
|
+
),
|
|
95
|
+
UNIQUE
|
|
96
|
+
(
|
|
97
|
+
name,
|
|
98
|
+
type_id
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS subcategories
|
|
103
|
+
(
|
|
104
|
+
id
|
|
105
|
+
INTEGER
|
|
106
|
+
PRIMARY
|
|
107
|
+
KEY
|
|
108
|
+
AUTOINCREMENT,
|
|
109
|
+
name
|
|
110
|
+
TEXT
|
|
111
|
+
NOT
|
|
112
|
+
NULL,
|
|
113
|
+
category_id
|
|
114
|
+
INTEGER
|
|
115
|
+
NOT
|
|
116
|
+
NULL
|
|
117
|
+
REFERENCES
|
|
118
|
+
categories
|
|
119
|
+
(
|
|
120
|
+
id
|
|
121
|
+
) ON DELETE CASCADE,
|
|
122
|
+
UNIQUE
|
|
123
|
+
(
|
|
124
|
+
name,
|
|
125
|
+
category_id
|
|
126
|
+
)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS accounts
|
|
130
|
+
(
|
|
131
|
+
id
|
|
132
|
+
INTEGER
|
|
133
|
+
PRIMARY
|
|
134
|
+
KEY
|
|
135
|
+
AUTOINCREMENT,
|
|
136
|
+
name
|
|
137
|
+
TEXT
|
|
138
|
+
UNIQUE
|
|
139
|
+
NOT
|
|
140
|
+
NULL,
|
|
141
|
+
color
|
|
142
|
+
TEXT
|
|
143
|
+
DEFAULT
|
|
144
|
+
'#7c6af7',
|
|
145
|
+
active
|
|
146
|
+
INTEGER
|
|
147
|
+
DEFAULT
|
|
148
|
+
1,
|
|
149
|
+
visible
|
|
150
|
+
INTEGER
|
|
151
|
+
DEFAULT
|
|
152
|
+
1,
|
|
153
|
+
show_in_accounts
|
|
154
|
+
INTEGER
|
|
155
|
+
DEFAULT
|
|
156
|
+
1
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
CREATE TABLE IF NOT EXISTS transactions
|
|
160
|
+
(
|
|
161
|
+
id
|
|
162
|
+
INTEGER
|
|
163
|
+
PRIMARY
|
|
164
|
+
KEY
|
|
165
|
+
AUTOINCREMENT,
|
|
166
|
+
date
|
|
167
|
+
TEXT
|
|
168
|
+
NOT
|
|
169
|
+
NULL,
|
|
170
|
+
type_id
|
|
171
|
+
INTEGER
|
|
172
|
+
REFERENCES
|
|
173
|
+
types
|
|
174
|
+
(
|
|
175
|
+
id
|
|
176
|
+
),
|
|
177
|
+
account_id INTEGER REFERENCES accounts
|
|
178
|
+
(
|
|
179
|
+
id
|
|
180
|
+
),
|
|
181
|
+
to_account_id INTEGER REFERENCES accounts
|
|
182
|
+
(
|
|
183
|
+
id
|
|
184
|
+
),
|
|
185
|
+
amount REAL NOT NULL,
|
|
186
|
+
subcategory_id INTEGER REFERENCES subcategories
|
|
187
|
+
(
|
|
188
|
+
id
|
|
189
|
+
),
|
|
190
|
+
category_id INTEGER REFERENCES categories
|
|
191
|
+
(
|
|
192
|
+
id
|
|
193
|
+
),
|
|
194
|
+
name TEXT,
|
|
195
|
+
memorandum TEXT,
|
|
196
|
+
description TEXT,
|
|
197
|
+
fitid TEXT,
|
|
198
|
+
transfer_status TEXT DEFAULT NULL,
|
|
199
|
+
created_at TEXT DEFAULT
|
|
200
|
+
(
|
|
201
|
+
datetime
|
|
202
|
+
(
|
|
203
|
+
'now'
|
|
204
|
+
))
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
CREATE TABLE IF NOT EXISTS import_mappings
|
|
208
|
+
(
|
|
209
|
+
id
|
|
210
|
+
INTEGER
|
|
211
|
+
PRIMARY
|
|
212
|
+
KEY
|
|
213
|
+
AUTOINCREMENT,
|
|
214
|
+
type_id
|
|
215
|
+
INTEGER
|
|
216
|
+
REFERENCES
|
|
217
|
+
types
|
|
218
|
+
(
|
|
219
|
+
id
|
|
220
|
+
),
|
|
221
|
+
account_id INTEGER REFERENCES accounts
|
|
222
|
+
(
|
|
223
|
+
id
|
|
224
|
+
),
|
|
225
|
+
name TEXT,
|
|
226
|
+
memorandum TEXT,
|
|
227
|
+
subcategory_id INTEGER REFERENCES subcategories
|
|
228
|
+
(
|
|
229
|
+
id
|
|
230
|
+
) ON DELETE CASCADE,
|
|
231
|
+
category_id INTEGER REFERENCES categories
|
|
232
|
+
(
|
|
233
|
+
id
|
|
234
|
+
),
|
|
235
|
+
UNIQUE
|
|
236
|
+
(
|
|
237
|
+
type_id,
|
|
238
|
+
account_id,
|
|
239
|
+
name,
|
|
240
|
+
memorandum
|
|
241
|
+
)
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
CREATE TABLE IF NOT EXISTS account_ids
|
|
245
|
+
(
|
|
246
|
+
id
|
|
247
|
+
INTEGER
|
|
248
|
+
PRIMARY
|
|
249
|
+
KEY
|
|
250
|
+
AUTOINCREMENT,
|
|
251
|
+
acctid
|
|
252
|
+
TEXT
|
|
253
|
+
UNIQUE
|
|
254
|
+
NOT
|
|
255
|
+
NULL,
|
|
256
|
+
accttype
|
|
257
|
+
TEXT,
|
|
258
|
+
bankid
|
|
259
|
+
TEXT,
|
|
260
|
+
account_id
|
|
261
|
+
INTEGER
|
|
262
|
+
REFERENCES
|
|
263
|
+
accounts
|
|
264
|
+
(
|
|
265
|
+
id
|
|
266
|
+
)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
CREATE INDEX IF NOT EXISTS idx_tx_type ON transactions(type_id);
|
|
270
|
+
CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions(category_id);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_tx_account ON transactions(account_id);
|
|
272
|
+
CREATE INDEX IF NOT EXISTS idx_tx_status ON transactions(transfer_status);
|
|
273
|
+
|
|
274
|
+
CREATE TABLE IF NOT EXISTS tags
|
|
275
|
+
(
|
|
276
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
277
|
+
name TEXT UNIQUE NOT NULL
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
CREATE TABLE IF NOT EXISTS transaction_tags
|
|
281
|
+
(
|
|
282
|
+
transaction_id INTEGER NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
|
|
283
|
+
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
|
284
|
+
PRIMARY KEY (transaction_id, tag_id)
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
CREATE INDEX IF NOT EXISTS idx_tx_tags_tx ON transaction_tags(transaction_id);
|
|
288
|
+
CREATE INDEX IF NOT EXISTS idx_tx_tags_tag ON transaction_tags(tag_id);
|
|
289
|
+
""")
|
|
290
|
+
self.conn.commit()
|
|
291
|
+
|
|
292
|
+
def _migrate(self):
|
|
293
|
+
"""Seed default types/categories; handle schema upgrades for existing DBs."""
|
|
294
|
+
c = self.conn
|
|
295
|
+
|
|
296
|
+
if not c.execute("SELECT 1 FROM types LIMIT 1").fetchone():
|
|
297
|
+
for t in ["Income", "Expense", "Transfer"]:
|
|
298
|
+
c.execute("INSERT OR IGNORE INTO types(name) VALUES(?)", (t,))
|
|
299
|
+
c.commit()
|
|
300
|
+
|
|
301
|
+
def add_col_if_missing(table, col, typedef):
|
|
302
|
+
cols = [r[1] for r in c.execute(f"PRAGMA table_info({table})").fetchall()]
|
|
303
|
+
if col not in cols:
|
|
304
|
+
c.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
|
305
|
+
|
|
306
|
+
# Migrate old year/month/day/weekday columns to single date column
|
|
307
|
+
tx_cols = [r[1] for r in c.execute("PRAGMA table_info(transactions)").fetchall()]
|
|
308
|
+
if "year" in tx_cols and "date" not in tx_cols:
|
|
309
|
+
# Add date column
|
|
310
|
+
c.execute("ALTER TABLE transactions ADD COLUMN date TEXT")
|
|
311
|
+
# Populate from existing y/m/d
|
|
312
|
+
month_case = " ".join(f"WHEN '{m}' THEN {i}" for i, m in enumerate(MONTHS_ORDER, 1))
|
|
313
|
+
c.execute(f"""
|
|
314
|
+
UPDATE transactions
|
|
315
|
+
SET date = printf('%04d-%02d-%02d',
|
|
316
|
+
year,
|
|
317
|
+
CASE month {month_case} ELSE 1 END,
|
|
318
|
+
day)
|
|
319
|
+
""")
|
|
320
|
+
c.commit()
|
|
321
|
+
# Remove old columns via table rebuild
|
|
322
|
+
c.executescript("""
|
|
323
|
+
CREATE TABLE transactions_new
|
|
324
|
+
(
|
|
325
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
326
|
+
date TEXT NOT NULL DEFAULT '2026-01-01',
|
|
327
|
+
type_id INTEGER REFERENCES types (id),
|
|
328
|
+
account_id INTEGER REFERENCES accounts (id),
|
|
329
|
+
to_account_id INTEGER REFERENCES accounts (id),
|
|
330
|
+
amount REAL NOT NULL DEFAULT 0,
|
|
331
|
+
subcategory_id INTEGER REFERENCES subcategories (id),
|
|
332
|
+
category_id INTEGER REFERENCES categories (id),
|
|
333
|
+
name TEXT,
|
|
334
|
+
memorandum TEXT,
|
|
335
|
+
description TEXT,
|
|
336
|
+
fitid TEXT,
|
|
337
|
+
transfer_status TEXT DEFAULT NULL,
|
|
338
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
339
|
+
);
|
|
340
|
+
INSERT INTO transactions_new
|
|
341
|
+
(id, date, type_id, account_id, to_account_id, amount,
|
|
342
|
+
subcategory_id, category_id, name, memorandum, description,
|
|
343
|
+
fitid, transfer_status, created_at)
|
|
344
|
+
SELECT id, date, type_id, account_id, to_account_id, amount, subcategory_id, category_id, name, memorandum, description, fitid, transfer_status, created_at
|
|
345
|
+
FROM transactions;
|
|
346
|
+
DROP TABLE transactions;
|
|
347
|
+
ALTER TABLE transactions_new RENAME TO transactions;
|
|
348
|
+
CREATE INDEX IF NOT EXISTS idx_tx_date ON transactions(date);
|
|
349
|
+
CREATE INDEX IF NOT EXISTS idx_tx_type ON transactions(type_id);
|
|
350
|
+
CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions(category_id);
|
|
351
|
+
CREATE INDEX IF NOT EXISTS idx_tx_account ON transactions(account_id);
|
|
352
|
+
CREATE INDEX IF NOT EXISTS idx_tx_status ON transactions(transfer_status);
|
|
353
|
+
""")
|
|
354
|
+
c.commit()
|
|
355
|
+
elif "date" not in tx_cols:
|
|
356
|
+
add_col_if_missing("transactions", "date", "TEXT NOT NULL DEFAULT '2026-01-01'")
|
|
357
|
+
|
|
358
|
+
# Ensure date index exists (safe to run every time)
|
|
359
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_tx_date ON transactions(date)")
|
|
360
|
+
c.commit()
|
|
361
|
+
|
|
362
|
+
add_col_if_missing("transactions", "to_account_id", "INTEGER REFERENCES accounts(id)")
|
|
363
|
+
add_col_if_missing("transactions", "description", "TEXT")
|
|
364
|
+
add_col_if_missing("transactions", "type_id", "INTEGER REFERENCES types(id)")
|
|
365
|
+
add_col_if_missing("transactions", "transfer_status", "TEXT DEFAULT NULL")
|
|
366
|
+
add_col_if_missing("transactions", "name", "TEXT")
|
|
367
|
+
add_col_if_missing("transactions", "memorandum", "TEXT")
|
|
368
|
+
add_col_if_missing("transactions", "fitid", "TEXT")
|
|
369
|
+
add_col_if_missing("accounts", "color", "TEXT DEFAULT '#7c6af7'")
|
|
370
|
+
add_col_if_missing("accounts", "active", "INTEGER DEFAULT 1")
|
|
371
|
+
add_col_if_missing("accounts", "visible", "INTEGER DEFAULT 1")
|
|
372
|
+
add_col_if_missing("accounts", "show_in_accounts", "INTEGER DEFAULT 1")
|
|
373
|
+
|
|
374
|
+
existing_cols = [r[1] for r in c.execute("PRAGMA table_info(import_mappings)").fetchall()]
|
|
375
|
+
needs_rebuild = "keyword" in existing_cols or "name" not in existing_cols
|
|
376
|
+
if needs_rebuild:
|
|
377
|
+
c.executescript("""
|
|
378
|
+
CREATE TABLE IF NOT EXISTS import_mappings_new
|
|
379
|
+
(
|
|
380
|
+
id
|
|
381
|
+
INTEGER
|
|
382
|
+
PRIMARY
|
|
383
|
+
KEY
|
|
384
|
+
AUTOINCREMENT,
|
|
385
|
+
type_id
|
|
386
|
+
INTEGER
|
|
387
|
+
REFERENCES
|
|
388
|
+
types
|
|
389
|
+
(
|
|
390
|
+
id
|
|
391
|
+
),
|
|
392
|
+
account_id INTEGER REFERENCES accounts
|
|
393
|
+
(
|
|
394
|
+
id
|
|
395
|
+
),
|
|
396
|
+
name TEXT,
|
|
397
|
+
memorandum TEXT,
|
|
398
|
+
subcategory_id INTEGER REFERENCES subcategories
|
|
399
|
+
(
|
|
400
|
+
id
|
|
401
|
+
) ON DELETE CASCADE,
|
|
402
|
+
category_id INTEGER REFERENCES categories
|
|
403
|
+
(
|
|
404
|
+
id
|
|
405
|
+
),
|
|
406
|
+
UNIQUE
|
|
407
|
+
(
|
|
408
|
+
type_id,
|
|
409
|
+
account_id,
|
|
410
|
+
name,
|
|
411
|
+
memorandum
|
|
412
|
+
)
|
|
413
|
+
);
|
|
414
|
+
DROP TABLE IF EXISTS import_mappings;
|
|
415
|
+
ALTER TABLE import_mappings_new RENAME TO import_mappings;
|
|
416
|
+
""")
|
|
417
|
+
|
|
418
|
+
# Migrate categories to UNIQUE(name, type_id) if still using old UNIQUE(name)
|
|
419
|
+
cat_indices = [r[1] for r in c.execute("PRAGMA index_list(categories)").fetchall()]
|
|
420
|
+
# Check by trying to detect the old single-column unique index
|
|
421
|
+
cat_index_info = c.execute("PRAGMA index_list(categories)").fetchall()
|
|
422
|
+
needs_cat_rebuild = False
|
|
423
|
+
for idx_row in cat_index_info:
|
|
424
|
+
if idx_row[2]: # unique index
|
|
425
|
+
cols = c.execute(f"PRAGMA index_info('{idx_row[1]}')").fetchall()
|
|
426
|
+
if len(cols) == 1 and cols[0][2] == "name":
|
|
427
|
+
needs_cat_rebuild = True
|
|
428
|
+
break
|
|
429
|
+
if needs_cat_rebuild:
|
|
430
|
+
c.execute("PRAGMA foreign_keys = OFF")
|
|
431
|
+
c.execute("DROP TABLE IF EXISTS categories_new")
|
|
432
|
+
c.execute("""
|
|
433
|
+
CREATE TABLE categories_new
|
|
434
|
+
(
|
|
435
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
436
|
+
name TEXT NOT NULL,
|
|
437
|
+
type_id INTEGER REFERENCES types (id),
|
|
438
|
+
UNIQUE (name, type_id)
|
|
439
|
+
)
|
|
440
|
+
""")
|
|
441
|
+
c.execute("""
|
|
442
|
+
INSERT INTO categories_new (id, name, type_id)
|
|
443
|
+
SELECT id, name, type_id
|
|
444
|
+
FROM categories
|
|
445
|
+
""")
|
|
446
|
+
c.execute("DROP TABLE categories")
|
|
447
|
+
c.execute("ALTER TABLE categories_new RENAME TO categories")
|
|
448
|
+
c.commit()
|
|
449
|
+
c.execute("PRAGMA foreign_keys = ON")
|
|
450
|
+
|
|
451
|
+
# Tags tables — ensure indices exist for older DBs
|
|
452
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_tx_tags_tx ON transaction_tags(transaction_id)")
|
|
453
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_tx_tags_tag ON transaction_tags(tag_id)")
|
|
454
|
+
|
|
455
|
+
c.commit()
|
|
456
|
+
self._seed_uncategorized()
|
|
457
|
+
self._seed_transfer_categories()
|
|
458
|
+
|
|
459
|
+
def _seed_transfer_categories(self):
|
|
460
|
+
for cat_name in [CAT_INTERNAL_TRANSFER, CAT_EXTERNAL_TRANSFER]:
|
|
461
|
+
if not self.get_category_by_name(cat_name):
|
|
462
|
+
self.add_category(cat_name, "Transfer")
|
|
463
|
+
cat = self.get_category_by_name(cat_name)
|
|
464
|
+
if cat:
|
|
465
|
+
for sub_name in [SUB_MATCHED, SUB_UNMATCHED]:
|
|
466
|
+
if not self.get_subcategory_by_name(sub_name, cat["id"]):
|
|
467
|
+
self.add_subcategory(sub_name, cat["id"])
|
|
468
|
+
|
|
469
|
+
def _seed_uncategorized(self):
|
|
470
|
+
if not self.get_category_by_name(UNCATEGORIZED_CAT):
|
|
471
|
+
self.add_category(UNCATEGORIZED_CAT, None)
|
|
472
|
+
cat = self.get_category_by_name(UNCATEGORIZED_CAT)
|
|
473
|
+
if cat and not self.get_subcategory_by_name(UNCATEGORIZED_SUB, cat["id"]):
|
|
474
|
+
self.add_subcategory(UNCATEGORIZED_SUB, cat["id"])
|
|
475
|
+
|
|
476
|
+
def get_uncategorized_ids(self):
|
|
477
|
+
cat = self.get_category_by_name(UNCATEGORIZED_CAT)
|
|
478
|
+
if not cat:
|
|
479
|
+
self._seed_uncategorized()
|
|
480
|
+
cat = self.get_category_by_name(UNCATEGORIZED_CAT)
|
|
481
|
+
sub = self.get_subcategory_by_name(UNCATEGORIZED_SUB, cat["id"])
|
|
482
|
+
return cat["id"], sub["id"] if sub else None
|
|
483
|
+
|
|
484
|
+
def get_transfer_cat_sub(self, description="", matched=False,
|
|
485
|
+
from_account="", to_account=""):
|
|
486
|
+
BANK_KEYWORDS = ["CIBC", "TD", "RBC", "BMO", "SCOTIABANK", "TANGERINE",
|
|
487
|
+
"SIMPLII", "DESJARDINS", "NATIONAL", "HSBC", "ATB"]
|
|
488
|
+
|
|
489
|
+
def _bank(name):
|
|
490
|
+
name_upper = (name or "").upper()
|
|
491
|
+
for kw in BANK_KEYWORDS:
|
|
492
|
+
if kw in name_upper:
|
|
493
|
+
return kw
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
def _person(name):
|
|
497
|
+
return (name or "").split("-")[0].strip().upper()
|
|
498
|
+
|
|
499
|
+
is_internal = False
|
|
500
|
+
|
|
501
|
+
if "INTERNET TRANSFER" in (description or "").upper():
|
|
502
|
+
is_internal = True
|
|
503
|
+
|
|
504
|
+
if not is_internal and from_account and to_account:
|
|
505
|
+
b1, b2 = _bank(from_account), _bank(to_account)
|
|
506
|
+
p1, p2 = _person(from_account), _person(to_account)
|
|
507
|
+
if b1 and b1 == b2 and p1 and p1 == p2:
|
|
508
|
+
is_internal = True
|
|
509
|
+
|
|
510
|
+
cat_name = CAT_INTERNAL_TRANSFER if is_internal else CAT_EXTERNAL_TRANSFER
|
|
511
|
+
sub_name = SUB_MATCHED if matched else SUB_UNMATCHED
|
|
512
|
+
|
|
513
|
+
cat = self.get_category_by_name(cat_name)
|
|
514
|
+
if not cat:
|
|
515
|
+
self._seed_transfer_categories()
|
|
516
|
+
cat = self.get_category_by_name(cat_name)
|
|
517
|
+
|
|
518
|
+
sub = self.get_subcategory_by_name(sub_name, cat["id"])
|
|
519
|
+
if not sub:
|
|
520
|
+
self.add_subcategory(sub_name, cat["id"])
|
|
521
|
+
sub = self.get_subcategory_by_name(sub_name, cat["id"])
|
|
522
|
+
|
|
523
|
+
return cat["id"], sub["id"] if sub else None
|
|
524
|
+
|
|
525
|
+
# ── Types ─────────────────────────────────────────────────────
|
|
526
|
+
def get_types(self):
|
|
527
|
+
return self.conn.execute("SELECT * FROM types ORDER BY id").fetchall()
|
|
528
|
+
|
|
529
|
+
def get_type_by_name(self, name):
|
|
530
|
+
return self.conn.execute("SELECT * FROM types WHERE name=?", (name,)).fetchone()
|
|
531
|
+
|
|
532
|
+
def get_type_id(self, name):
|
|
533
|
+
row = self.get_type_by_name(name)
|
|
534
|
+
return row["id"] if row else None
|
|
535
|
+
|
|
536
|
+
def add_type(self, name):
|
|
537
|
+
try:
|
|
538
|
+
self.conn.execute("INSERT INTO types(name) VALUES(?)", (name,))
|
|
539
|
+
self.conn.commit()
|
|
540
|
+
return True
|
|
541
|
+
except sqlite3.IntegrityError:
|
|
542
|
+
return False
|
|
543
|
+
|
|
544
|
+
def rename_type(self, old_name, new_name):
|
|
545
|
+
self.conn.execute("UPDATE types SET name=? WHERE name=?", (new_name, old_name))
|
|
546
|
+
self.conn.commit()
|
|
547
|
+
|
|
548
|
+
def delete_type(self, name):
|
|
549
|
+
self.conn.execute("DELETE FROM types WHERE name=?", (name,))
|
|
550
|
+
self.conn.commit()
|
|
551
|
+
|
|
552
|
+
# ── Categories ────────────────────────────────────────────────
|
|
553
|
+
def get_categories(self, type_id=None):
|
|
554
|
+
if type_id:
|
|
555
|
+
return self.conn.execute(
|
|
556
|
+
"SELECT c.*, t.name as type_name FROM categories c LEFT JOIN types t ON c.type_id=t.id WHERE c.type_id=? ORDER BY c.name",
|
|
557
|
+
(type_id,)).fetchall()
|
|
558
|
+
return self.conn.execute(
|
|
559
|
+
"SELECT c.*, t.name as type_name FROM categories c LEFT JOIN types t ON c.type_id=t.id ORDER BY c.name"
|
|
560
|
+
).fetchall()
|
|
561
|
+
|
|
562
|
+
def get_category_by_name(self, name, type_id=None):
|
|
563
|
+
if type_id is not None:
|
|
564
|
+
return self.conn.execute(
|
|
565
|
+
"SELECT c.*, t.name as type_name FROM categories c LEFT JOIN types t ON c.type_id=t.id WHERE c.name=? AND c.type_id=?",
|
|
566
|
+
(name, type_id)).fetchone()
|
|
567
|
+
return self.conn.execute(
|
|
568
|
+
"SELECT c.*, t.name as type_name FROM categories c LEFT JOIN types t ON c.type_id=t.id WHERE c.name=? LIMIT 1",
|
|
569
|
+
(name,)).fetchone()
|
|
570
|
+
|
|
571
|
+
def get_category_by_id(self, id_):
|
|
572
|
+
return self.conn.execute(
|
|
573
|
+
"SELECT c.*, t.name as type_name FROM categories c LEFT JOIN types t ON c.type_id=t.id WHERE c.id=?",
|
|
574
|
+
(id_,)).fetchone()
|
|
575
|
+
|
|
576
|
+
def add_category(self, name, type_name=None):
|
|
577
|
+
try:
|
|
578
|
+
type_id = self.get_type_id(type_name) if type_name else None
|
|
579
|
+
self.conn.execute("INSERT INTO categories(name, type_id) VALUES(?,?)", (name, type_id))
|
|
580
|
+
self.conn.commit()
|
|
581
|
+
return True
|
|
582
|
+
except sqlite3.IntegrityError:
|
|
583
|
+
return False # already exists under same type
|
|
584
|
+
|
|
585
|
+
def rename_category(self, old_name, new_name):
|
|
586
|
+
old = self.get_category_by_name(old_name)
|
|
587
|
+
if not old:
|
|
588
|
+
return
|
|
589
|
+
existing = self.get_category_by_name(new_name, old["type_id"])
|
|
590
|
+
if existing and existing["id"] != old["id"]:
|
|
591
|
+
self.conn.execute(
|
|
592
|
+
"UPDATE transactions SET category_id=? WHERE category_id=?",
|
|
593
|
+
(existing["id"], old["id"]))
|
|
594
|
+
self.conn.execute(
|
|
595
|
+
"UPDATE subcategories SET category_id=? WHERE category_id=?",
|
|
596
|
+
(existing["id"], old["id"]))
|
|
597
|
+
self.conn.execute("DELETE FROM categories WHERE id=?", (old["id"],))
|
|
598
|
+
else:
|
|
599
|
+
self.conn.execute("UPDATE categories SET name=? WHERE id=?", (new_name, old["id"]))
|
|
600
|
+
self.conn.commit()
|
|
601
|
+
|
|
602
|
+
def delete_category(self, cat_id):
|
|
603
|
+
uncat_cat_id, uncat_sub_id = self.get_uncategorized_ids()
|
|
604
|
+
self.conn.execute("""
|
|
605
|
+
UPDATE transactions
|
|
606
|
+
SET category_id=?,
|
|
607
|
+
subcategory_id=?
|
|
608
|
+
WHERE category_id = ?
|
|
609
|
+
""", (uncat_cat_id, uncat_sub_id, cat_id))
|
|
610
|
+
self.conn.execute("DELETE FROM categories WHERE id=?", (cat_id,))
|
|
611
|
+
self.conn.commit()
|
|
612
|
+
|
|
613
|
+
# ── Subcategories ─────────────────────────────────────────────
|
|
614
|
+
def get_subcategories(self, category_id=None):
|
|
615
|
+
if category_id:
|
|
616
|
+
return self.conn.execute(
|
|
617
|
+
"SELECT s.*, c.name as cat_name FROM subcategories s JOIN categories c ON s.category_id=c.id WHERE s.category_id=? ORDER BY s.name",
|
|
618
|
+
(category_id,)).fetchall()
|
|
619
|
+
return self.conn.execute(
|
|
620
|
+
"SELECT s.*, c.name as cat_name FROM subcategories s JOIN categories c ON s.category_id=c.id ORDER BY c.name, s.name"
|
|
621
|
+
).fetchall()
|
|
622
|
+
|
|
623
|
+
def get_subcategory_by_name(self, name, category_id):
|
|
624
|
+
return self.conn.execute(
|
|
625
|
+
"SELECT * FROM subcategories WHERE name=? AND category_id=?", (name, category_id)
|
|
626
|
+
).fetchone()
|
|
627
|
+
|
|
628
|
+
def get_subcategory_by_id(self, id_):
|
|
629
|
+
return self.conn.execute(
|
|
630
|
+
"SELECT s.*, c.name as cat_name FROM subcategories s JOIN categories c ON s.category_id=c.id WHERE s.id=?",
|
|
631
|
+
(id_,)).fetchone()
|
|
632
|
+
|
|
633
|
+
def add_subcategory(self, name, category_id):
|
|
634
|
+
try:
|
|
635
|
+
self.conn.execute("INSERT INTO subcategories(name, category_id) VALUES(?,?)", (name, category_id))
|
|
636
|
+
self.conn.commit()
|
|
637
|
+
return True
|
|
638
|
+
except sqlite3.IntegrityError:
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
def rename_subcategory(self, subcat_id, new_name, new_category_id=None):
|
|
642
|
+
old = self.get_subcategory_by_id(subcat_id)
|
|
643
|
+
if not old:
|
|
644
|
+
return
|
|
645
|
+
target_cat_id = new_category_id if new_category_id else old["category_id"]
|
|
646
|
+
existing = self.get_subcategory_by_name(new_name, target_cat_id)
|
|
647
|
+
if existing and existing["id"] != subcat_id:
|
|
648
|
+
self.conn.execute(
|
|
649
|
+
"UPDATE transactions SET subcategory_id=? WHERE subcategory_id=?",
|
|
650
|
+
(existing["id"], subcat_id))
|
|
651
|
+
self.conn.execute("DELETE FROM subcategories WHERE id=?", (subcat_id,))
|
|
652
|
+
else:
|
|
653
|
+
self.conn.execute(
|
|
654
|
+
"UPDATE subcategories SET name=?, category_id=? WHERE id=?",
|
|
655
|
+
(new_name, target_cat_id, subcat_id))
|
|
656
|
+
self.conn.commit()
|
|
657
|
+
|
|
658
|
+
def reparent_subcategory(self, subcat_id, new_category_id):
|
|
659
|
+
old = self.get_subcategory_by_id(subcat_id)
|
|
660
|
+
if not old:
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
existing = self.get_subcategory_by_name(old["name"], new_category_id)
|
|
664
|
+
|
|
665
|
+
if existing:
|
|
666
|
+
self.conn.execute(
|
|
667
|
+
"UPDATE transactions SET subcategory_id=?, category_id=? WHERE subcategory_id=?",
|
|
668
|
+
(existing["id"], new_category_id, subcat_id))
|
|
669
|
+
self.conn.execute(
|
|
670
|
+
"UPDATE import_mappings SET subcategory_id=?, category_id=? WHERE subcategory_id=?",
|
|
671
|
+
(existing["id"], new_category_id, subcat_id))
|
|
672
|
+
self.conn.execute("DELETE FROM subcategories WHERE id=?", (subcat_id,))
|
|
673
|
+
else:
|
|
674
|
+
self.conn.execute(
|
|
675
|
+
"UPDATE subcategories SET category_id=? WHERE id=?",
|
|
676
|
+
(new_category_id, subcat_id))
|
|
677
|
+
self.conn.execute(
|
|
678
|
+
"UPDATE transactions SET category_id=? WHERE subcategory_id=?",
|
|
679
|
+
(new_category_id, subcat_id))
|
|
680
|
+
self.conn.execute(
|
|
681
|
+
"UPDATE import_mappings SET category_id=? WHERE subcategory_id=?",
|
|
682
|
+
(new_category_id, subcat_id))
|
|
683
|
+
|
|
684
|
+
self.conn.commit()
|
|
685
|
+
|
|
686
|
+
def delete_subcategory(self, subcat_id):
|
|
687
|
+
uncat_cat_id, uncat_sub_id = self.get_uncategorized_ids()
|
|
688
|
+
self.conn.execute("""
|
|
689
|
+
UPDATE transactions
|
|
690
|
+
SET category_id=?,
|
|
691
|
+
subcategory_id=?
|
|
692
|
+
WHERE subcategory_id = ?
|
|
693
|
+
""", (uncat_cat_id, uncat_sub_id, subcat_id))
|
|
694
|
+
self.conn.execute("DELETE FROM subcategories WHERE id=?", (subcat_id,))
|
|
695
|
+
self.conn.commit()
|
|
696
|
+
|
|
697
|
+
# ── Accounts ──────────────────────────────────────────────────
|
|
698
|
+
def get_accounts(self, active_only=False, visible_only=False):
|
|
699
|
+
q = "SELECT * FROM accounts"
|
|
700
|
+
conds = []
|
|
701
|
+
if active_only: conds.append("active=1")
|
|
702
|
+
if visible_only: conds.append("visible=1")
|
|
703
|
+
if conds: q += " WHERE " + " AND ".join(conds)
|
|
704
|
+
q += " ORDER BY name"
|
|
705
|
+
return self.conn.execute(q).fetchall()
|
|
706
|
+
|
|
707
|
+
def get_account_monthly_inout(self, account_id, type_filter, account_field="account_id"):
|
|
708
|
+
q = f"""
|
|
709
|
+
SELECT strftime('%Y', t.date) AS year,
|
|
710
|
+
strftime('%m', t.date) AS month_num,
|
|
711
|
+
SUM(t.amount) as total
|
|
712
|
+
FROM transactions t
|
|
713
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
714
|
+
WHERE t.{account_field} = ?
|
|
715
|
+
AND ty.name LIKE ?
|
|
716
|
+
GROUP BY year, month_num
|
|
717
|
+
"""
|
|
718
|
+
rows = self.conn.execute(q, (account_id, f"%{type_filter}%")).fetchall()
|
|
719
|
+
result = {}
|
|
720
|
+
for r in rows:
|
|
721
|
+
y = int(r["year"])
|
|
722
|
+
m = MONTHS_ORDER[int(r["month_num"]) - 1]
|
|
723
|
+
result[(y, m)] = r["total"] or 0
|
|
724
|
+
return result
|
|
725
|
+
|
|
726
|
+
def get_account_monthly_transfer_net(self, account_id):
|
|
727
|
+
"""Transfer In minus Transfer Out per month for one account."""
|
|
728
|
+
t_out = self.get_account_monthly_inout(account_id, "Transfer", "account_id")
|
|
729
|
+
t_in = self.get_account_monthly_inout(account_id, "Transfer", "to_account_id")
|
|
730
|
+
all_periods = set(t_out.keys()) | set(t_in.keys())
|
|
731
|
+
return {
|
|
732
|
+
p: round2(t_in.get(p, 0) - t_out.get(p, 0))
|
|
733
|
+
for p in all_periods
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
def get_account_by_name(self, name):
|
|
737
|
+
return self.conn.execute("SELECT * FROM accounts WHERE name=?", (name,)).fetchone()
|
|
738
|
+
|
|
739
|
+
def get_or_create_account(self, name):
|
|
740
|
+
row = self.conn.execute("SELECT id FROM accounts WHERE name=?", (name,)).fetchone()
|
|
741
|
+
if row:
|
|
742
|
+
return row["id"]
|
|
743
|
+
used = [r["color"] for r in self.conn.execute("SELECT color FROM accounts").fetchall()]
|
|
744
|
+
color = next((c for c in TAB20_COLORS if c not in used), TAB20_COLORS[0])
|
|
745
|
+
cur = self.conn.execute("INSERT INTO accounts(name, color) VALUES(?,?)", (name, color))
|
|
746
|
+
self.conn.commit()
|
|
747
|
+
return cur.lastrowid
|
|
748
|
+
|
|
749
|
+
def update_account(self, account_id, **kwargs):
|
|
750
|
+
sets = ", ".join(f"{k}=?" for k in kwargs)
|
|
751
|
+
self.conn.execute(f"UPDATE accounts SET {sets} WHERE id=?",
|
|
752
|
+
list(kwargs.values()) + [account_id])
|
|
753
|
+
self.conn.commit()
|
|
754
|
+
|
|
755
|
+
def rename_account(self, old_name, new_name):
|
|
756
|
+
existing = self.get_account_by_name(new_name)
|
|
757
|
+
old = self.get_account_by_name(old_name)
|
|
758
|
+
if not old:
|
|
759
|
+
return
|
|
760
|
+
if existing and existing["id"] != old["id"]:
|
|
761
|
+
self.conn.execute(
|
|
762
|
+
"UPDATE transactions SET account_id=? WHERE account_id=?",
|
|
763
|
+
(existing["id"], old["id"]))
|
|
764
|
+
self.conn.execute(
|
|
765
|
+
"UPDATE transactions SET to_account_id=? WHERE to_account_id=?",
|
|
766
|
+
(existing["id"], old["id"]))
|
|
767
|
+
self.conn.execute("DELETE FROM accounts WHERE id=?", (old["id"],))
|
|
768
|
+
else:
|
|
769
|
+
self.conn.execute("UPDATE accounts SET name=? WHERE name=?", (new_name, old_name))
|
|
770
|
+
self.conn.commit()
|
|
771
|
+
|
|
772
|
+
def delete_account(self, account_id):
|
|
773
|
+
self.conn.execute("UPDATE transactions SET account_id=NULL WHERE account_id=?", (account_id,))
|
|
774
|
+
self.conn.execute("UPDATE transactions SET to_account_id=NULL WHERE to_account_id=?", (account_id,))
|
|
775
|
+
self.conn.execute("UPDATE import_mappings SET account_id=NULL WHERE account_id=?", (account_id,))
|
|
776
|
+
self.conn.execute("DELETE FROM account_ids WHERE account_id=?", (account_id,))
|
|
777
|
+
self.conn.execute("DELETE FROM accounts WHERE id=?", (account_id,))
|
|
778
|
+
self.conn.commit()
|
|
779
|
+
|
|
780
|
+
# ── Transactions ──────────────────────────────────────────────
|
|
781
|
+
def get_transactions(self, filters=None):
|
|
782
|
+
q = """
|
|
783
|
+
SELECT t.id,
|
|
784
|
+
t.date,
|
|
785
|
+
ty.name as type,
|
|
786
|
+
a.name as account,
|
|
787
|
+
a.color as account_color,
|
|
788
|
+
ta.name as to_account,
|
|
789
|
+
ta.color as to_account_color,
|
|
790
|
+
t.amount,
|
|
791
|
+
s.name as subcategory,
|
|
792
|
+
c.name as category,
|
|
793
|
+
t.name,
|
|
794
|
+
t.memorandum,
|
|
795
|
+
t.description,
|
|
796
|
+
t.fitid,
|
|
797
|
+
t.transfer_status,
|
|
798
|
+
t.account_id,
|
|
799
|
+
t.to_account_id,
|
|
800
|
+
t.subcategory_id,
|
|
801
|
+
t.category_id,
|
|
802
|
+
t.type_id
|
|
803
|
+
FROM transactions t
|
|
804
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
805
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
806
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
807
|
+
LEFT JOIN subcategories s ON t.subcategory_id = s.id
|
|
808
|
+
LEFT JOIN categories c ON t.category_id = c.id
|
|
809
|
+
WHERE 1 = 1 \
|
|
810
|
+
"""
|
|
811
|
+
params = []
|
|
812
|
+
f = filters or {}
|
|
813
|
+
|
|
814
|
+
if f.get("type"):
|
|
815
|
+
q += " AND ty.name IN (" + ",".join("?" * len(f["type"])) + ")"
|
|
816
|
+
params += f["type"]
|
|
817
|
+
if f.get("account"):
|
|
818
|
+
q += " AND (a.name IN (" + ",".join("?" * len(f["account"])) + ") OR ta.name IN (" + ",".join(
|
|
819
|
+
"?" * len(f["account"])) + "))"
|
|
820
|
+
params += f["account"]
|
|
821
|
+
params += f["account"]
|
|
822
|
+
if f.get("category"):
|
|
823
|
+
q += " AND c.name IN (" + ",".join("?" * len(f["category"])) + ")"
|
|
824
|
+
params += f["category"]
|
|
825
|
+
if f.get("subcategory"):
|
|
826
|
+
q += " AND s.name IN (" + ",".join("?" * len(f["subcategory"])) + ")"
|
|
827
|
+
params += f["subcategory"]
|
|
828
|
+
if f.get("tags"):
|
|
829
|
+
ph = ",".join("?" * len(f["tags"]))
|
|
830
|
+
tag_id_rows = self.conn.execute(f"""
|
|
831
|
+
SELECT DISTINCT tt.transaction_id
|
|
832
|
+
FROM transaction_tags tt
|
|
833
|
+
JOIN tags tg ON tg.id = tt.tag_id
|
|
834
|
+
WHERE tg.name IN ({ph})
|
|
835
|
+
""", f["tags"]).fetchall()
|
|
836
|
+
allowed_ids = [r[0] for r in tag_id_rows]
|
|
837
|
+
if not allowed_ids:
|
|
838
|
+
return []
|
|
839
|
+
q += " AND t.id IN (" + ",".join("?" * len(allowed_ids)) + ")"
|
|
840
|
+
params += allowed_ids
|
|
841
|
+
if f.get("transfer_status") == "unmatched":
|
|
842
|
+
q += " AND t.transfer_status = 'unmatched'"
|
|
843
|
+
elif f.get("transfer_status") == "matched":
|
|
844
|
+
q += " AND t.transfer_status = 'matched'"
|
|
845
|
+
|
|
846
|
+
if f.get("from_day"):
|
|
847
|
+
q += " AND t.date >= ?"
|
|
848
|
+
params.append(f["from_day"])
|
|
849
|
+
|
|
850
|
+
if f.get("to_day"):
|
|
851
|
+
q += " AND t.date <= ?"
|
|
852
|
+
params.append(f["to_day"])
|
|
853
|
+
|
|
854
|
+
if f.get("search"):
|
|
855
|
+
kw = f"%{f['search']}%"
|
|
856
|
+
sf = f.get("search_fields") or {}
|
|
857
|
+
# default: name + memo + category + subcategory if no fields specified
|
|
858
|
+
if not any(sf.values()):
|
|
859
|
+
sf = {"name": True, "memo": True, "category": True, "subcategory": True}
|
|
860
|
+
field_clauses = []
|
|
861
|
+
if sf.get("name"):
|
|
862
|
+
field_clauses.append("t.name LIKE ?")
|
|
863
|
+
params.append(kw)
|
|
864
|
+
if sf.get("memo"):
|
|
865
|
+
field_clauses.append("t.memorandum LIKE ?")
|
|
866
|
+
params.append(kw)
|
|
867
|
+
if sf.get("category"):
|
|
868
|
+
field_clauses.append("c.name LIKE ?")
|
|
869
|
+
params.append(kw)
|
|
870
|
+
if sf.get("subcategory"):
|
|
871
|
+
field_clauses.append("s.name LIKE ?")
|
|
872
|
+
params.append(kw)
|
|
873
|
+
if sf.get("tags"):
|
|
874
|
+
field_clauses.append("""t.id IN (
|
|
875
|
+
SELECT tt2.transaction_id FROM transaction_tags tt2
|
|
876
|
+
JOIN tags tg2 ON tg2.id = tt2.tag_id
|
|
877
|
+
WHERE tg2.name LIKE ?)""")
|
|
878
|
+
params.append(kw)
|
|
879
|
+
if sf.get("fitid"):
|
|
880
|
+
field_clauses.append("t.fitid LIKE ?")
|
|
881
|
+
params.append(kw)
|
|
882
|
+
if field_clauses:
|
|
883
|
+
q += " AND (" + " OR ".join(field_clauses) + ")"
|
|
884
|
+
|
|
885
|
+
q += " ORDER BY t.date DESC, t.id"
|
|
886
|
+
rows = self.conn.execute(q, params).fetchall()
|
|
887
|
+
|
|
888
|
+
if not rows:
|
|
889
|
+
return []
|
|
890
|
+
|
|
891
|
+
# Second pass — attach tags without blowing up the main query
|
|
892
|
+
ids = [r["id"] for r in rows]
|
|
893
|
+
ph = ",".join("?" * len(ids))
|
|
894
|
+
tag_rows = self.conn.execute(f"""
|
|
895
|
+
SELECT tt.transaction_id, tg.name
|
|
896
|
+
FROM transaction_tags tt
|
|
897
|
+
JOIN tags tg ON tg.id = tt.tag_id
|
|
898
|
+
WHERE tt.transaction_id IN ({ph})
|
|
899
|
+
ORDER BY tg.name
|
|
900
|
+
""", ids).fetchall()
|
|
901
|
+
|
|
902
|
+
tag_map = {}
|
|
903
|
+
for tr in tag_rows:
|
|
904
|
+
tag_map.setdefault(tr["transaction_id"], []).append(tr["name"])
|
|
905
|
+
|
|
906
|
+
result = []
|
|
907
|
+
for r in rows:
|
|
908
|
+
d = dict(r)
|
|
909
|
+
d["tags"] = ", ".join(tag_map.get(r["id"], []))
|
|
910
|
+
result.append(d)
|
|
911
|
+
return result
|
|
912
|
+
|
|
913
|
+
def add_transaction(self, year, month, day, type_name, account_name, amount,
|
|
914
|
+
subcategory_id, category_id, name=None, memorandum=None,
|
|
915
|
+
description=None, to_account_name=None, transfer_status=None,
|
|
916
|
+
fitid=None):
|
|
917
|
+
amount = round2(amount)
|
|
918
|
+
type_id = self.get_type_id(type_name)
|
|
919
|
+
account_id = self.get_or_create_account(account_name) if account_name else None
|
|
920
|
+
to_account_id = self.get_or_create_account(to_account_name) if to_account_name else None
|
|
921
|
+
|
|
922
|
+
if transfer_status is None and type_name and "Transfer" in type_name:
|
|
923
|
+
if account_id and to_account_id:
|
|
924
|
+
transfer_status = TRANSFER_MATCHED
|
|
925
|
+
else:
|
|
926
|
+
transfer_status = TRANSFER_UNMATCHED
|
|
927
|
+
|
|
928
|
+
date_str = _ymd_to_date_str(year, month, day)
|
|
929
|
+
|
|
930
|
+
self.conn.execute("""
|
|
931
|
+
INSERT INTO transactions(date, type_id, account_id, to_account_id,
|
|
932
|
+
amount, subcategory_id, category_id, name, memorandum,
|
|
933
|
+
description, fitid, transfer_status)
|
|
934
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
935
|
+
""", (date_str, type_id, account_id, to_account_id,
|
|
936
|
+
amount, subcategory_id, category_id, name, memorandum,
|
|
937
|
+
description, fitid, transfer_status))
|
|
938
|
+
self.conn.commit()
|
|
939
|
+
return self.conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
940
|
+
|
|
941
|
+
def update_transaction(self, tx_id, **kwargs):
|
|
942
|
+
# Convert y/m/d -> date if passed separately
|
|
943
|
+
if "year" in kwargs or "month" in kwargs or "day" in kwargs:
|
|
944
|
+
row = self.conn.execute("SELECT date FROM transactions WHERE id=?", (tx_id,)).fetchone()
|
|
945
|
+
old_y, old_m, old_d = _date_str_to_parts(row["date"] if row else None)
|
|
946
|
+
year = kwargs.pop("year", old_y)
|
|
947
|
+
month = kwargs.pop("month", old_m)
|
|
948
|
+
day = kwargs.pop("day", old_d)
|
|
949
|
+
kwargs["date"] = _ymd_to_date_str(year, month, day)
|
|
950
|
+
if "type_name" in kwargs:
|
|
951
|
+
kwargs["type_id"] = self.get_type_id(kwargs.pop("type_name"))
|
|
952
|
+
if "account_name" in kwargs:
|
|
953
|
+
kwargs["account_id"] = self.get_or_create_account(kwargs.pop("account_name"))
|
|
954
|
+
if "to_account_name" in kwargs:
|
|
955
|
+
n = kwargs.pop("to_account_name")
|
|
956
|
+
kwargs["to_account_id"] = self.get_or_create_account(n) if n else None
|
|
957
|
+
if "amount" in kwargs:
|
|
958
|
+
kwargs["amount"] = round2(kwargs["amount"])
|
|
959
|
+
sets = ", ".join(f"{k}=?" for k in kwargs)
|
|
960
|
+
self.conn.execute(f"UPDATE transactions SET {sets} WHERE id=?",
|
|
961
|
+
list(kwargs.values()) + [tx_id])
|
|
962
|
+
self.conn.commit()
|
|
963
|
+
|
|
964
|
+
def delete_transaction(self, tx_id):
|
|
965
|
+
self.conn.execute("DELETE FROM transactions WHERE id=?", (tx_id,))
|
|
966
|
+
self.conn.commit()
|
|
967
|
+
|
|
968
|
+
def fitid_exists(self, fitid, account_name):
|
|
969
|
+
if not fitid:
|
|
970
|
+
return False
|
|
971
|
+
acc = self.get_account_by_name(account_name)
|
|
972
|
+
if not acc:
|
|
973
|
+
return False
|
|
974
|
+
row = self.conn.execute("""
|
|
975
|
+
SELECT fitid
|
|
976
|
+
FROM transactions
|
|
977
|
+
WHERE account_id = ?
|
|
978
|
+
AND fitid IS NOT NULL
|
|
979
|
+
""", (acc["id"],)).fetchall()
|
|
980
|
+
for r in row:
|
|
981
|
+
stored = r["fitid"] or ""
|
|
982
|
+
if fitid in stored.split("|"):
|
|
983
|
+
return True
|
|
984
|
+
row2 = self.conn.execute("""
|
|
985
|
+
SELECT fitid
|
|
986
|
+
FROM transactions
|
|
987
|
+
WHERE to_account_id = ?
|
|
988
|
+
AND fitid IS NOT NULL
|
|
989
|
+
""", (acc["id"],)).fetchall()
|
|
990
|
+
for r in row2:
|
|
991
|
+
stored = r["fitid"] or ""
|
|
992
|
+
if fitid in stored.split("|"):
|
|
993
|
+
return True
|
|
994
|
+
return False
|
|
995
|
+
|
|
996
|
+
def get_uncategorized_transactions(self, from_str, to_str):
|
|
997
|
+
uncat_cat_id, _ = self.get_uncategorized_ids()
|
|
998
|
+
return self.conn.execute("""
|
|
999
|
+
SELECT t.date,
|
|
1000
|
+
t.amount,
|
|
1001
|
+
a.name as account,
|
|
1002
|
+
ty.name as type_name,
|
|
1003
|
+
t.name,
|
|
1004
|
+
t.memorandum
|
|
1005
|
+
FROM transactions t
|
|
1006
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1007
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1008
|
+
WHERE t.category_id = ?
|
|
1009
|
+
AND (ty.name LIKE '%Income%' OR ty.name LIKE '%Expense%')
|
|
1010
|
+
AND t.date BETWEEN ? AND ?
|
|
1011
|
+
ORDER BY t.date DESC
|
|
1012
|
+
""", (uncat_cat_id, from_str, to_str)).fetchall()
|
|
1013
|
+
|
|
1014
|
+
def get_unmatched_transfer_transactions(self, from_str, to_str):
|
|
1015
|
+
return self.conn.execute("""
|
|
1016
|
+
SELECT t.date,
|
|
1017
|
+
t.amount,
|
|
1018
|
+
a.name as account,
|
|
1019
|
+
ta.name as to_account,
|
|
1020
|
+
t.name,
|
|
1021
|
+
t.memorandum
|
|
1022
|
+
FROM transactions t
|
|
1023
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1024
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
1025
|
+
WHERE t.transfer_status = 'unmatched'
|
|
1026
|
+
AND t.date BETWEEN ? AND ?
|
|
1027
|
+
ORDER BY t.date DESC
|
|
1028
|
+
""", (from_str, to_str)).fetchall()
|
|
1029
|
+
|
|
1030
|
+
def get_unmatched_transfers(self):
|
|
1031
|
+
return self.conn.execute("""
|
|
1032
|
+
SELECT t.date,
|
|
1033
|
+
t.amount,
|
|
1034
|
+
a.name as account,
|
|
1035
|
+
ta.name as to_account,
|
|
1036
|
+
t.memorandum,
|
|
1037
|
+
t.name
|
|
1038
|
+
FROM transactions t
|
|
1039
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1040
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
1041
|
+
WHERE t.transfer_status = ?
|
|
1042
|
+
ORDER BY t.date DESC
|
|
1043
|
+
""", (TRANSFER_UNMATCHED,)).fetchall()
|
|
1044
|
+
|
|
1045
|
+
# ── Account ID Mappings ───────────────────────────────────────
|
|
1046
|
+
def get_account_by_acctid(self, acctid):
|
|
1047
|
+
return self.conn.execute("""
|
|
1048
|
+
SELECT a.name, a.id, ai.accttype, ai.bankid
|
|
1049
|
+
FROM account_ids ai
|
|
1050
|
+
JOIN accounts a ON ai.account_id = a.id
|
|
1051
|
+
WHERE ai.acctid = ?
|
|
1052
|
+
""", (acctid,)).fetchone()
|
|
1053
|
+
|
|
1054
|
+
def save_account_id(self, acctid, account_name, accttype=None, bankid=None):
|
|
1055
|
+
acc = self.get_account_by_name(account_name)
|
|
1056
|
+
if not acc:
|
|
1057
|
+
return False
|
|
1058
|
+
self.conn.execute("""
|
|
1059
|
+
INSERT INTO account_ids(acctid, accttype, bankid, account_id)
|
|
1060
|
+
VALUES (?, ?, ?, ?) ON CONFLICT(acctid) DO
|
|
1061
|
+
UPDATE SET
|
|
1062
|
+
account_id = excluded.account_id,
|
|
1063
|
+
accttype = excluded.accttype,
|
|
1064
|
+
bankid = excluded.bankid
|
|
1065
|
+
""", (acctid, accttype, bankid, acc["id"]))
|
|
1066
|
+
self.conn.commit()
|
|
1067
|
+
return True
|
|
1068
|
+
|
|
1069
|
+
def get_all_account_ids(self):
|
|
1070
|
+
return self.conn.execute("""
|
|
1071
|
+
SELECT ai.id, ai.acctid, ai.accttype, ai.bankid, a.name as account_name
|
|
1072
|
+
FROM account_ids ai
|
|
1073
|
+
LEFT JOIN accounts a ON ai.account_id = a.id
|
|
1074
|
+
ORDER BY a.name, ai.acctid
|
|
1075
|
+
""").fetchall()
|
|
1076
|
+
|
|
1077
|
+
# ── Transfer matching ─────────────────────────────────────────
|
|
1078
|
+
def match_unmatched_transfers(self):
|
|
1079
|
+
rows = self.conn.execute("""
|
|
1080
|
+
SELECT t.id,
|
|
1081
|
+
t.date,
|
|
1082
|
+
t.amount,
|
|
1083
|
+
t.account_id,
|
|
1084
|
+
t.to_account_id,
|
|
1085
|
+
t.subcategory_id,
|
|
1086
|
+
t.category_id,
|
|
1087
|
+
t.fitid,
|
|
1088
|
+
t.memorandum,
|
|
1089
|
+
t.name
|
|
1090
|
+
FROM transactions t
|
|
1091
|
+
WHERE t.transfer_status = 'unmatched'
|
|
1092
|
+
OR (
|
|
1093
|
+
t.transfer_status IS NULL
|
|
1094
|
+
AND t.type_id IN (SELECT id FROM types WHERE name LIKE '%Transfer%')
|
|
1095
|
+
AND (
|
|
1096
|
+
(t.account_id IS NOT NULL AND t.to_account_id IS NULL)
|
|
1097
|
+
OR
|
|
1098
|
+
(t.account_id IS NULL AND t.to_account_id IS NOT NULL)
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
ORDER BY t.date, t.id
|
|
1102
|
+
""").fetchall()
|
|
1103
|
+
|
|
1104
|
+
def _transfer_type(memo, name):
|
|
1105
|
+
text = ((memo or "") + " " + (name or "")).upper()
|
|
1106
|
+
if "E-TRANSFER" in text or "ETRANSFER" in text:
|
|
1107
|
+
return "etransfer"
|
|
1108
|
+
if "INTERNET TRANSFER" in text:
|
|
1109
|
+
return "internet"
|
|
1110
|
+
return "other"
|
|
1111
|
+
|
|
1112
|
+
from_rows = [r for r in rows if r["account_id"] is not None and r["to_account_id"] is None]
|
|
1113
|
+
to_rows = [r for r in rows if r["to_account_id"] is not None and r["account_id"] is None]
|
|
1114
|
+
|
|
1115
|
+
used = set()
|
|
1116
|
+
matched = 0
|
|
1117
|
+
|
|
1118
|
+
for r1 in from_rows:
|
|
1119
|
+
if r1["id"] in used:
|
|
1120
|
+
continue
|
|
1121
|
+
|
|
1122
|
+
r1_ttype = _transfer_type(r1["memorandum"], r1["name"])
|
|
1123
|
+
|
|
1124
|
+
best = None
|
|
1125
|
+
best_diff = 999
|
|
1126
|
+
for r2 in to_rows:
|
|
1127
|
+
if r2["id"] in used:
|
|
1128
|
+
continue
|
|
1129
|
+
if round2(r2["amount"]) != round2(r1["amount"]):
|
|
1130
|
+
continue
|
|
1131
|
+
if r2["to_account_id"] == r1["account_id"]:
|
|
1132
|
+
continue
|
|
1133
|
+
|
|
1134
|
+
r2_ttype = _transfer_type(r2["memorandum"], r2["name"])
|
|
1135
|
+
if r1_ttype != "other" and r2_ttype != "other" and r1_ttype != r2_ttype:
|
|
1136
|
+
continue
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
diff = abs((_date.fromisoformat(r2["date"]) - _date.fromisoformat(r1["date"])).days)
|
|
1140
|
+
except Exception:
|
|
1141
|
+
diff = 999
|
|
1142
|
+
if diff <= 10 and diff < best_diff:
|
|
1143
|
+
best = r2
|
|
1144
|
+
best_diff = diff
|
|
1145
|
+
|
|
1146
|
+
if best is None:
|
|
1147
|
+
continue
|
|
1148
|
+
|
|
1149
|
+
r2 = best
|
|
1150
|
+
acc1_name = self.conn.execute(
|
|
1151
|
+
"SELECT name FROM accounts WHERE id=?", (r1["account_id"],)
|
|
1152
|
+
).fetchone()
|
|
1153
|
+
acc2_name = self.conn.execute(
|
|
1154
|
+
"SELECT name FROM accounts WHERE id=?", (r2["to_account_id"],)
|
|
1155
|
+
).fetchone()
|
|
1156
|
+
|
|
1157
|
+
cat_id, sub_id = self.get_transfer_cat_sub(
|
|
1158
|
+
from_account=acc1_name["name"] if acc1_name else "",
|
|
1159
|
+
to_account=acc2_name["name"] if acc2_name else "",
|
|
1160
|
+
matched=True
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
fitid1 = r1["fitid"] or ""
|
|
1164
|
+
fitid2 = r2["fitid"] or ""
|
|
1165
|
+
combined_fitid = "|".join(f for f in [fitid1, fitid2] if f) or None
|
|
1166
|
+
|
|
1167
|
+
self.conn.execute("""
|
|
1168
|
+
UPDATE transactions
|
|
1169
|
+
SET to_account_id=?,
|
|
1170
|
+
transfer_status=?,
|
|
1171
|
+
subcategory_id=?,
|
|
1172
|
+
category_id=?,
|
|
1173
|
+
fitid=?
|
|
1174
|
+
WHERE id = ?
|
|
1175
|
+
""", (r2["to_account_id"], TRANSFER_MATCHED,
|
|
1176
|
+
sub_id, cat_id, combined_fitid, r1["id"]))
|
|
1177
|
+
self.conn.execute("DELETE FROM transactions WHERE id=?", (r2["id"],))
|
|
1178
|
+
|
|
1179
|
+
used.add(r1["id"])
|
|
1180
|
+
used.add(r2["id"])
|
|
1181
|
+
matched += 1
|
|
1182
|
+
|
|
1183
|
+
self.conn.commit()
|
|
1184
|
+
return matched
|
|
1185
|
+
|
|
1186
|
+
# ── Cascading filter options ───────────────────────────────────
|
|
1187
|
+
def get_filter_options(self, filters=None):
|
|
1188
|
+
f = filters or {}
|
|
1189
|
+
|
|
1190
|
+
def _selected(key):
|
|
1191
|
+
vals = f.get(key)
|
|
1192
|
+
return bool(vals)
|
|
1193
|
+
|
|
1194
|
+
def _add_date_filters(q, params):
|
|
1195
|
+
if f.get("from_day"):
|
|
1196
|
+
q += " AND t.date >= ?"
|
|
1197
|
+
params.append(f["from_day"])
|
|
1198
|
+
if f.get("to_day"):
|
|
1199
|
+
q += " AND t.date <= ?"
|
|
1200
|
+
params.append(f["to_day"])
|
|
1201
|
+
return q, params
|
|
1202
|
+
|
|
1203
|
+
def _add_search_filter(q, params):
|
|
1204
|
+
search = (f.get("search") or "").strip()
|
|
1205
|
+
if search:
|
|
1206
|
+
kw = f"%{search}%"
|
|
1207
|
+
q += """
|
|
1208
|
+
AND (
|
|
1209
|
+
s.name LIKE ?
|
|
1210
|
+
OR c.name LIKE ?
|
|
1211
|
+
OR a.name LIKE ?
|
|
1212
|
+
OR ta.name LIKE ?
|
|
1213
|
+
OR t.name LIKE ?
|
|
1214
|
+
OR t.memorandum LIKE ?
|
|
1215
|
+
OR t.description LIKE ?
|
|
1216
|
+
OR t.fitid LIKE ?
|
|
1217
|
+
)
|
|
1218
|
+
"""
|
|
1219
|
+
params += [kw, kw, kw, kw, kw, kw, kw, kw]
|
|
1220
|
+
return q, params
|
|
1221
|
+
|
|
1222
|
+
def build_base(exclude_key):
|
|
1223
|
+
q = """
|
|
1224
|
+
SELECT ty.name AS type_name,
|
|
1225
|
+
a.name AS aname,
|
|
1226
|
+
ta.name AS taname,
|
|
1227
|
+
c.name AS cname,
|
|
1228
|
+
s.name AS sname
|
|
1229
|
+
FROM transactions t
|
|
1230
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1231
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1232
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
1233
|
+
LEFT JOIN subcategories s ON t.subcategory_id = s.id
|
|
1234
|
+
LEFT JOIN categories c ON t.category_id = c.id
|
|
1235
|
+
WHERE 1 = 1 \
|
|
1236
|
+
"""
|
|
1237
|
+
params = []
|
|
1238
|
+
|
|
1239
|
+
q, params = _add_date_filters(q, params)
|
|
1240
|
+
|
|
1241
|
+
if exclude_key != "type" and _selected("type"):
|
|
1242
|
+
vals = f["type"]
|
|
1243
|
+
ph = ",".join("?" * len(vals))
|
|
1244
|
+
q += f" AND ty.name IN ({ph})"
|
|
1245
|
+
params += vals
|
|
1246
|
+
|
|
1247
|
+
if exclude_key != "account" and _selected("account"):
|
|
1248
|
+
vals = f["account"]
|
|
1249
|
+
ph = ",".join("?" * len(vals))
|
|
1250
|
+
q += f" AND (a.name IN ({ph}) OR ta.name IN ({ph}))"
|
|
1251
|
+
params += vals + vals
|
|
1252
|
+
|
|
1253
|
+
if exclude_key != "category" and _selected("category"):
|
|
1254
|
+
vals = f["category"]
|
|
1255
|
+
ph = ",".join("?" * len(vals))
|
|
1256
|
+
q += f" AND c.name IN ({ph})"
|
|
1257
|
+
params += vals
|
|
1258
|
+
|
|
1259
|
+
if exclude_key != "subcategory" and _selected("subcategory"):
|
|
1260
|
+
vals = f["subcategory"]
|
|
1261
|
+
ph = ",".join("?" * len(vals))
|
|
1262
|
+
q += f" AND s.name IN ({ph})"
|
|
1263
|
+
params += vals
|
|
1264
|
+
|
|
1265
|
+
q, params = _add_search_filter(q, params)
|
|
1266
|
+
|
|
1267
|
+
return q, params
|
|
1268
|
+
|
|
1269
|
+
result = {}
|
|
1270
|
+
|
|
1271
|
+
for dim in ["type", "account", "category", "subcategory"]:
|
|
1272
|
+
q, params = build_base(dim)
|
|
1273
|
+
|
|
1274
|
+
if dim == "account":
|
|
1275
|
+
rows = self.conn.execute(
|
|
1276
|
+
f"""
|
|
1277
|
+
SELECT DISTINCT aname AS val FROM ({q}) x WHERE val IS NOT NULL
|
|
1278
|
+
UNION
|
|
1279
|
+
SELECT DISTINCT taname AS val FROM ({q}) x WHERE val IS NOT NULL
|
|
1280
|
+
""",
|
|
1281
|
+
params + params
|
|
1282
|
+
).fetchall()
|
|
1283
|
+
result[dim] = sorted({r["val"] for r in rows})
|
|
1284
|
+
|
|
1285
|
+
elif dim == "type":
|
|
1286
|
+
rows = self.conn.execute(
|
|
1287
|
+
f"SELECT DISTINCT type_name AS val FROM ({q}) x WHERE val IS NOT NULL ORDER BY val",
|
|
1288
|
+
params
|
|
1289
|
+
).fetchall()
|
|
1290
|
+
result[dim] = [r["val"] for r in rows]
|
|
1291
|
+
|
|
1292
|
+
elif dim == "category":
|
|
1293
|
+
rows = self.conn.execute(
|
|
1294
|
+
f"SELECT DISTINCT cname AS val FROM ({q}) x WHERE val IS NOT NULL ORDER BY val",
|
|
1295
|
+
params
|
|
1296
|
+
).fetchall()
|
|
1297
|
+
result[dim] = [r["val"] for r in rows]
|
|
1298
|
+
|
|
1299
|
+
elif dim == "subcategory":
|
|
1300
|
+
rows = self.conn.execute(
|
|
1301
|
+
f"SELECT DISTINCT sname AS val FROM ({q}) x WHERE val IS NOT NULL ORDER BY val",
|
|
1302
|
+
params
|
|
1303
|
+
).fetchall()
|
|
1304
|
+
result[dim] = [r["val"] for r in rows]
|
|
1305
|
+
|
|
1306
|
+
return result
|
|
1307
|
+
|
|
1308
|
+
# ── Import Mappings ───────────────────────────────────────────
|
|
1309
|
+
def save_mapping(self, type_name, account_name, name=None, memorandum=None,
|
|
1310
|
+
subcategory_id=None, category_id=None):
|
|
1311
|
+
type_id = self.get_type_id(type_name) if type_name else None
|
|
1312
|
+
acc = self.get_account_by_name(account_name) if account_name else None
|
|
1313
|
+
account_id = acc["id"] if acc else None
|
|
1314
|
+
|
|
1315
|
+
self.conn.execute("""
|
|
1316
|
+
INSERT INTO import_mappings(type_id, account_id, name, memorandum, subcategory_id, category_id)
|
|
1317
|
+
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(type_id, account_id, name, memorandum) DO
|
|
1318
|
+
UPDATE SET
|
|
1319
|
+
subcategory_id = excluded.subcategory_id,
|
|
1320
|
+
category_id = excluded.category_id
|
|
1321
|
+
""", (type_id, account_id, name, memorandum, subcategory_id, category_id))
|
|
1322
|
+
self.conn.commit()
|
|
1323
|
+
|
|
1324
|
+
def lookup_mapping(self, type_name, account_name, name=None, memorandum=None):
|
|
1325
|
+
type_id = self.get_type_id(type_name) if type_name else None
|
|
1326
|
+
if account_name:
|
|
1327
|
+
acc = self.get_account_by_name(account_name)
|
|
1328
|
+
account_id = acc["id"] if acc else None
|
|
1329
|
+
else:
|
|
1330
|
+
account_id = None
|
|
1331
|
+
|
|
1332
|
+
def _field_matches(value, pattern):
|
|
1333
|
+
if not pattern:
|
|
1334
|
+
return True, 0
|
|
1335
|
+
value = (value or "").lower()
|
|
1336
|
+
terms = [p.strip().lower() for p in pattern.split("|")]
|
|
1337
|
+
matched = all(t in value for t in terms)
|
|
1338
|
+
return matched, len(terms) if matched else 0
|
|
1339
|
+
|
|
1340
|
+
candidates = self.conn.execute("""
|
|
1341
|
+
SELECT *
|
|
1342
|
+
FROM import_mappings
|
|
1343
|
+
WHERE type_id = ?
|
|
1344
|
+
AND (account_id = ? OR account_id IS NULL)
|
|
1345
|
+
""", (type_id, account_id)).fetchall()
|
|
1346
|
+
|
|
1347
|
+
best = None
|
|
1348
|
+
best_score = -1
|
|
1349
|
+
for row in candidates:
|
|
1350
|
+
name_ok, name_score = _field_matches(name, row["name"])
|
|
1351
|
+
memo_ok, memo_score = _field_matches(memorandum, row["memorandum"])
|
|
1352
|
+
if not name_ok or not memo_ok:
|
|
1353
|
+
continue
|
|
1354
|
+
score = (1 if row["account_id"] else 0) + name_score + memo_score
|
|
1355
|
+
if score > best_score:
|
|
1356
|
+
best_score = score
|
|
1357
|
+
best = row
|
|
1358
|
+
|
|
1359
|
+
return best
|
|
1360
|
+
|
|
1361
|
+
# ── Dashboard / Analytics ─────────────────────────────────────
|
|
1362
|
+
@staticmethod
|
|
1363
|
+
def _month_range_dates(from_year, from_month, to_year, to_month):
|
|
1364
|
+
"""Returns (from_date_str, to_date_str) for a month range."""
|
|
1365
|
+
from_str = f"{int(from_year):04d}-{MONTH_NUM.get(from_month, 1):02d}-01"
|
|
1366
|
+
last_day = calendar.monthrange(int(to_year), MONTH_NUM.get(to_month, 12))[1]
|
|
1367
|
+
to_str = f"{int(to_year):04d}-{MONTH_NUM.get(to_month, 12):02d}-{last_day:02d}"
|
|
1368
|
+
return from_str, to_str
|
|
1369
|
+
|
|
1370
|
+
def get_dashboard_summary(self, from_year, from_month, to_year, to_month):
|
|
1371
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1372
|
+
row = self.conn.execute("""
|
|
1373
|
+
SELECT SUM(CASE WHEN ty.name LIKE '%Income%' THEN t.amount ELSE 0 END) AS income,
|
|
1374
|
+
SUM(CASE WHEN ty.name LIKE '%Expense%' THEN t.amount ELSE 0 END) AS expense,
|
|
1375
|
+
COUNT(*) AS tx_count
|
|
1376
|
+
FROM transactions t
|
|
1377
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1378
|
+
WHERE t.date BETWEEN ? AND ?
|
|
1379
|
+
""", (from_str, to_str)).fetchone()
|
|
1380
|
+
|
|
1381
|
+
income = round2(row["income"] or 0)
|
|
1382
|
+
expense = round2(row["expense"] or 0)
|
|
1383
|
+
return {
|
|
1384
|
+
"income": income,
|
|
1385
|
+
"expense": expense,
|
|
1386
|
+
"net": round2(income - expense),
|
|
1387
|
+
"tx_count": row["tx_count"] or 0,
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
def get_uncategorized_count(self, from_year, from_month, to_year, to_month):
|
|
1391
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1392
|
+
cat_id, _ = self.get_uncategorized_ids()
|
|
1393
|
+
row = self.conn.execute("""
|
|
1394
|
+
SELECT COUNT(*) AS cnt
|
|
1395
|
+
FROM transactions t
|
|
1396
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1397
|
+
WHERE t.date BETWEEN ? AND ?
|
|
1398
|
+
AND t.category_id = ?
|
|
1399
|
+
AND (ty.name LIKE '%Income%' OR ty.name LIKE '%Expense%')
|
|
1400
|
+
""", (from_str, to_str, cat_id)).fetchone()
|
|
1401
|
+
return row["cnt"] if row else 0
|
|
1402
|
+
|
|
1403
|
+
def get_unmatched_transfer_count(self, from_year, from_month, to_year, to_month):
|
|
1404
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1405
|
+
row = self.conn.execute("""
|
|
1406
|
+
SELECT COUNT(*) AS cnt
|
|
1407
|
+
FROM transactions t
|
|
1408
|
+
WHERE t.date BETWEEN ? AND ?
|
|
1409
|
+
AND t.transfer_status = ?
|
|
1410
|
+
""", (from_str, to_str, TRANSFER_UNMATCHED)).fetchone()
|
|
1411
|
+
return row["cnt"] if row else 0
|
|
1412
|
+
|
|
1413
|
+
def get_monthly_summary_for_range(self, from_year, from_month, to_year, to_month):
|
|
1414
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1415
|
+
rows = self.conn.execute("""
|
|
1416
|
+
SELECT strftime('%Y', t.date) AS year,
|
|
1417
|
+
strftime('%m', t.date) AS month_num,
|
|
1418
|
+
SUM(CASE WHEN ty.name LIKE '%Income%' THEN t.amount ELSE 0 END) AS income,
|
|
1419
|
+
SUM(CASE WHEN ty.name LIKE '%Expense%' THEN t.amount ELSE 0 END) AS expense
|
|
1420
|
+
FROM transactions t
|
|
1421
|
+
LEFT JOIN types ty
|
|
1422
|
+
ON t.type_id = ty.id
|
|
1423
|
+
WHERE t.date BETWEEN ?
|
|
1424
|
+
AND ?
|
|
1425
|
+
AND (ty.name LIKE '%Income%'
|
|
1426
|
+
OR ty.name LIKE '%Expense%')
|
|
1427
|
+
GROUP BY year, month_num
|
|
1428
|
+
ORDER BY year, month_num
|
|
1429
|
+
""", (from_str, to_str)).fetchall()
|
|
1430
|
+
|
|
1431
|
+
result = {}
|
|
1432
|
+
for r in rows:
|
|
1433
|
+
y = int(r["year"])
|
|
1434
|
+
m = MONTHS_ORDER[int(r["month_num"]) - 1]
|
|
1435
|
+
result[(y, m)] = {
|
|
1436
|
+
"income": round2(r["income"] or 0),
|
|
1437
|
+
"expense": round2(r["expense"] or 0),
|
|
1438
|
+
}
|
|
1439
|
+
return result
|
|
1440
|
+
|
|
1441
|
+
def get_category_totals_for_range(self, from_year, from_month, to_year, to_month):
|
|
1442
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1443
|
+
return self.conn.execute("""
|
|
1444
|
+
SELECT c.name AS category, ty.name AS type_name, SUM(t.amount) AS total
|
|
1445
|
+
FROM transactions t
|
|
1446
|
+
JOIN categories c ON t.category_id = c.id
|
|
1447
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1448
|
+
WHERE t.date BETWEEN ? AND ?
|
|
1449
|
+
AND (ty.name LIKE '%Income%' OR ty.name LIKE '%Expense%')
|
|
1450
|
+
GROUP BY c.name, ty.name
|
|
1451
|
+
HAVING total > 0
|
|
1452
|
+
ORDER BY total DESC
|
|
1453
|
+
""", (from_str, to_str)).fetchall()
|
|
1454
|
+
|
|
1455
|
+
def get_latest_account_balances(self, visible_only=False):
|
|
1456
|
+
snapshots, _ = self.get_account_monthly_balance()
|
|
1457
|
+
if not snapshots:
|
|
1458
|
+
return {}
|
|
1459
|
+
latest_period = sorted(
|
|
1460
|
+
snapshots.keys(),
|
|
1461
|
+
key=lambda ym: (ym[0], MONTH_NUM.get(ym[1], 0))
|
|
1462
|
+
)[-1]
|
|
1463
|
+
balances = snapshots[latest_period]
|
|
1464
|
+
if visible_only:
|
|
1465
|
+
visible_ids = {a["id"] for a in self.get_accounts(visible_only=True)}
|
|
1466
|
+
balances = {aid: bal for aid, bal in balances.items() if aid in visible_ids}
|
|
1467
|
+
return {aid: round2(bal) for aid, bal in balances.items()}
|
|
1468
|
+
|
|
1469
|
+
def get_current_total_balance(self):
|
|
1470
|
+
return round2(sum(self.get_latest_account_balances().values()))
|
|
1471
|
+
|
|
1472
|
+
def get_monthly_category_totals(self, year_from, month_from, year_to, month_to,
|
|
1473
|
+
type_filter="Expense",
|
|
1474
|
+
account_names=None,
|
|
1475
|
+
category_name=None,
|
|
1476
|
+
group_by="category"):
|
|
1477
|
+
if group_by not in ("category", "subcategory"):
|
|
1478
|
+
group_by = "category"
|
|
1479
|
+
|
|
1480
|
+
key_expr = "c.name" if group_by == "category" else "COALESCE(s.name, '(No Sub-Category)')"
|
|
1481
|
+
from_str, to_str = self._month_range_dates(year_from, month_from, year_to, month_to)
|
|
1482
|
+
|
|
1483
|
+
q = f"""
|
|
1484
|
+
SELECT strftime('%Y', t.date) AS year,
|
|
1485
|
+
strftime('%m', t.date) AS month_num,
|
|
1486
|
+
{key_expr} AS k, SUM(t.amount) AS total
|
|
1487
|
+
FROM transactions t
|
|
1488
|
+
LEFT JOIN categories c ON t.category_id = c.id
|
|
1489
|
+
LEFT JOIN subcategories s ON t.subcategory_id = s.id
|
|
1490
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1491
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1492
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
1493
|
+
WHERE ty.name LIKE ?
|
|
1494
|
+
AND t.date BETWEEN ? AND ?
|
|
1495
|
+
"""
|
|
1496
|
+
params = [f"%{type_filter}%", from_str, to_str]
|
|
1497
|
+
|
|
1498
|
+
if account_names:
|
|
1499
|
+
placeholders = ",".join("?" * len(account_names))
|
|
1500
|
+
q += f" AND (a.name IN ({placeholders}) OR ta.name IN ({placeholders}))"
|
|
1501
|
+
params += account_names + account_names
|
|
1502
|
+
|
|
1503
|
+
if category_name:
|
|
1504
|
+
q += " AND c.name = ?"
|
|
1505
|
+
params.append(category_name)
|
|
1506
|
+
|
|
1507
|
+
q += f" GROUP BY year, month_num, {key_expr}"
|
|
1508
|
+
|
|
1509
|
+
rows = self.conn.execute(q, params).fetchall()
|
|
1510
|
+
result = {}
|
|
1511
|
+
for r in rows:
|
|
1512
|
+
y = int(r["year"])
|
|
1513
|
+
m = MONTHS_ORDER[int(r["month_num"]) - 1]
|
|
1514
|
+
key = (y, m)
|
|
1515
|
+
if key not in result:
|
|
1516
|
+
result[key] = {}
|
|
1517
|
+
result[key][r["k"]] = r["total"] or 0
|
|
1518
|
+
return result
|
|
1519
|
+
|
|
1520
|
+
def get_period_totals_by_group(self, from_year, from_month, to_year, to_month,
|
|
1521
|
+
type_filter, account_names=None,
|
|
1522
|
+
category_name=None, group_by="category"):
|
|
1523
|
+
if group_by not in ("category", "subcategory"):
|
|
1524
|
+
group_by = "category"
|
|
1525
|
+
|
|
1526
|
+
key_expr = "c.name" if group_by == "category" else "COALESCE(s.name, '(No Sub-Category)')"
|
|
1527
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1528
|
+
|
|
1529
|
+
q = f"""
|
|
1530
|
+
SELECT {key_expr} AS k, SUM(t.amount) AS total
|
|
1531
|
+
FROM transactions t
|
|
1532
|
+
LEFT JOIN categories c ON t.category_id = c.id
|
|
1533
|
+
LEFT JOIN subcategories s ON t.subcategory_id = s.id
|
|
1534
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1535
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1536
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
1537
|
+
WHERE ty.name LIKE ?
|
|
1538
|
+
AND t.date BETWEEN ? AND ?
|
|
1539
|
+
"""
|
|
1540
|
+
params = [f"%{type_filter}%", from_str, to_str]
|
|
1541
|
+
|
|
1542
|
+
if account_names:
|
|
1543
|
+
ph = ",".join("?" * len(account_names))
|
|
1544
|
+
q += f" AND (a.name IN ({ph}) OR ta.name IN ({ph}))"
|
|
1545
|
+
params += account_names + account_names
|
|
1546
|
+
|
|
1547
|
+
if category_name:
|
|
1548
|
+
q += " AND c.name = ?"
|
|
1549
|
+
params.append(category_name)
|
|
1550
|
+
|
|
1551
|
+
q += f" GROUP BY {key_expr} HAVING total > 0 ORDER BY total DESC"
|
|
1552
|
+
rows = self.conn.execute(q, params).fetchall()
|
|
1553
|
+
return {r["k"]: round2(r["total"] or 0) for r in rows if r["k"]}
|
|
1554
|
+
|
|
1555
|
+
def get_account_monthly_balance(self):
|
|
1556
|
+
"""Running balance per account per month, starting from 0."""
|
|
1557
|
+
rows = self.conn.execute("""
|
|
1558
|
+
SELECT strftime('%Y', t.date) AS year,
|
|
1559
|
+
strftime('%m', t.date) AS month_num,
|
|
1560
|
+
a.id as account_id, a.name as account,
|
|
1561
|
+
ty.name as type_name,
|
|
1562
|
+
t.amount as total,
|
|
1563
|
+
ta.id as to_account_id, ta.name as to_account
|
|
1564
|
+
FROM transactions t
|
|
1565
|
+
LEFT JOIN types ty
|
|
1566
|
+
ON t.type_id=ty.id
|
|
1567
|
+
LEFT JOIN accounts a ON t.account_id=a.id
|
|
1568
|
+
LEFT JOIN accounts ta ON t.to_account_id=ta.id
|
|
1569
|
+
ORDER BY t.date
|
|
1570
|
+
""", []).fetchall()
|
|
1571
|
+
|
|
1572
|
+
accounts = {a["id"]: a["name"] for a in self.get_accounts()}
|
|
1573
|
+
running = {aid: 0.0 for aid in accounts}
|
|
1574
|
+
snapshots = {}
|
|
1575
|
+
|
|
1576
|
+
all_periods = sorted(
|
|
1577
|
+
{(int(r["year"]), MONTHS_ORDER[int(r["month_num"]) - 1]) for r in rows},
|
|
1578
|
+
key=lambda ym: (ym[0], MONTH_NUM.get(ym[1], 0))
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
for period in all_periods:
|
|
1582
|
+
y, m = period
|
|
1583
|
+
mn = f"{MONTH_NUM.get(m, 1):02d}"
|
|
1584
|
+
for r in rows:
|
|
1585
|
+
if r["year"] != str(y) or r["month_num"] != mn:
|
|
1586
|
+
continue
|
|
1587
|
+
type_name = r["type_name"] or ""
|
|
1588
|
+
aid = r["account_id"]
|
|
1589
|
+
taid = r["to_account_id"]
|
|
1590
|
+
total = r["total"] or 0
|
|
1591
|
+
|
|
1592
|
+
if "Income" in type_name:
|
|
1593
|
+
if aid: running[aid] = running.get(aid, 0) + total
|
|
1594
|
+
elif "Expense" in type_name:
|
|
1595
|
+
if aid: running[aid] = running.get(aid, 0) - total
|
|
1596
|
+
elif "Transfer" in type_name:
|
|
1597
|
+
if aid: running[aid] = running.get(aid, 0) - total
|
|
1598
|
+
if taid: running[taid] = running.get(taid, 0) + total
|
|
1599
|
+
|
|
1600
|
+
snapshots[period] = {aid: round2(bal) for aid, bal in running.items()}
|
|
1601
|
+
|
|
1602
|
+
return snapshots, accounts
|
|
1603
|
+
|
|
1604
|
+
def get_categories_in_month_range(self, from_year, from_month, to_year, to_month,
|
|
1605
|
+
type_filter, account_names=None):
|
|
1606
|
+
from_str, to_str = self._month_range_dates(from_year, from_month, to_year, to_month)
|
|
1607
|
+
q = f"""
|
|
1608
|
+
SELECT c.name
|
|
1609
|
+
FROM transactions t
|
|
1610
|
+
JOIN categories c ON t.category_id = c.id
|
|
1611
|
+
LEFT JOIN types ty ON t.type_id = ty.id
|
|
1612
|
+
LEFT JOIN accounts a ON t.account_id = a.id
|
|
1613
|
+
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
|
1614
|
+
WHERE ty.name LIKE ?
|
|
1615
|
+
AND t.date BETWEEN ? AND ?
|
|
1616
|
+
"""
|
|
1617
|
+
params = [f"%{type_filter}%", from_str, to_str]
|
|
1618
|
+
|
|
1619
|
+
if account_names:
|
|
1620
|
+
ph = ",".join("?" * len(account_names))
|
|
1621
|
+
q += f" AND (a.name IN ({ph}) OR ta.name IN ({ph}))"
|
|
1622
|
+
params += account_names + account_names
|
|
1623
|
+
|
|
1624
|
+
q += " GROUP BY c.name HAVING SUM(t.amount) > 0 ORDER BY c.name"
|
|
1625
|
+
rows = self.conn.execute(q, params).fetchall()
|
|
1626
|
+
return [r["name"] for r in rows]
|
|
1627
|
+
|
|
1628
|
+
def get_data_date_range(self):
|
|
1629
|
+
row = self.conn.execute("""
|
|
1630
|
+
SELECT MIN(date) as min_date, MAX(date) as max_date
|
|
1631
|
+
FROM transactions
|
|
1632
|
+
""").fetchone()
|
|
1633
|
+
today = _date.today()
|
|
1634
|
+
if not row or not row["min_date"]:
|
|
1635
|
+
return today, today
|
|
1636
|
+
return _date.fromisoformat(row["min_date"]), _date.fromisoformat(row["max_date"])
|
|
1637
|
+
|
|
1638
|
+
# ── Export from xlsx ──────────────────────────────────────────
|
|
1639
|
+
def export_to_xlsx(self, path, filters=None):
|
|
1640
|
+
rows = self.get_transactions(filters)
|
|
1641
|
+
data = []
|
|
1642
|
+
for r in rows:
|
|
1643
|
+
y, m, d = _date_str_to_parts(r["date"])
|
|
1644
|
+
data.append({
|
|
1645
|
+
"Date": r["date"],
|
|
1646
|
+
"Year": y, "Month": m, "Day": d,
|
|
1647
|
+
"Type": r["type"],
|
|
1648
|
+
"Account": r["account"],
|
|
1649
|
+
"To Account": r["to_account"] or "",
|
|
1650
|
+
"Amount": r["amount"],
|
|
1651
|
+
"Sub-Category": r["subcategory"],
|
|
1652
|
+
"Category": r["category"],
|
|
1653
|
+
"Name": r["name"] or "",
|
|
1654
|
+
"Memorandum": r["memorandum"] or "",
|
|
1655
|
+
"Description": r["description"] or "",
|
|
1656
|
+
"Status": r["transfer_status"] or "",
|
|
1657
|
+
})
|
|
1658
|
+
pd.DataFrame(data).to_excel(path, index=False)
|
|
1659
|
+
|
|
1660
|
+
# ── Manage dialog helpers ─────────────────────────────────────
|
|
1661
|
+
def get_transaction_count_for_account(self, account_id):
|
|
1662
|
+
return self.conn.execute(
|
|
1663
|
+
"SELECT COUNT(*) FROM transactions WHERE account_id=? OR to_account_id=?",
|
|
1664
|
+
(account_id, account_id)
|
|
1665
|
+
).fetchone()[0]
|
|
1666
|
+
|
|
1667
|
+
def get_account_name_by_id(self, account_id):
|
|
1668
|
+
row = self.conn.execute(
|
|
1669
|
+
"SELECT name FROM accounts WHERE id=?", (account_id,)
|
|
1670
|
+
).fetchone()
|
|
1671
|
+
return row["name"] if row else None
|
|
1672
|
+
|
|
1673
|
+
def get_account_with_bank_by_id(self, account_id):
|
|
1674
|
+
acc = self.conn.execute(
|
|
1675
|
+
"SELECT * FROM accounts WHERE id=?", (account_id,)
|
|
1676
|
+
).fetchone()
|
|
1677
|
+
bank = self.conn.execute(
|
|
1678
|
+
"SELECT * FROM account_ids WHERE account_id=?", (account_id,)
|
|
1679
|
+
).fetchone()
|
|
1680
|
+
return acc, bank
|
|
1681
|
+
|
|
1682
|
+
def delete_account_id_by_account(self, account_id):
|
|
1683
|
+
self.conn.execute(
|
|
1684
|
+
"DELETE FROM account_ids WHERE account_id=?", (account_id,)
|
|
1685
|
+
)
|
|
1686
|
+
self.conn.commit()
|
|
1687
|
+
|
|
1688
|
+
def get_account_color_by_id(self, account_id):
|
|
1689
|
+
row = self.conn.execute(
|
|
1690
|
+
"SELECT color FROM accounts WHERE id=?", (account_id,)
|
|
1691
|
+
).fetchone()
|
|
1692
|
+
return row["color"] if row else None
|
|
1693
|
+
|
|
1694
|
+
def get_transaction_count_for_type(self, type_id):
|
|
1695
|
+
return self.conn.execute(
|
|
1696
|
+
"SELECT COUNT(*) FROM transactions WHERE type_id=?", (type_id,)
|
|
1697
|
+
).fetchone()[0]
|
|
1698
|
+
|
|
1699
|
+
def get_rule_count_for_type(self, type_id):
|
|
1700
|
+
return self.conn.execute("""
|
|
1701
|
+
SELECT COUNT(*)
|
|
1702
|
+
FROM import_mappings m
|
|
1703
|
+
JOIN subcategories s ON m.subcategory_id = s.id
|
|
1704
|
+
JOIN categories c ON s.category_id = c.id
|
|
1705
|
+
WHERE c.type_id = ?
|
|
1706
|
+
""", (type_id,)).fetchone()[0]
|
|
1707
|
+
|
|
1708
|
+
def get_category_count_for_type(self, type_id):
|
|
1709
|
+
return self.conn.execute(
|
|
1710
|
+
"SELECT COUNT(*) FROM categories WHERE type_id=?", (type_id,)
|
|
1711
|
+
).fetchone()[0]
|
|
1712
|
+
|
|
1713
|
+
def get_transaction_count_for_category(self, category_id):
|
|
1714
|
+
return self.conn.execute(
|
|
1715
|
+
"SELECT COUNT(*) FROM transactions WHERE category_id=?", (category_id,)
|
|
1716
|
+
).fetchone()[0]
|
|
1717
|
+
|
|
1718
|
+
def get_rule_count_for_category(self, category_id):
|
|
1719
|
+
return self.conn.execute("""
|
|
1720
|
+
SELECT COUNT(*)
|
|
1721
|
+
FROM import_mappings m
|
|
1722
|
+
JOIN subcategories s ON m.subcategory_id = s.id
|
|
1723
|
+
WHERE s.category_id = ?
|
|
1724
|
+
""", (category_id,)).fetchone()[0]
|
|
1725
|
+
|
|
1726
|
+
def get_subcategory_count_for_category(self, category_id):
|
|
1727
|
+
return self.conn.execute(
|
|
1728
|
+
"SELECT COUNT(*) FROM subcategories WHERE category_id=?", (category_id,)
|
|
1729
|
+
).fetchone()[0]
|
|
1730
|
+
|
|
1731
|
+
def get_transaction_count_for_subcategory(self, subcategory_id):
|
|
1732
|
+
return self.conn.execute(
|
|
1733
|
+
"SELECT COUNT(*) FROM transactions WHERE subcategory_id=?", (subcategory_id,)
|
|
1734
|
+
).fetchone()[0]
|
|
1735
|
+
|
|
1736
|
+
def get_rule_count_for_subcategory(self, subcategory_id):
|
|
1737
|
+
return self.conn.execute(
|
|
1738
|
+
"SELECT COUNT(*) FROM import_mappings WHERE subcategory_id=?", (subcategory_id,)
|
|
1739
|
+
).fetchone()[0]
|
|
1740
|
+
|
|
1741
|
+
def get_rules_for_subcategory(self, subcategory_id):
|
|
1742
|
+
return self.conn.execute("""
|
|
1743
|
+
SELECT m.id,
|
|
1744
|
+
m.name,
|
|
1745
|
+
m.memorandum,
|
|
1746
|
+
a.name as account_name,
|
|
1747
|
+
m.account_id,
|
|
1748
|
+
m.type_id,
|
|
1749
|
+
m.subcategory_id,
|
|
1750
|
+
m.category_id
|
|
1751
|
+
FROM import_mappings m
|
|
1752
|
+
LEFT JOIN accounts a ON m.account_id = a.id
|
|
1753
|
+
WHERE m.subcategory_id = ?
|
|
1754
|
+
ORDER BY a.name, m.name, m.memorandum
|
|
1755
|
+
""", (subcategory_id,)).fetchall()
|
|
1756
|
+
|
|
1757
|
+
def delete_rule(self, rule_id):
|
|
1758
|
+
self.conn.execute("DELETE FROM import_mappings WHERE id=?", (rule_id,))
|
|
1759
|
+
self.conn.commit()
|
|
1760
|
+
|
|
1761
|
+
def delete_rule_and_save(self, rule_id, type_name, account_name,
|
|
1762
|
+
name, memorandum, subcategory_id, category_id):
|
|
1763
|
+
with self.conn:
|
|
1764
|
+
self.conn.execute("DELETE FROM import_mappings WHERE id=?", (rule_id,))
|
|
1765
|
+
self.save_mapping(
|
|
1766
|
+
type_name=type_name,
|
|
1767
|
+
account_name=account_name,
|
|
1768
|
+
name=name,
|
|
1769
|
+
memorandum=memorandum,
|
|
1770
|
+
subcategory_id=subcategory_id,
|
|
1771
|
+
category_id=category_id,
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
# ── Tags ──────────────────────────────────────────────────────
|
|
1775
|
+
def get_tags(self):
|
|
1776
|
+
return self.conn.execute(
|
|
1777
|
+
"SELECT id, name FROM tags ORDER BY name"
|
|
1778
|
+
).fetchall()
|
|
1779
|
+
|
|
1780
|
+
def add_tag(self, name):
|
|
1781
|
+
try:
|
|
1782
|
+
self.conn.execute("INSERT INTO tags(name) VALUES(?)", (name,))
|
|
1783
|
+
self.conn.commit()
|
|
1784
|
+
return True
|
|
1785
|
+
except sqlite3.IntegrityError:
|
|
1786
|
+
return False
|
|
1787
|
+
|
|
1788
|
+
def rename_tag(self, tag_id, new_name):
|
|
1789
|
+
try:
|
|
1790
|
+
self.conn.execute("UPDATE tags SET name=? WHERE id=?", (new_name, tag_id))
|
|
1791
|
+
self.conn.commit()
|
|
1792
|
+
return True
|
|
1793
|
+
except sqlite3.IntegrityError:
|
|
1794
|
+
return False
|
|
1795
|
+
|
|
1796
|
+
def delete_tag(self, tag_id):
|
|
1797
|
+
self.conn.execute("DELETE FROM tags WHERE id=?", (tag_id,))
|
|
1798
|
+
self.conn.commit()
|
|
1799
|
+
|
|
1800
|
+
def get_tags_for_transaction(self, transaction_id):
|
|
1801
|
+
return self.conn.execute("""
|
|
1802
|
+
SELECT t.id, t.name
|
|
1803
|
+
FROM tags t
|
|
1804
|
+
JOIN transaction_tags tt ON tt.tag_id = t.id
|
|
1805
|
+
WHERE tt.transaction_id = ?
|
|
1806
|
+
ORDER BY t.name
|
|
1807
|
+
""", (transaction_id,)).fetchall()
|
|
1808
|
+
|
|
1809
|
+
def set_tags_for_transaction(self, transaction_id, tag_ids):
|
|
1810
|
+
with self.conn:
|
|
1811
|
+
self.conn.execute(
|
|
1812
|
+
"DELETE FROM transaction_tags WHERE transaction_id=?",
|
|
1813
|
+
(transaction_id,)
|
|
1814
|
+
)
|
|
1815
|
+
for tag_id in tag_ids:
|
|
1816
|
+
self.conn.execute(
|
|
1817
|
+
"INSERT OR IGNORE INTO transaction_tags(transaction_id, tag_id) VALUES(?,?)",
|
|
1818
|
+
(transaction_id, tag_id)
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
def get_transaction_count_for_tag(self, tag_id):
|
|
1822
|
+
return self.conn.execute(
|
|
1823
|
+
"SELECT COUNT(*) FROM transaction_tags WHERE tag_id=?", (tag_id,)
|
|
1824
|
+
).fetchone()[0]
|