Skip to content

Commit e015ea0

Browse files
committed
Enhance transaction categorization: add category_type to AkahuTransaction and update related methods
1 parent 7da7618 commit e015ea0

6 files changed

Lines changed: 72 additions & 40 deletions

File tree

src/bank_sync/akahu_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def from_payload(cls, payload: Dict, *, source: str, account_name: str = "unknow
4141
source=source,
4242
)
4343

44-
def to_row(self, *, category: str, is_transfer: bool, imported_at: datetime) -> List[str]:
44+
def to_row(self, *, category: str, category_type: str, is_transfer: bool, imported_at: datetime) -> List[str]:
4545
"""Return the row representation expected by Google Sheets."""
4646

4747
return [
@@ -53,6 +53,7 @@ def to_row(self, *, category: str, is_transfer: bool, imported_at: datetime) ->
5353
self.description_raw,
5454
self.merchant_normalised,
5555
category,
56+
category_type,
5657
str(is_transfer).upper(),
5758
self.source,
5859
imported_at.isoformat(),

src/bank_sync/categoriser.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class CategoryRule:
3636
pattern: str
3737
field: str
3838
category: str
39+
category_type: str = field(default="", compare=False)
3940
amount_condition: AmountCondition | None = field(default=None, compare=False)
4041

4142
def matches(self, transaction: dict) -> bool:
@@ -66,16 +67,18 @@ def __init__(self, rules: Iterable[dict]):
6667
pattern=pattern,
6768
field=rule.get("field", "merchant_normalised"),
6869
category=rule.get("category", "Uncategorised"),
70+
category_type=rule.get("category_type", ""),
6971
amount_condition=_parse_amount_condition(rule.get("amount_condition", "")),
7072
)
7173
)
7274
self._rules.sort()
7375

74-
def categorise(self, transaction: dict) -> str:
76+
def categorise(self, transaction: dict) -> tuple[str, str]:
77+
"""Return (category, category_type) for the transaction."""
7578
for rule in self._rules:
7679
if rule.matches(transaction):
77-
return rule.category
78-
return "Uncategorised"
80+
return (rule.category, rule.category_type)
81+
return ("Uncategorised", "")
7982

8083
@staticmethod
8184
def detect_transfer(transaction: dict) -> bool:

src/bank_sync/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@ def run_sync(dry_run: bool = False, reset_state: bool = False) -> None:
158158
"merchant_normalised": transaction.merchant_normalised,
159159
"source": transaction.source,
160160
}
161-
category = categoriser.categorise(transaction_dict)
161+
category, category_type = categoriser.categorise(transaction_dict)
162162
is_transfer = categoriser.detect_transfer(transaction_dict)
163-
row = transaction.to_row(category=category, is_transfer=is_transfer, imported_at=imported_at)
163+
row = transaction.to_row(category=category, category_type=category_type, is_transfer=is_transfer, imported_at=imported_at)
164164

165165
if transaction.id not in existing_map:
166166
new_rows.append(row)

src/bank_sync/sheets_client.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"description_raw",
2121
"merchant_normalised",
2222
"category",
23+
"category_type",
2324
"is_transfer",
2425
"source",
2526
"imported_at",
@@ -69,7 +70,7 @@ def _get_sheet_id(self, sheet_name: str) -> int:
6970
return self._sheet_id_cache.get(sheet_name, 0)
7071

7172
def fetch_transactions(self) -> List[SheetTransaction]:
72-
range_name = f"{self._transactions_tab}!A2:K"
73+
range_name = f"{self._transactions_tab}!A2:L"
7374
response = self._service.spreadsheets().values().get(
7475
spreadsheetId=self._spreadsheet_id, range=range_name
7576
).execute()
@@ -84,7 +85,7 @@ def fetch_transactions(self) -> List[SheetTransaction]:
8485
def append_transactions(self, rows: Iterable[List[str]]) -> None:
8586
if not rows:
8687
return
87-
range_name = f"{self._transactions_tab}!A:K"
88+
range_name = f"{self._transactions_tab}!A:L"
8889
body = {"values": list(rows)}
8990
LOGGER.info("Appending %s new transactions", len(body["values"]))
9091
self._service.spreadsheets().values().append(
@@ -95,7 +96,7 @@ def append_transactions(self, rows: Iterable[List[str]]) -> None:
9596
).execute()
9697

9798
def update_transaction(self, row_index: int, row: List[str]) -> None:
98-
range_name = f"{self._transactions_tab}!A{row_index}:K{row_index}"
99+
range_name = f"{self._transactions_tab}!A{row_index}:L{row_index}"
99100
LOGGER.info("Updating row %s", row_index)
100101
self._service.spreadsheets().values().update(
101102
spreadsheetId=self._spreadsheet_id,
@@ -111,7 +112,7 @@ def batch_update_transactions(self, updates: List[tuple[int, List[str]]]) -> Non
111112

112113
data = [
113114
{
114-
"range": f"{self._transactions_tab}!A{row_index}:K{row_index}",
115+
"range": f"{self._transactions_tab}!A{row_index}:L{row_index}",
115116
"values": [row]
116117
}
117118
for row_index, row in updates
@@ -153,20 +154,21 @@ def delete_rows(self, row_indices: List[int]) -> None:
153154
raise
154155

155156
def fetch_category_rules(self) -> List[Dict[str, str]]:
156-
range_name = f"{self._category_tab}!A2:E"
157+
range_name = f"{self._category_tab}!A2:F"
157158
response = self._service.spreadsheets().values().get(
158159
spreadsheetId=self._spreadsheet_id, range=range_name
159160
).execute()
160161
rules = []
161162
for row in response.get("values", []):
162-
padded = row + [""] * (5 - len(row))
163+
padded = row + [""] * (6 - len(row))
163164
rules.append(
164165
{
165166
"pattern": padded[0],
166167
"field": padded[1] or "merchant_normalised",
167168
"category": padded[2] or "Uncategorised",
168169
"priority": padded[3] or "1000",
169170
"amount_condition": padded[4],
171+
"category_type": padded[5],
170172
}
171173
)
172174
return rules
@@ -177,15 +179,15 @@ def upload_category_rules(self, rows: List[List[str]]) -> None:
177179
raise ValueError("No rows to upload")
178180

179181
# Clear existing content (keep header row)
180-
range_name = f"{self._category_tab}!A2:E"
182+
range_name = f"{self._category_tab}!A2:F"
181183
self._service.spreadsheets().values().clear(
182184
spreadsheetId=self._spreadsheet_id,
183185
range=range_name
184186
).execute()
185187
LOGGER.info("Cleared existing category rules")
186188

187189
# Upload new data (including header if first row)
188-
range_name = f"{self._category_tab}!A1:E"
190+
range_name = f"{self._category_tab}!A1:F"
189191
body = {"values": rows}
190192
self._service.spreadsheets().values().update(
191193
spreadsheetId=self._spreadsheet_id,

tests/test_akahu_client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,12 @@ def test_transaction_from_payload(transaction_payload):
5454

5555
def test_transaction_to_row_formats_numbers(transaction_payload):
5656
txn = AkahuTransaction.from_payload(transaction_payload, source="akahu_bnz", account_name="Cheque")
57-
row = txn.to_row(category="Groceries", is_transfer=False, imported_at=dt.datetime(2023, 9, 2, 10, 0))
57+
row = txn.to_row(category="Groceries", category_type="E", is_transfer=False, imported_at=dt.datetime(2023, 9, 2, 10, 0))
5858
assert row[:4] == ["txn_123", "2023-09-02", "Cheque", "-12.34"]
5959
assert row[4] == "120.55"
6060
assert row[7] == "Groceries"
61-
assert row[8] == "FALSE"
61+
assert row[8] == "E"
62+
assert row[9] == "FALSE"
6263

6364

6465
def test_fetch_settled_transactions_paginates(transaction_payload):

tests/test_categoriser.py

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ def test_categoriser_applies_highest_priority_match():
88
]
99
categoriser = Categoriser(rules)
1010
txn = {"merchant_normalised": "Countdown Ponsonby"}
11-
assert categoriser.categorise(txn) == "Groceries"
11+
category, category_type = categoriser.categorise(txn)
12+
assert category == "Groceries"
1213

1314

1415
def test_categoriser_skips_empty_patterns():
@@ -17,7 +18,8 @@ def test_categoriser_skips_empty_patterns():
1718
{"pattern": "ferry", "category": "Transport"},
1819
]
1920
categoriser = Categoriser(rules)
20-
assert categoriser.categorise({"merchant_normalised": "Ferry ride"}) == "Transport"
21+
category, category_type = categoriser.categorise({"merchant_normalised": "Ferry ride"})
22+
assert category == "Transport"
2123

2224

2325
def test_categoriser_honours_amount_conditions():
@@ -36,14 +38,10 @@ def test_categoriser_honours_amount_conditions():
3638
},
3739
]
3840
categoriser = Categoriser(rules)
39-
assert (
40-
categoriser.categorise({"merchant_normalised": "New World", "amount": "12.00"})
41-
== "Groceries"
42-
)
43-
assert (
44-
categoriser.categorise({"merchant_normalised": "New World", "amount": "5.00"})
45-
== "Snacks"
46-
)
41+
category1, _ = categoriser.categorise({"merchant_normalised": "New World", "amount": "12.00"})
42+
assert category1 == "Groceries"
43+
category2, _ = categoriser.categorise({"merchant_normalised": "New World", "amount": "5.00"})
44+
assert category2 == "Snacks"
4745

4846

4947
def test_categoriser_supports_exact_amounts_and_or_conditions():
@@ -68,18 +66,12 @@ def test_categoriser_supports_exact_amounts_and_or_conditions():
6866
},
6967
]
7068
categoriser = Categoriser(rules)
71-
assert (
72-
categoriser.categorise({"merchant_normalised": "Coffee", "amount": "4.50"})
73-
== "Work Coffee"
74-
)
75-
assert (
76-
categoriser.categorise({"merchant_normalised": "Coffee", "amount": "0"})
77-
== "Free Coffee"
78-
)
79-
assert (
80-
categoriser.categorise({"merchant_normalised": "Coffee", "amount": "-4"})
81-
== "Discount Coffee"
82-
)
69+
category1, _ = categoriser.categorise({"merchant_normalised": "Coffee", "amount": "4.50"})
70+
assert category1 == "Work Coffee"
71+
category2, _ = categoriser.categorise({"merchant_normalised": "Coffee", "amount": "0"})
72+
assert category2 == "Free Coffee"
73+
category3, _ = categoriser.categorise({"merchant_normalised": "Coffee", "amount": "-4"})
74+
assert category3 == "Discount Coffee"
8375

8476

8577
def test_categoriser_ignores_amount_condition_when_unparseable():
@@ -91,7 +83,8 @@ def test_categoriser_ignores_amount_condition_when_unparseable():
9183
}
9284
]
9385
categoriser = Categoriser(rules)
94-
assert categoriser.categorise({"merchant_normalised": "Countdown", "amount": "1"}) == "Groceries"
86+
category, _ = categoriser.categorise({"merchant_normalised": "Countdown", "amount": "1"})
87+
assert category == "Groceries"
9588

9689

9790
def test_categoriser_falls_back_when_amount_missing():
@@ -104,10 +97,42 @@ def test_categoriser_falls_back_when_amount_missing():
10497
{"pattern": "fuel", "category": "Misc"},
10598
]
10699
categoriser = Categoriser(rules)
107-
assert categoriser.categorise({"merchant_normalised": "Fuel stop"}) == "Misc"
100+
category, _ = categoriser.categorise({"merchant_normalised": "Fuel stop"})
101+
assert category == "Misc"
108102

109103

110104
def test_detect_transfer_checks_multiple_fields():
111105
txn = {"description_raw": "Internal Transfer", "merchant_normalised": "BNZ"}
112106
assert Categoriser.detect_transfer(txn) is True
113107
assert Categoriser.detect_transfer({"description_raw": "Cafe"}) is False
108+
109+
110+
def test_categoriser_returns_category_type():
111+
rules = [
112+
{
113+
"pattern": "countdown",
114+
"category": "Groceries",
115+
"category_type": "E",
116+
"priority": 10,
117+
},
118+
{
119+
"pattern": "sharesies",
120+
"category": "Investments",
121+
"category_type": "Iv",
122+
"priority": 5,
123+
},
124+
]
125+
categoriser = Categoriser(rules)
126+
127+
category, category_type = categoriser.categorise({"merchant_normalised": "Countdown"})
128+
assert category == "Groceries"
129+
assert category_type == "E"
130+
131+
category, category_type = categoriser.categorise({"merchant_normalised": "Sharesies"})
132+
assert category == "Investments"
133+
assert category_type == "Iv"
134+
135+
# Test uncategorised transaction
136+
category, category_type = categoriser.categorise({"merchant_normalised": "Unknown Store"})
137+
assert category == "Uncategorised"
138+
assert category_type == ""

0 commit comments

Comments
 (0)