Compare commits

...

8 Commits

Author SHA1 Message Date
conatum 79bbb720b0 feat: Support metadata in transaction
Also moves the comment for several converters to original metadata
2026-10-05 20:27:26 +10:00
conatum 42751393ad (paypal): Add basic paypal converter 2026-09-24 11:15:20 +10:00
conatum 2f514402e3 (wise): Add refunded type 2026-09-22 19:13:16 +10:00
conatum 9f37b94209 feat: Parametrise converter by txn type 2026-02-12 21:48:10 +10:00
conatum c891ee883b Add txn type parametrisation for converter 2026-02-12 21:28:26 +10:00
conatum bde2b65710 tweak(gui): Change accelerators to prevent clash 2026-02-12 20:49:56 +10:00
conatum 0340d1dd1b fix(cbacsv): More flexible date header 2026-02-12 19:47:43 +10:00
conatum 1099339b35 Add partial save and load 2026-02-12 19:47:17 +10:00
14 changed files with 625 additions and 110 deletions
+4 -3
View File
@@ -44,7 +44,7 @@ class ConverterConfig:
raise NotImplementedError raise NotImplementedError
class Converter[RecordT: Record, ConfigT: ConverterConfig]: class Converter[RecordT: Record, TxnT: PartialTXN, ConfigT: ConverterConfig]:
""" """
ABC for Record -> Transaction conversion interface. ABC for Record -> Transaction conversion interface.
@@ -60,6 +60,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
record_type: ClassVar[Type[RecordT]] record_type: ClassVar[Type[RecordT]]
config_type: ClassVar[Type[ConfigT]] config_type: ClassVar[Type[ConfigT]]
txn_type: ClassVar[Type[TxnT]]
def __init__(self, config: ConfigT, **kwargs): def __init__(self, config: ConfigT, **kwargs):
self.config = config self.config = config
@@ -68,7 +69,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
def qual_name(cls): def qual_name(cls):
return f"{cls.converter_name}_v{cls.version}" return f"{cls.converter_name}_v{cls.version}"
def annotation(self, record: RecordT, partial: PartialTXN) -> Optional[str]: def annotation(self, record: RecordT, partial: TxnT) -> Optional[str]:
""" """
Optional user-readable note/warning to attach to a mapped record. Optional user-readable note/warning to attach to a mapped record.
@@ -76,7 +77,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
""" """
raise NotImplementedError raise NotImplementedError
def convert(self, record: RecordT, ruleset: RuleSet) -> PartialTXN | _SKIPT: def convert(self, record: RecordT, ruleset: RuleSet) -> TxnT | _SKIPT:
""" """
The meat of the conversion process. The meat of the conversion process.
Take a raw Record from the data and convert it into a partial transaction, Take a raw Record from the data and convert it into a partial transaction,
+8 -58
View File
@@ -1,5 +1,5 @@
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
from dataclasses import dataclass, replace from dataclasses import dataclass, field, replace
import datetime as dt import datetime as dt
from .transaction import ABCPosting, Transaction, TXNPosting, TXNFlag from .transaction import ABCPosting, Transaction, TXNPosting, TXNFlag
@@ -62,13 +62,12 @@ class PartialTXN:
narration: str = "" narration: str = ""
comment: Optional[str] = None comment: Optional[str] = None
document: Optional[str] = None document: Optional[str] = None
original: Optional[str] = None
metadata: dict[str, str] = field(default_factory=dict)
tags: str = "" tags: str = ""
links: str = "" links: str = ""
source_posting: PartialPosting source_posting: PartialPosting
source_fee_asset_posting: Optional[PartialPosting] = None
source_fee_expense_posting: Optional[PartialPosting] = None
target_posting: PartialPosting target_posting: PartialPosting
target_fee_expense_posting: Optional[PartialPosting] = None
# Exposing set of fields which may be updated (e.g. from rules) # Exposing set of fields which may be updated (e.g. from rules)
# Map field name -> display name # Map field name -> display name
@@ -81,17 +80,11 @@ class PartialTXN:
"tags": "Tags", "tags": "Tags",
"links": "Links", "links": "Links",
"source_account": "Source Account", "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_account": "Target Account",
"target_fee_expense_account": "Target Fee Expense Account",
} }
posting_fields = { posting_fields = {
"source_posting": "source_account", "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_posting": "target_account",
"target_fee_expense_posting": "target_fee_expense_account",
} }
@property @property
@@ -110,48 +103,6 @@ class PartialTXN:
def target_account(self, value: str): def target_account(self, value: str):
self.target_posting.account = value 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 @property
def postings(self): def postings(self):
postings = {} postings = {}
@@ -194,6 +145,10 @@ class PartialTXN:
elif self.partial: elif self.partial:
raise ValueError("Cannot upgrade partial transaction.") raise ValueError("Cannot upgrade partial transaction.")
else: else:
if self.original is not None:
metadata = self.metadata | {"original": self.original}
else:
metadata = self.metadata.copy()
upgraded = Transaction( upgraded = Transaction(
date=self.date, date=self.date,
flag=TXNFlag(self.flag), flag=TXNFlag(self.flag),
@@ -201,6 +156,7 @@ class PartialTXN:
narration=self.narration, narration=self.narration,
comments=[self.comment] if self.comment else [], comments=[self.comment] if self.comment else [],
document=[self.document] if self.document else [], document=[self.document] if self.document else [],
metadata=metadata,
tags=self.tags.split(), tags=self.tags.split(),
links=self.links.split(), links=self.links.split(),
postings=[p.upgrade() for p in self.postings.values()], postings=[p.upgrade() for p in self.postings.values()],
@@ -265,12 +221,6 @@ class PartialTXN:
updater[name] = userstr or None updater[name] = userstr or None
case "source_account" | "target_account": case "source_account" | "target_account":
updater[name] = userstr 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 _: case _:
raise ValueError(f"Unknown field {name} passed to TXN parser.") raise ValueError(f"Unknown field {name} passed to TXN parser.")
return updater return updater
+4
View File
@@ -141,6 +141,7 @@ class Transaction:
self.documents: list[str] = kwargs.get("documents", []) self.documents: list[str] = kwargs.get("documents", [])
self.tags: list[str] = kwargs.get("tags", []) self.tags: list[str] = kwargs.get("tags", [])
self.links: list[str] = kwargs.get("links", []) self.links: list[str] = kwargs.get("links", [])
self.metadata: dict[str, str] = kwargs.get("metadata", {})
self.postings: list[TXNPosting] = kwargs.get("postings", []) self.postings: list[TXNPosting] = kwargs.get("postings", [])
@@ -164,6 +165,9 @@ class Transaction:
).strip() ).strip()
lines = [] lines = []
for key, value in self.metadata.items():
lines.append(f'{key}: "{value}"')
for comment in self.comments: for comment in self.comments:
lines.append("; " + comment) lines.append("; " + comment)
+1
View File
@@ -34,3 +34,4 @@ def available_converter(converter_cls):
from .wise_converter import * from .wise_converter import *
from .cba_converter import * from .cba_converter import *
from .cbastatement_converter import * from .cbastatement_converter import *
from .paypal_converter import *
+4 -3
View File
@@ -97,9 +97,10 @@ class CBAConfig(ConverterConfig):
@available_converter @available_converter
class CBAConverter(Converter[CBARecord, CBAConfig]): class CBAConverter(Converter[CBARecord, PartialTXN, CBAConfig]):
record_type = CBARecord record_type = CBARecord
config_type = CBAConfig config_type = CBAConfig
txn_type = PartialTXN
converter_name = "cbacsv" converter_name = "cbacsv"
version = "0" version = "0"
display_name = "CBACSV converter v0" display_name = "CBACSV converter v0"
@@ -143,7 +144,7 @@ class CBAConverter(Converter[CBARecord, CBAConfig]):
account=fields.get("target_account", None), account=fields.get("target_account", None),
amount=record.to_target, amount=record.to_target,
) )
args.setdefault("comment", record.description) args.setdefault("original", record.description)
txn = PartialTXN(**args) txn = PartialTXN(**args)
logger.debug(f"Converted CBA CSV Record {record!r} to PartialTXN {txn!r}") logger.debug(f"Converted CBA CSV Record {record!r} to PartialTXN {txn!r}")
@@ -205,7 +206,7 @@ class CBAConverter(Converter[CBARecord, CBAConfig]):
records = [] records = []
for row in reader: for row in reader:
if row[0].lower().strip() == "date": if "date" in row[0].lower().strip():
# Skip header row # Skip header row
continue continue
record = self._make_record(CBACSVRow(*row)) record = self._make_record(CBACSVRow(*row))
@@ -94,9 +94,10 @@ class CBASTConfig(ConverterConfig):
@available_converter @available_converter
class CBASTConverter(Converter[CBASTRecord, CBASTConfig]): class CBASTConverter(Converter[CBASTRecord, PartialTXN, CBASTConfig]):
record_type = CBASTRecord record_type = CBASTRecord
config_type = CBASTConfig config_type = CBASTConfig
txn_type = PartialTXN
converter_name = "cbast" converter_name = "cbast"
version = "0" version = "0"
display_name = "CBAST converter v0" display_name = "CBAST converter v0"
@@ -140,7 +141,7 @@ class CBASTConverter(Converter[CBASTRecord, CBASTConfig]):
account=fields.get("target_account", None), account=fields.get("target_account", None),
amount=record.to_target, amount=record.to_target,
) )
args.setdefault("comment", record.description) args.setdefault("original", record.description)
txn = PartialTXN(**args) txn = PartialTXN(**args)
logger.debug(f"Converted CBA Statement Record {record!r} to PartialTXN {txn!r}") logger.debug(f"Converted CBA Statement Record {record!r} to PartialTXN {txn!r}")
+398
View File
@@ -0,0 +1,398 @@
"""
Beanify Converter implementation for the hot mess which is paypal csv records.
"""
from typing import NamedTuple, Optional
from dataclasses import dataclass, field
from enum import Enum
import datetime as dt
from datetime import date, datetime, time
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, TXNPosting, Transaction
from ..base.partial import UserInputError
from . import available_converter
logger = logging.getLogger(__name__)
__all__ = [
"PaypalRecord",
"PaypalRecordDirection",
"PaypalCSVRow",
"PaypalPartialTXN",
"PaypalConverter",
]
class PaypalCSVRow(NamedTuple):
date: str
time: str
timezone: str
name: str
txntype: str
status: str
currency: str
amount: str
fees: str
total: str
ex_rate: str
receipt_id: str
balance: str
txnid: str
item_title: str
class PaypalRecordDirection(Enum):
IGNORE = -1
TRANSFERIN = 0
IN = 1
CONVERT = 2
OUT = 3
TRANSFEROUT = 0
def get_direction(typestr):
match typestr:
case "Reversal of General Account Hold":
return PaypalRecordDirection.IGNORE
case "General Currency Conversion":
return PaypalRecordDirection.CONVERT
case "Subscription Payment":
return PaypalRecordDirection.OUT
case "Express Checkout Payment":
return PaypalRecordDirection.OUT
case "Pre-approved Payment Bill User Payment":
return PaypalRecordDirection.OUT
case "Mass payment":
return PaypalRecordDirection.IN
case "Transfer to PayPal account":
return PaypalRecordDirection.TRANSFERIN
case "Account Hold for Open Authorisation":
return PaypalRecordDirection.IGNORE
case "User Initiated Withdrawal":
# TODO: Probably don't actually want to ignore this
# return PaypalRecordDirection.TRANSFEROUT
return PaypalRecordDirection.IGNORE
case "Reversal of ACH Withdrawal Transaction":
return PaypalRecordDirection.IGNORE
case "BillPay transaction":
return PaypalRecordDirection.OUT
case "Payment Refund":
return PaypalRecordDirection.IN
case "Reversal of ACH Deposit":
return PaypalRecordDirection.IGNORE
case "Mobile Payment":
return PaypalRecordDirection.IN
case _:
raise ValueError(f"Unknown Paypal typestr {typestr}")
@dataclass(kw_only=True, frozen=True, eq=False)
class PaypalRecord(Record):
# With time zone, for a change
created_on: datetime
txnid: str
name: str
txntype: str
direction: PaypalRecordDirection
balance_effect: Amount
ex_target_amount: Amount | None = None
# Each source amount is (Amount, exchange rate)
ex_source_amounts: list[tuple[Amount, float]] = field(default_factory=list)
transferred_in: Amount | None = None
_display_fields = [
("txnid", "ID"),
("txntype", "Type"),
("direction", "Direction"),
("created_on", "Created"),
("name", "Name"),
("source_account", "Source"),
("target_account", "Target"),
("balance_effect", "Balance Change"),
("transferred_in", "Transferred In"),
]
_match_fields = [
"txnid",
"txntype",
"direction",
"balance_effect",
"name",
]
@classmethod
def sample_record(cls):
self = cls(
date=dt.date.today(),
source_account="John Doe",
target_account="Jane Austen",
from_source=Amount(314, "CROC"),
to_target=Amount(314, "CROC"),
created_on=datetime.now(),
txnid="0",
name="Sample",
txntype="Payment Refund",
direction=PaypalRecordDirection.IN,
balance_effect=Amount(314, "CROC"),
transferred_in=Amount(314, "CROC"),
ex_target_amount=None,
ex_source_amounts=[],
)
return self
@dataclass
class PaypalConfig(ConverterConfig):
asset_account: str
required = {
"asset-account",
}
@classmethod
def from_dict(cls, data: dict) -> "PaypalConfig":
if (f := next((f for f in cls.required if f not in data), None)) is not None:
raise ValueError(f"Paypal Configuration missing required field: {f}")
return cls(data["asset-account"])
@dataclass(kw_only=True)
class PaypalPartialTXN(PartialTXN):
# Hard-coded postings that will be added to the final transaction
# Very much a hack, will fix later
extra_postings: list[TXNPosting] | None = None
def upgrade(self, defaults=...) -> Transaction:
txn = super().upgrade(defaults)
if self.extra_postings:
txn.postings.extend(self.extra_postings)
return txn
@available_converter
class PaypalConverter(Converter[PaypalRecord, PaypalPartialTXN, PaypalConfig]):
record_type = PaypalRecord
config_type = PaypalConfig
txn_type = PaypalPartialTXN
converter_name = "paypal"
version = "0"
display_name = "Paypal CSV converter v0"
config_field = "PAYPAL"
def __init__(self, config: PaypalConfig, *kwargs):
self.config = config
def convert(self, record: PaypalRecord, ruleset: RuleSet) -> PaypalPartialTXN:
fields = {}
match record.direction:
case PaypalRecordDirection.TRANSFERIN:
fields["target_account"] = self.config.asset_account
case PaypalRecordDirection.IN:
fields["target_account"] = self.config.asset_account
case PaypalRecordDirection.OUT:
fields["source_account"] = self.config.asset_account
case PaypalRecordDirection.TRANSFEROUT:
fields["source_account"] = self.config.asset_account
fields |= ruleset.apply(record.match_fields())
args = {}
args["date"] = record.date
for name in {
"payee",
"narration",
"comment",
"document",
"tags",
"links",
"flag",
}:
if name in fields:
args[name] = fields[name]
# source posting
# target posting
args["source_posting"] = PartialPosting(
account=fields.get("source_account", None),
amount=record.from_source,
total_cost=abs(record.to_target)
if abs(record.from_source) != abs(record.to_target)
else None,
)
args["target_posting"] = PartialPosting(
account=fields.get("target_account", None),
amount=record.to_target,
)
# comment
args.setdefault(
"original",
f"{record.txntype} | {record.name} | {record.txnid} | Transferred {record.transferred_in}",
)
currency_postings = []
if record.ex_target_amount is not None:
for source_amount, exrate in record.ex_source_amounts:
source_posting = TXNPosting(
account=self.config.asset_account,
amount=source_amount,
price=Amount(exrate, record.ex_target_amount.currency),
)
currency_postings.append(source_posting)
target_posting = TXNPosting(
account=self.config.asset_account, amount=record.ex_target_amount
)
currency_postings.append(target_posting)
txn = PaypalPartialTXN(extra_postings=currency_postings, **args)
logger.debug(f"Converted Paypal CSV Record {record!r} to PaypalTXN {txn!r}")
return txn
def _make_record(self, *rows: PaypalCSVRow) -> PaypalRecord | None:
# Find main, which is an in or out
main_txns = []
convert_rows = []
transfer_rows = []
for row in rows:
dir = get_direction(row.txntype)
if dir is PaypalRecordDirection.IGNORE:
continue
elif dir in (PaypalRecordDirection.IN, PaypalRecordDirection.OUT):
main_txns.append(row)
elif dir is PaypalRecordDirection.TRANSFERIN:
transfer_rows.append(row)
elif dir is PaypalRecordDirection.CONVERT:
convert_rows.append(row)
# Some sanity checks
if not main_txns:
# No main transaction
if not convert_rows:
return None
raise ValueError(
f"Paypal Row block with convert by no main transactions: {rows}"
)
if len(main_txns) > 1:
raise ValueError(f"Paypal Row block with multiple main txns: {rows}")
if len(convert_rows) == 1:
raise ValueError(f"Paypal Row block with orphan convert: {rows}")
if convert_rows and not any(not row.ex_rate for row in rows):
raise ValueError(f"Paypal Row block without main convert: {rows}")
if any(float(row.fees) for row in rows):
raise ValueError(f"Paypal Row block with fees: {rows}")
# We guarantee that we are somewhat sane
mainrow = main_txns[0]
direction = get_direction(mainrow.txntype)
dt_format = "%d/%m/%Y %H:%M:%S"
created_on = datetime.strptime(f"{mainrow.date} {mainrow.time}", dt_format)
balance_effect = Amount(float(mainrow.amount), mainrow.currency)
# transfer rows
if transfer_rows:
assert len({row.currency for row in transfer_rows}) == 1
transferred_in = Amount(
sum(float(row.amount) for row in transfer_rows),
transfer_rows[0].currency,
)
else:
transferred_in = None
# Currency conversion
source_amounts = []
target_amount = None
for row in convert_rows:
if row.ex_rate:
# If the row has an exchange rate, it's a source row
# Note the amount will be negative
source_details = (Amount(row.amount, row.currency), float(row.ex_rate))
source_amounts.append(source_details)
else:
# Otherwise, main row
target_amount = Amount(row.amount, row.currency)
if direction is PaypalRecordDirection.IN:
record = PaypalRecord(
date=created_on.date(),
source_account=mainrow.name,
target_account="ACCOUNT",
from_source=-balance_effect,
to_target=balance_effect,
created_on=created_on,
txnid=mainrow.txnid,
name=mainrow.name,
txntype=mainrow.txntype,
direction=direction,
balance_effect=balance_effect,
transferred_in=transferred_in,
ex_target_amount=target_amount,
ex_source_amounts=source_amounts,
)
else:
record = PaypalRecord(
date=created_on.date(),
target_account=mainrow.name,
source_account="ACCOUNT",
from_source=balance_effect,
to_target=-balance_effect,
created_on=created_on,
txnid=mainrow.txnid,
name=mainrow.name,
txntype=mainrow.txntype,
direction=direction,
balance_effect=balance_effect,
transferred_in=transferred_in,
ex_target_amount=target_amount,
ex_source_amounts=source_amounts,
)
return record
def ingest_string(self, data: str) -> list[PaypalRecord]:
"""
Parse a string of Paypal CSV input into a list of records
"""
reader = csv.reader(data.splitlines())
records = []
thisblock = []
thisblock_key = None
for i, row in enumerate(reader):
if not i:
# Skip header row
continue
row = PaypalCSVRow(*row)
key = (row.date, row.time)
if key != thisblock_key:
if thisblock:
# Flush
record = self._make_record(*thisblock)
if record:
records.append(record)
thisblock.clear()
thisblock_key = key
thisblock.append(row)
if thisblock:
# Flush
record = self._make_record(*thisblock)
if record:
records.append(record)
thisblock.clear()
return records
def ingest_file(self, path) -> list[PaypalRecord]:
with open(path) as f:
f.readline()
return self.ingest_string(f.read())
+2 -1
View File
@@ -65,9 +65,10 @@ class SampleConfig(ConverterConfig):
@available_converter @available_converter
class SampleConverter(Converter[SampleRecord, SampleConfig]): class SampleConverter(Converter[SampleRecord, PartialTXN, SampleConfig]):
record_type = SampleRecord record_type = SampleRecord
config_type = SampleConfig config_type = SampleConfig
txn_type = PartialTXN
converter_name = "sample" converter_name = "sample"
version = "0" version = "0"
display_name = "Sample converter v0" display_name = "Sample converter v0"
+107 -5
View File
@@ -1,14 +1,16 @@
import csv from typing import Optional
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
import datetime as dt import datetime as dt
from datetime import datetime from datetime import datetime
import logging import logging
import csv
from ..base import Converter, PartialTXN, PartialPosting, Record, Amount from ..base import Converter, PartialTXN, PartialPosting, Record, Amount
from ..base.converter import ConverterConfig from ..base.converter import ConverterConfig
from ..base.rules import RuleSet from ..base.rules import RuleSet
from ..base.transaction import TXNFlag from ..base.transaction import TXNFlag
from ..base.partial import UserInputError
from . import available_converter from . import available_converter
@@ -27,6 +29,7 @@ logger = logging.getLogger(__name__)
class WiseRecordStatus(Enum): class WiseRecordStatus(Enum):
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
REFUNDED = "REFUNDED"
class WiseRecordDirection(Enum): class WiseRecordDirection(Enum):
@@ -212,10 +215,107 @@ class WiseConfig(ConverterConfig):
return cls(data["asset-account"], data["fee-account"]) 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 @available_converter
class WiseConverter(Converter[WiseRecord, WiseConfig]): class WiseConverter(Converter[WiseRecord, WisePartialTXN, WiseConfig]):
record_type = WiseRecord record_type = WiseRecord
config_type = WiseConfig config_type = WiseConfig
txn_type = WisePartialTXN
converter_name = "wise" converter_name = "wise"
version = "0" version = "0"
display_name = "Wise Record Converter v0" display_name = "Wise Record Converter v0"
@@ -224,9 +324,9 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
def __init__(self, config: WiseConfig, **kwargs): def __init__(self, config: WiseConfig, **kwargs):
self.config = config 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 = {} fields = {}
match record.direction: match record.direction:
@@ -301,7 +401,9 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
amount=record.target_fee, amount=record.target_fee,
) )
txn = PartialTXN(**args) # TODO: Add original field
txn = WisePartialTXN(**args)
logger.debug(f"Converted Wise Record {record!r} to Partial Transaction {txn!r}") logger.debug(f"Converted Wise Record {record!r} to Partial Transaction {txn!r}")
return txn return txn
+60 -8
View File
@@ -1,3 +1,4 @@
import pickle
import tkinter as tk import tkinter as tk
from tkinter import StringVar, filedialog, messagebox, ttk from tkinter import StringVar, filedialog, messagebox, ttk
@@ -73,6 +74,13 @@ class MainWindow(ThemedTk):
menu_file.add_command(label="Save Rules", command=self.do_save_rules) 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_command(label="Reload Rules", command=self.do_reload_rules)
menu_file.add_separator() 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_separator()
menu_file.add_command(label="Exit", command=lambda: self.destroy()) menu_file.add_command(label="Exit", command=lambda: self.destroy())
self.menubar.add_cascade(menu=menu_file, label="File") self.menubar.add_cascade(menu=menu_file, label="File")
@@ -93,12 +101,10 @@ class MainWindow(ThemedTk):
# Alt+up/down for next entry in that direction # Alt+up/down for next entry in that direction
# Alt+shift+up/down for next incomplete entry in that direction # Alt+shift+up/down for next incomplete entry in that direction
self.bind_all("<Alt-KeyPress-1>", lambda event: self.rowtree.grab_focus()) self.bind_all("<Alt-KeyPress-1>", lambda event: self.rowtree.grab_focus())
self.bind_all("<Alt-Up>", lambda event: self.rowtree.focus_prev()) self.bind_all("<Alt-k>", lambda event: self.rowtree.focus_prev())
self.bind_all("<Alt-Down>", lambda event: self.rowtree.focus_next()) self.bind_all("<Alt-j>", lambda event: self.rowtree.focus_next())
self.bind_all("<Alt-Shift-Up>", lambda event: self.rowtree.focus_prev_partial()) self.bind_all("<Alt-Shift-K>", lambda event: self.rowtree.focus_prev_partial())
self.bind_all( self.bind_all("<Alt-Shift-J>", lambda event: self.rowtree.focus_next_partial())
"<Alt-Shift-Down>", lambda event: self.rowtree.focus_next_partial()
)
self.bind_all( self.bind_all(
"<Alt-KeyPress-2>", lambda event: self.editor.focus_record_frame() "<Alt-KeyPress-2>", lambda event: self.editor.focus_record_frame()
@@ -134,7 +140,7 @@ class MainWindow(ThemedTk):
self.content.add(self.rowtree, weight=1) self.content.add(self.rowtree, weight=1)
self.editor = RowEditor( 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) self.content.add(self.editor, weight=1)
@@ -331,7 +337,7 @@ class MainWindow(ThemedTk):
# Merge into a map, and update the cached map with it. # Merge into a map, and update the cached map with it.
# Build the list of account names we want to acmpl # 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} cache = {name: set() for name in field_names}
# Read the ruleset rules for options # Read the ruleset rules for options
@@ -359,3 +365,49 @@ class MainWindow(ThemedTk):
""" """
self.ruleset.reload_rules() self.ruleset.reload_rules()
self.regenerate_rows() self.regenerate_rows()
def do_save_partial(self):
"""
Save all partial entries to be loaded later.
"""
flattened = {
"converter": self.converter.qual_name(),
"rows": self.rows,
"custom": self.custom,
}
result = pickle.dumps(flattened)
path = filedialog.asksaveasfilename(
defaultextension=".pickle",
filetypes=[
("All Files", "*.*"),
],
)
if path:
with open(path, "wb") as f:
f.write(result)
message = f"Saved progress to {path}"
else:
message = "Save cancelled"
self.update_status(message)
def do_load_partial(self):
# Prompt for file to ingest
path = filedialog.askopenfilename(
defaultextension=".pickle",
filetypes=[("Pickle Files", ".pickle"), ("All Files", "*.*")],
)
with open(path, "rb") as f:
result = pickle.load(f)
if result["converter"] != self.converter.qual_name():
messagebox.showerror(
"Load Error",
f"Cannot load partial data for converter '{result['converter']}'",
)
return
self.rows |= result["rows"]
self.custom |= result["custom"]
self.rowtree.update_rows(self.rows)
self.show_ingest_summary([path], self.rows)
self.rebuild_account_cache()
+6 -2
View File
@@ -1,6 +1,8 @@
from tkinter import BooleanVar, StringVar, ttk from tkinter import BooleanVar, StringVar, ttk
from typing import Type
from ..base.converter import Converter
from ..base.partial import PartialTXN from ..base.partial import PartialTXN
from ..base.record import Record from ..base.record import Record
@@ -8,9 +10,11 @@ from . import logger
class RowEditor(ttk.Frame): class RowEditor(ttk.Frame):
def __init__(self, master, acmpl_cache={}, **kwargs): def __init__(self, master, converter: Converter, acmpl_cache={}, **kwargs):
super().__init__(master, **kwargs) super().__init__(master, **kwargs)
self.converter = converter
# Data state # Data state
self.rows: list[tuple[Record, PartialTXN]] = [] self.rows: list[tuple[Record, PartialTXN]] = []
self.acmpl_cache: dict[str, list[str]] = acmpl_cache 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 # The txn display fields will always be a subset of this
fieldnames = PartialTXN.fields fieldnames = self.converter.txn_type.fields
# field -> stringvar # field -> stringvar
txnvars = {name: StringVar() for name in fieldnames} 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.tags": ("Bean Tags", str),
"txn.links": ("Bean Links", str), "txn.links": ("Bean Links", str),
"txn.source_account": ("Bean Source", str), "txn.source_account": ("Bean Source", str),
"txn.source_fee_asset_account": ("Bean Source Fee Asset 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.source_fee_expense_account": ("Bean Source Fee Expense Acc", str),
"txn.target_account": ("Bean Target", 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 return columns
+9 -9
View File
@@ -1,45 +1,45 @@
2025-06-29 ! "Amazon" "Something from Amazon AU" 2025-06-29 ! "Amazon" "Something from Amazon AU"
; AMAZON AU MARKETPLACE SYDNEY original: "AMAZON AU MARKETPLACE SYDNEY"
Liabilities:CBA:CreditCard -70.4 AUD Liabilities:CBA:CreditCard -70.4 AUD
Expenses:Shopping:Amazon 70.4 AUD Expenses:Shopping:Amazon 70.4 AUD
2025-06-24 * "APIA" "House Insurance" 2025-06-24 * "APIA" "House Insurance"
; APIA BRISBANE QLD original: "APIA BRISBANE QLD"
Liabilities:CBA:CreditCard -322.39 AUD Liabilities:CBA:CreditCard -322.39 AUD
Expenses:House:Insurance 322.39 AUD Expenses:House:Insurance 322.39 AUD
2025-06-24 * "CBA" "Bank Fees" 2025-06-24 * "CBA" "Bank Fees"
; MONTHLY FEE original: "MONTHLY FEE"
Liabilities:CBA:CreditCard -8.0 AUD Liabilities:CBA:CreditCard -8.0 AUD
Expenses:Fees 8.0 AUD Expenses:Fees 8.0 AUD
2025-06-24 * "CBA" "Bank Fees" 2025-06-24 * "CBA" "Bank Fees"
; MONTHLY FEE original: "MONTHLY FEE"
Liabilities:CBA:CreditCard -8.0 AUD Liabilities:CBA:CreditCard -8.0 AUD
Expenses:Fees 8.0 AUD Expenses:Fees 8.0 AUD
2025-06-17 * "Ergon Energy" "Electricity Bill" 2025-06-17 * "Ergon Energy" "Electricity Bill"
; BPAYN ERGON ENERGY RETAILBPAY original: "BPAYN ERGON ENERGY RETAILBPAY"
Liabilities:CBA:CreditCard -515.43 AUD Liabilities:CBA:CreditCard -515.43 AUD
Expenses:House:Electricity 515.43 AUD Expenses:House:Electricity 515.43 AUD
2025-06-15 ! "CBA" "International Transaction Fee" 2025-06-15 ! "CBA" "International Transaction Fee"
; INTNL TRANSACTION FEE original: "INTNL TRANSACTION FEE"
Liabilities:CBA:CreditCard -1.19 AUD Liabilities:CBA:CreditCard -1.19 AUD
Expenses:CBA:Fees 1.19 AUD Expenses:CBA:Fees 1.19 AUD
2025-06-14 ! "CBA" "International Transaction Fee" 2025-06-14 ! "CBA" "International Transaction Fee"
; INTNL TRANSACTION FEE original: "INTNL TRANSACTION FEE"
Liabilities:CBA:CreditCard -8.64 AUD Liabilities:CBA:CreditCard -8.64 AUD
Expenses:CBA:Fees 8.64 AUD Expenses:CBA:Fees 8.64 AUD
2025-06-11 ! "CBA" "International Transaction Fee" 2025-06-11 ! "CBA" "International Transaction Fee"
; INTNL TRANSACTION FEE original: "INTNL TRANSACTION FEE"
Liabilities:CBA:CreditCard -0.86 AUD Liabilities:CBA:CreditCard -0.86 AUD
Expenses:CBA:Fees 0.86 AUD Expenses:CBA:Fees 0.86 AUD
2025-06-04 ! "" "Credit Card Payment" 2025-06-04 ! "" "Credit Card Payment"
; PAYMENT RECEIVED, THANK YOU original: "PAYMENT RECEIVED, THANK YOU"
Assets:OtherBankAccount -2897.86 AUD Assets:OtherBankAccount -2897.86 AUD
Liabilities:CBA:CreditCard 2897.86 AUD Liabilities:CBA:CreditCard 2897.86 AUD
+16 -16
View File
@@ -1,80 +1,80 @@
2018-04-01 * "Account Fees" "" 2018-04-01 * "Account Fees" ""
; Account Fee original: "Account Fee"
Liabilities:CBA:Streamline -6.0 AUD Liabilities:CBA:Streamline -6.0 AUD
Expenses:Fees 6.0 AUD Expenses:Fees 6.0 AUD
2018-04-04 * "Tenants" "" 2018-04-04 * "Tenants" ""
; Direct Credit 015895 original: "Direct Credit 015895"
Income:Tenants -399.01 AUD Income:Tenants -399.01 AUD
Liabilities:CBA:Streamline 399.01 AUD Liabilities:CBA:Streamline 399.01 AUD
2018-04-13 * "Ergon Energy" "" 2018-04-13 * "Ergon Energy" ""
; Direct Debit 066228 ERGON original: "Direct Debit 066228 ERGON"
Liabilities:CBA:Streamline -882.81 AUD Liabilities:CBA:Streamline -882.81 AUD
Expenses:Electricity 882.81 AUD Expenses:Electricity 882.81 AUD
2018-04-17 * "CommInsure" "" 2018-04-17 * "CommInsure" ""
; Direct Debit 062246 CommInsure original: "Direct Debit 062246 CommInsure"
Liabilities:CBA:Streamline -53.82 AUD Liabilities:CBA:Streamline -53.82 AUD
Expenses:Insurance 53.82 AUD Expenses:Insurance 53.82 AUD
2018-04-18 * "Tenants" "" 2018-04-18 * "Tenants" ""
; Direct Credit 015895 FAO FAMILIES original: "Direct Credit 015895 FAO FAMILIES"
Income:Tenants -399.01 AUD Income:Tenants -399.01 AUD
Liabilities:CBA:Streamline 399.01 AUD Liabilities:CBA:Streamline 399.01 AUD
2018-04-26 * "Transfer" "" 2018-04-26 * "Transfer" ""
; Transfer to xx3555 NetBank original: "Transfer to xx3555 NetBank"
Liabilities:CBA:Streamline -800.0 AUD Liabilities:CBA:Streamline -800.0 AUD
Assets:3555 800.0 AUD Assets:3555 800.0 AUD
2018-05-01 * "Account Fees" "" 2018-05-01 * "Account Fees" ""
; Account Fee original: "Account Fee"
Liabilities:CBA:Streamline -6.0 AUD Liabilities:CBA:Streamline -6.0 AUD
Expenses:Fees 6.0 AUD Expenses:Fees 6.0 AUD
2018-05-01 * "Colonial First State" "" 2018-05-01 * "Colonial First State" ""
; Direct Credit 011802 Col First State original: "Direct Credit 011802 Col First State"
Income:Trading -53.62 AUD Income:Trading -53.62 AUD
Liabilities:CBA:Streamline 53.62 AUD Liabilities:CBA:Streamline 53.62 AUD
2018-05-02 * "Tenants" "" 2018-05-02 * "Tenants" ""
; Direct Credit 015895 FAO FAMILIES original: "Direct Credit 015895 FAO FAMILIES"
Income:Tenants -399.01 AUD Income:Tenants -399.01 AUD
Liabilities:CBA:Streamline 399.01 AUD Liabilities:CBA:Streamline 399.01 AUD
2018-05-03 * "Transfer" "" 2018-05-03 * "Transfer" ""
; Transfer to xx3555 NetBank original: "Transfer to xx3555 NetBank"
Liabilities:CBA:Streamline -400.0 AUD Liabilities:CBA:Streamline -400.0 AUD
Assets:3555 400.0 AUD Assets:3555 400.0 AUD
2018-05-16 * "Tenants" "" 2018-05-16 * "Tenants" ""
; Direct Credit 015895 FAO FAMILIES original: "Direct Credit 015895 FAO FAMILIES"
Income:Tenants -399.01 AUD Income:Tenants -399.01 AUD
Liabilities:CBA:Streamline 399.01 AUD Liabilities:CBA:Streamline 399.01 AUD
2018-05-17 * "CommInsure" "" 2018-05-17 * "CommInsure" ""
; Direct Debit 062246 CommInsure original: "Direct Debit 062246 CommInsure"
Liabilities:CBA:Streamline -53.82 AUD Liabilities:CBA:Streamline -53.82 AUD
Expenses:Insurance 53.82 AUD Expenses:Insurance 53.82 AUD
2018-05-21 * "Transfer" "" 2018-05-21 * "Transfer" ""
; Transfer to xx3555 NetBank original: "Transfer to xx3555 NetBank"
Liabilities:CBA:Streamline -300.0 AUD Liabilities:CBA:Streamline -300.0 AUD
Assets:3555 300.0 AUD Assets:3555 300.0 AUD
2018-05-30 * "Tenants" "" 2018-05-30 * "Tenants" ""
; Direct Credit 015895 FAO FAMILIES original: "Direct Credit 015895 FAO FAMILIES"
Income:Tenants -399.01 AUD Income:Tenants -399.01 AUD
Liabilities:CBA:Streamline 399.01 AUD Liabilities:CBA:Streamline 399.01 AUD
2018-06-01 * "Account Fees" "" 2018-06-01 * "Account Fees" ""
; Account Fee original: "Account Fee"
Liabilities:CBA:Streamline -6.0 AUD Liabilities:CBA:Streamline -6.0 AUD
Expenses:Fees 6.0 AUD Expenses:Fees 6.0 AUD
2018-06-13 * "Tenants" "" 2018-06-13 * "Tenants" ""
; Direct Credit 015895 FAO FAMILIES original: "Direct Credit 015895 FAO FAMILIES"
Income:Tenants -399.01 AUD Income:Tenants -399.01 AUD
Liabilities:CBA:Streamline 399.01 AUD Liabilities:CBA:Streamline 399.01 AUD