feat: Parametrise converter by txn type

This commit is contained in:
2026-02-12 21:48:10 +10:00
parent c891ee883b
commit 9f37b94209
5 changed files with 123 additions and 76 deletions
-57
View File
@@ -65,10 +65,7 @@ class PartialTXN:
tags: str = ""
links: str = ""
source_posting: PartialPosting
source_fee_asset_posting: Optional[PartialPosting] = None
source_fee_expense_posting: Optional[PartialPosting] = None
target_posting: PartialPosting
target_fee_expense_posting: Optional[PartialPosting] = None
# Exposing set of fields which may be updated (e.g. from rules)
# Map field name -> display name
@@ -81,17 +78,11 @@ class PartialTXN:
"tags": "Tags",
"links": "Links",
"source_account": "Source Account",
"source_fee_asset_account": "Source Fee Asset Account",
"source_fee_expense_account": "Source Fee Expense Account",
"target_account": "Target Account",
"target_fee_expense_account": "Target Fee Expense Account",
}
posting_fields = {
"source_posting": "source_account",
"source_fee_asset_posting": "source_fee_asset_account",
"source_fee_expense_posting": "source_fee_expense_account",
"target_posting": "target_account",
"target_fee_expense_posting": "target_fee_expense_account",
}
@property
@@ -110,48 +101,6 @@ class PartialTXN:
def target_account(self, value: str):
self.target_posting.account = value
@property
def source_fee_asset_account(self):
if (posting := self.source_fee_asset_posting) is not None:
return posting.account
@source_fee_asset_account.setter
def source_fee_asset_account(self, value: str):
if (posting := self.source_fee_asset_posting) is not None:
posting.account = value
else:
raise ValueError(
"This TXN does not have a source fee asset posting to set."
)
@property
def source_fee_expense_account(self):
if (posting := self.source_fee_expense_posting) is not None:
return posting.account
@source_fee_expense_account.setter
def source_fee_expense_account(self, value: str):
if (posting := self.source_fee_expense_posting) is not None:
posting.account = value
else:
raise ValueError(
"This TXN does not have a source fee expense posting to set."
)
@property
def target_fee_expense_account(self):
if (posting := self.target_fee_expense_posting) is not None:
return posting.account
@target_fee_expense_account.setter
def target_fee_expense_account(self, value: str):
if (posting := self.target_fee_expense_posting) is not None:
posting.account = value
else:
raise ValueError(
"This TXN does not have a target fee expense posting to set."
)
@property
def postings(self):
postings = {}
@@ -265,12 +214,6 @@ class PartialTXN:
updater[name] = userstr or None
case "source_account" | "target_account":
updater[name] = userstr
case "source_fee_asset_account":
updater[name] = userstr
case "source_fee_expense_account":
updater[name] = userstr
case "target_fee_expense_account":
updater[name] = userstr
case _:
raise ValueError(f"Unknown field {name} passed to TXN parser.")
return updater
+104 -6
View File
@@ -1,14 +1,16 @@
import csv
from typing import Optional
from dataclasses import dataclass
from enum import Enum
import datetime as dt
from datetime import datetime
import logging
import csv
from ..base import Converter, PartialTXN, PartialPosting, Record, Amount
from ..base.converter import ConverterConfig
from ..base.rules import RuleSet
from ..base.transaction import TXNFlag
from ..base.partial import UserInputError
from . import available_converter
@@ -212,11 +214,107 @@ class WiseConfig(ConverterConfig):
return cls(data["asset-account"], data["fee-account"])
@dataclass(kw_only=True)
class WisePartialTXN(PartialTXN):
source_fee_asset_posting: Optional[PartialPosting] = None
source_fee_expense_posting: Optional[PartialPosting] = None
target_fee_expense_posting: Optional[PartialPosting] = None
fields = PartialTXN.fields | {
"source_fee_asset_account": "Source Fee Asset Account",
"source_fee_expense_account": "Source Fee Expense Account",
"target_fee_expense_account": "Target Fee Expense Account",
}
posting_fields = PartialTXN.posting_fields | {
"source_fee_asset_posting": "source_fee_asset_account",
"source_fee_expense_posting": "source_fee_expense_account",
"target_fee_expense_posting": "target_fee_expense_account",
}
@property
def source_fee_asset_account(self):
if (posting := self.source_fee_asset_posting) is not None:
return posting.account
@source_fee_asset_account.setter
def source_fee_asset_account(self, value: str):
if (posting := self.source_fee_asset_posting) is not None:
posting.account = value
else:
raise ValueError(
"This TXN does not have a source fee asset posting to set."
)
@property
def source_fee_expense_account(self):
if (posting := self.source_fee_expense_posting) is not None:
return posting.account
@source_fee_expense_account.setter
def source_fee_expense_account(self, value: str):
if (posting := self.source_fee_expense_posting) is not None:
posting.account = value
else:
raise ValueError(
"This TXN does not have a source fee expense posting to set."
)
@property
def target_fee_expense_account(self):
if (posting := self.target_fee_expense_posting) is not None:
return posting.account
@target_fee_expense_account.setter
def target_fee_expense_account(self, value: str):
if (posting := self.target_fee_expense_posting) is not None:
posting.account = value
else:
raise ValueError(
"This TXN does not have a target fee expense posting to set."
)
def parse_input(self, entries: dict[str, str]):
"""
Parse a map of field name -> user entered strings
into a dictionary which may be used in update()
"""
updater = {}
for name, userstr in entries.items():
userstr = userstr.strip()
# TODO: Each of these cases needs custom validation
match name:
case "flag":
if userstr == "!":
updater["flag"] = TXNFlag.INCOMPLETE.value
elif userstr == "*":
updater["flag"] = TXNFlag.COMPLETE.value
else:
raise UserInputError(
"Transaction flag must be either '*' or '!'"
)
case "payee" | "narration" | "tags" | "links":
updater[name] = userstr
case "comment" | "document":
updater[name] = userstr or None
case "source_account" | "target_account":
updater[name] = userstr
case "source_fee_asset_account":
updater[name] = userstr
case "source_fee_expense_account":
updater[name] = userstr
case "target_fee_expense_account":
updater[name] = userstr
case _:
raise ValueError(f"Unknown field {name} passed to TXN parser.")
return updater
@available_converter
class WiseConverter(Converter[WiseRecord, PartialTXN, WiseConfig]):
class WiseConverter(Converter[WiseRecord, WisePartialTXN, WiseConfig]):
record_type = WiseRecord
config_type = WiseConfig
txn_type = PartialTXN
txn_type = WisePartialTXN
converter_name = "wise"
version = "0"
display_name = "Wise Record Converter v0"
@@ -225,9 +323,9 @@ class WiseConverter(Converter[WiseRecord, PartialTXN, WiseConfig]):
def __init__(self, config: WiseConfig, **kwargs):
self.config = config
def annotation(self, record: WiseRecord, partial: PartialTXN): ...
def annotation(self, record: WiseRecord, partial: WisePartialTXN): ...
def convert(self, record: WiseRecord, ruleset: RuleSet) -> PartialTXN:
def convert(self, record: WiseRecord, ruleset: RuleSet) -> WisePartialTXN:
fields = {}
match record.direction:
@@ -302,7 +400,7 @@ class WiseConverter(Converter[WiseRecord, PartialTXN, WiseConfig]):
amount=record.target_fee,
)
txn = PartialTXN(**args)
txn = WisePartialTXN(**args)
logger.debug(f"Converted Wise Record {record!r} to Partial Transaction {txn!r}")
return txn
+10 -8
View File
@@ -74,8 +74,12 @@ class MainWindow(ThemedTk):
menu_file.add_command(label="Save Rules", command=self.do_save_rules)
menu_file.add_command(label="Reload Rules", command=self.do_reload_rules)
menu_file.add_separator()
menu_file.add_command(label="Save Partial Transactions", command=self.do_save_partial)
menu_file.add_command(label="Load Partial Transactions", command=self.do_load_partial)
menu_file.add_command(
label="Save Partial Transactions", command=self.do_save_partial
)
menu_file.add_command(
label="Load Partial Transactions", command=self.do_load_partial
)
menu_file.add_separator()
menu_file.add_command(label="Exit", command=lambda: self.destroy())
self.menubar.add_cascade(menu=menu_file, label="File")
@@ -100,9 +104,7 @@ class MainWindow(ThemedTk):
self.bind_all("<Alt-k>", lambda event: self.rowtree.focus_prev())
self.bind_all("<Alt-j>", lambda event: self.rowtree.focus_next())
self.bind_all("<Alt-Shift-K>", lambda event: self.rowtree.focus_prev_partial())
self.bind_all(
"<Alt-Shift-J>", lambda event: self.rowtree.focus_next_partial()
)
self.bind_all("<Alt-Shift-J>", lambda event: self.rowtree.focus_next_partial())
self.bind_all(
"<Alt-KeyPress-2>", lambda event: self.editor.focus_record_frame()
@@ -138,7 +140,7 @@ class MainWindow(ThemedTk):
self.content.add(self.rowtree, weight=1)
self.editor = RowEditor(
self, acmpl_cache=self.account_cache, padding=(3, 3, 12, 12)
self, self.converter, acmpl_cache=self.account_cache, padding=(3, 3, 12, 12)
)
self.content.add(self.editor, weight=1)
@@ -335,7 +337,7 @@ class MainWindow(ThemedTk):
# Merge into a map, and update the cached map with it.
# Build the list of account names we want to acmpl
field_names = list(PartialTXN.posting_fields.values())
field_names = list(self.converter.txn_type.posting_fields.values())
cache = {name: set() for name in field_names}
# Read the ruleset rules for options
@@ -400,7 +402,7 @@ class MainWindow(ThemedTk):
if result["converter"] != self.converter.qual_name():
messagebox.showerror(
"Load Error",
f"Cannot load partial data for converter '{result['converter']}'"
f"Cannot load partial data for converter '{result['converter']}'",
)
return
self.rows |= result["rows"]
+6 -2
View File
@@ -1,6 +1,8 @@
from tkinter import BooleanVar, StringVar, ttk
from typing import Type
from ..base.converter import Converter
from ..base.partial import PartialTXN
from ..base.record import Record
@@ -8,9 +10,11 @@ from . import logger
class RowEditor(ttk.Frame):
def __init__(self, master, acmpl_cache={}, **kwargs):
def __init__(self, master, converter: Converter, acmpl_cache={}, **kwargs):
super().__init__(master, **kwargs)
self.converter = converter
# Data state
self.rows: list[tuple[Record, PartialTXN]] = []
self.acmpl_cache: dict[str, list[str]] = acmpl_cache
@@ -432,7 +436,7 @@ class RowEditor(ttk.Frame):
)
# The txn display fields will always be a subset of this
fieldnames = PartialTXN.fields
fieldnames = self.converter.txn_type.fields
# field -> stringvar
txnvars = {name: StringVar() for name in fieldnames}
+3 -3
View File
@@ -175,10 +175,10 @@ class RowTree(ttk.Frame):
"txn.tags": ("Bean Tags", str),
"txn.links": ("Bean Links", str),
"txn.source_account": ("Bean Source", str),
"txn.source_fee_asset_account": ("Bean Source Fee Asset Acc", str),
"txn.source_fee_expense_account": ("Bean Source Fee Expense Acc", str),
# "txn.source_fee_asset_account": ("Bean Source Fee Asset Acc", str),
# "txn.source_fee_expense_account": ("Bean Source Fee Expense Acc", str),
"txn.target_account": ("Bean Target", str),
"txn.target_fee_expense_account": ("Bean Target Fee Acc", str),
# "txn.target_fee_expense_account": ("Bean Target Fee Acc", str),
}
return columns