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/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]