Add CBA converter

This commit is contained in:
2025-12-02 19:45:28 +10:00
parent 058547c918
commit ff4da24123
9 changed files with 547 additions and 206 deletions

1
src/beanify/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .main import run

View File

@@ -17,7 +17,7 @@ Notes:
Or not.. we can just have placeholders filled in for the defaults. Or not.. we can just have placeholders filled in for the defaults.
""" """
_SKIPT = Enum('SKIPT', (('SKIP', 'SKIP'),)) _SKIPT = Enum("SKIPT", (("SKIP", "SKIP"),))
SKIP = _SKIPT.SKIP SKIP = _SKIPT.SKIP
@@ -25,6 +25,7 @@ class ConverterConfig:
""" """
Base class for converter configuration. Base class for converter configuration.
""" """
@classmethod @classmethod
def from_dict(cls, data: dict) -> Self: def from_dict(cls, data: dict) -> Self:
""" """
@@ -51,6 +52,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
- How input is parsed into Records - How input is parsed into Records
- How Records are converted to Transactions (in addition to the RuleSet). - How Records are converted to Transactions (in addition to the RuleSet).
""" """
converter_name: str converter_name: str
version: str version: str
display_name: str display_name: str
@@ -92,17 +94,15 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
""" """
raise NotImplementedError raise NotImplementedError
@classmethod def ingest_string(self, data: str) -> list[RecordT]:
def ingest_string(cls, data: str) -> list[RecordT]:
""" """
Parse a string of raw input into a list of records. Parse a string of raw input into a list of records.
""" """
raise NotImplementedError raise NotImplementedError
@classmethod def ingest_file(self, path) -> list[RecordT]:
def ingest_file(cls, path) -> list[RecordT]:
""" """
Ingest a target file (from path) into Records. Ingest a target file (from path) into Records.
""" """
with open(path, 'r') as f: with open(path, "r") as f:
return cls.ingest_string(f.read()) return self.ingest_string(f.read())

View File

@@ -26,6 +26,7 @@ class Record:
The Record fields will be provided and checked for the conditional fields of Rules. The Record fields will be provided and checked for the conditional fields of Rules.
""" """
date: dt.date date: dt.date
source_account: str source_account: str
target_account: str target_account: str
@@ -33,9 +34,9 @@ class Record:
from_source: Amount from_source: Amount
to_target: Amount to_target: Amount
fees: tuple[Amount] = field(default_factory=tuple) fees: tuple[Amount, ...] = field(default_factory=tuple)
raw: str | None = None raw: str | None = None
comments: tuple[str] = field(default_factory=tuple) comments: tuple[str, ...] = field(default_factory=tuple)
# List of record fields to display in UI # List of record fields to display in UI
# List of [field name, display name] # List of [field name, display name]
@@ -60,7 +61,7 @@ class Record:
fields = [] fields = []
for name, display in self._display_fields: for name, display in self._display_fields:
value = getattr(self, name) value = getattr(self, name)
value = str(value) if value is not None else '' value = str(value) if value is not None else ""
matchable = name in self._match_fields matchable = name in self._match_fields
fields.append(RecordField(name, display, value, matchable)) fields.append(RecordField(name, display, value, matchable))
@@ -78,7 +79,7 @@ class Record:
for field_name in self._match_fields: for field_name in self._match_fields:
value = getattr(self, field_name) value = getattr(self, field_name)
# Replace None values by empty strings # Replace None values by empty strings
value = str(value) if value is not None else '' value = str(value) if value is not None else ""
field_values[field_name] = value field_values[field_name] = value
return field_values return field_values

View File

@@ -6,21 +6,23 @@ from dataclasses import dataclass
class TXNFlag(Enum): class TXNFlag(Enum):
COMPLETE = '*' COMPLETE = "*"
INCOMPLETE = '!' INCOMPLETE = "!"
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class Amount: class Amount:
""" """
Represents a beancount 'amount' with given currency. Represents a beancount 'amount' with given currency.
""" """
value: float value: float
currency: str currency: str
def __str__(self): def __str__(self):
return f"{self.value} {self.currency}" return f"{self.value} {self.currency}"
def at_price(self, price: 'Amount') -> 'Amount': def at_price(self, price: "Amount") -> "Amount":
return Amount(self.value * price.value, price.currency) return Amount(self.value * price.value, price.currency)
def __add__(self, other): def __add__(self, other):
@@ -39,6 +41,9 @@ class Amount:
else: else:
return NotImplemented return NotImplemented
def __eq__(self, other):
return (self.value == other.value) and (self.currency == other.currency)
def __rsub__(self, other): def __rsub__(self, other):
return self - other return self - other
@@ -48,11 +53,16 @@ class Amount:
def __neg__(self): def __neg__(self):
return Amount(-self.value, self.currency) return Amount(-self.value, self.currency)
def __abs__(self):
return Amount(abs(self.value), self.currency)
@dataclass(slots=True, kw_only=True) @dataclass(slots=True, kw_only=True)
class ABCPosting: class ABCPosting:
""" """
Represents the data of a TXNPosting. Represents the data of a TXNPosting.
""" """
amount: Amount amount: Amount
cost: Optional[Amount] = None cost: Optional[Amount] = None
total_cost: Optional[Amount] = None total_cost: Optional[Amount] = None
@@ -92,7 +102,9 @@ class TXNPosting(ABCPosting):
Note: Remember that Cost, Price, and Total Price are unsigned. Note: Remember that Cost, Price, and Total Price are unsigned.
""" """
account: str account: str
def __str__(self): def __str__(self):
parts = [] parts = []
if self.flag: if self.flag:
@@ -109,7 +121,7 @@ class TXNPosting(ABCPosting):
if self.comment: if self.comment:
parts.append(f" ; {self.comment}") parts.append(f" ; {self.comment}")
return ' '.join(parts) return " ".join(parts)
class Transaction: class Transaction:
@@ -119,15 +131,15 @@ class Transaction:
def __init__(self, date, **kwargs): def __init__(self, date, **kwargs):
self.date: dt.date = date self.date: dt.date = date
self.flag: TXNFlag = kwargs.get('flag', TXNFlag.COMPLETE) self.flag: TXNFlag = kwargs.get("flag", TXNFlag.COMPLETE)
self.payee: str = kwargs.get('payee', "") self.payee: str = kwargs.get("payee", "")
self.narration: str = kwargs.get('narration', "") self.narration: str = kwargs.get("narration", "")
self.comments: list[str] = kwargs.get('comments', []) self.comments: list[str] = kwargs.get("comments", [])
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.postings: list[TXNPosting] = kwargs.get('postings', []) self.postings: list[TXNPosting] = kwargs.get("postings", [])
def check(self) -> bool: def check(self) -> bool:
""" """
@@ -142,10 +154,10 @@ class Transaction:
header = "{date} {flag} {payee} {narration} {tags} {links}".format( header = "{date} {flag} {payee} {narration} {tags} {links}".format(
date=self.date, date=self.date,
flag=self.flag.value, flag=self.flag.value,
payee=f"\"{self.payee or ''}\"", payee=f'"{self.payee or ""}"',
narration=f"\"{self.narration or ''}\"", narration=f'"{self.narration or ""}"',
tags=' '.join('#' + tag for tag in self.tags), tags=" ".join("#" + tag for tag in self.tags),
links=' '.join('^' + link for link in self.links), links=" ".join("^" + link for link in self.links),
).strip() ).strip()
lines = [] lines = []
@@ -158,7 +170,4 @@ class Transaction:
for posting in self.postings: for posting in self.postings:
lines.append(str(posting)) lines.append(str(posting))
return '\n'.join((header, *(' ' + line for line in lines))) return "\n".join((header, *(" " + line for line in lines)))

View File

@@ -4,13 +4,20 @@ from base.converter import Converter
converters_available: list[Type[Converter]] = [] converters_available: list[Type[Converter]] = []
def converter_factory(name: str | None = None, qual_name: str | None = None) -> Type[Converter]:
def converter_factory(
name: str | None = None, qual_name: str | None = None
) -> Type[Converter]:
if name and not qual_name: if name and not qual_name:
converter = next((c for c in converters_available if c.converter_name == name), None) converter = next(
(c for c in converters_available if c.converter_name == name), None
)
if converter is None: if converter is None:
raise ValueError(f"No converter matching {name=}") raise ValueError(f"No converter matching {name=}")
elif qual_name and not name: elif qual_name and not name:
converter = next((c for c in converters_available if c.qual_name() == qual_name), None) converter = next(
(c for c in converters_available if c.qual_name() == qual_name), None
)
if converter is None: if converter is None:
raise ValueError(f"No converter matching {qual_name=}") raise ValueError(f"No converter matching {qual_name=}")
else: else:
@@ -18,8 +25,11 @@ def converter_factory(name: str | None = None, qual_name: str | None = None) ->
return converter return converter
def available_converter(converter_cls): def available_converter(converter_cls):
converters_available.append(converter_cls) converters_available.append(converter_cls)
return converter_cls return converter_cls
from .wise_converter import * from .wise_converter import *
from .cba_converter import *

View File

@@ -0,0 +1,198 @@
import csv
from dataclasses import dataclass
import datetime as dt
from datetime import datetime
import logging
from typing import NamedTuple
from enum import Enum
from base import Converter, PartialTXN, PartialPosting, Record, Amount
from base.converter import ConverterConfig
from base.rules import Rule, RuleSet
from base.transaction import TXNFlag
from . import available_converter
__all__ = [
"CBARecord",
"CBAConfig",
"CBAConverter",
]
logger = logging.getLogger(__name__)
class CBACSVRow(NamedTuple):
date: str
amount: str
description: str
unknown: str
currency_table = {"US DOLLAR": "USD"}
class RecordDirection(Enum):
OUT = "OUT"
IN = "IN"
@dataclass(kw_only=True, frozen=True)
class CBARecord(Record):
description: str
direction: RecordDirection
_display_fields = [
("date", "Date"),
("source_account", "Source Account"),
("target_account", "Target Account"),
("from_source", "From Source"),
("to_target", "To Target"),
("description", "Description"),
]
_match_fields = ["source_account", "target_account", "description"]
@classmethod
def sample_record(cls):
self = cls(
date=dt.date.today(),
source_account="John Doe",
target_account="Jane Austen",
from_source=Amount(314, "USD"),
to_target=Amount(314, "USD"),
fees=(),
raw="Raw Data",
description="Raw Description",
direction=RecordDirection.OUT,
)
return self
@dataclass
class CBAConfig(ConverterConfig):
asset_account: str
asset_currency: str
required = {
"asset_account",
"asset_currency",
}
@classmethod
def from_dict(cls, data: dict) -> "CBAConfig":
if (f := next((f for f in cls.required if f not in data), None)) is not None:
raise ValueError(
f"CBA CSV Converter Configuration missing required field: {f}"
)
return cls(**data)
@available_converter
class CBAConverter(Converter[CBARecord, CBAConfig]):
record_type = CBARecord
config_type = CBAConfig
converter_name = "cbacsv"
version = "0"
display_name = "CBACSV converter v0"
config_field = "CBACSV"
def convert(self, record: CBARecord, ruleset: RuleSet) -> PartialTXN:
fields = {}
match record.direction:
case RecordDirection.OUT:
fields["source_account"] = self.config.asset_account
case RecordDirection.IN:
fields["target_account"] = self.config.asset_account
fields |= ruleset.apply(record.match_fields())
args = {}
args["date"] = record.date
if "flag" in fields:
args["flag"] = TXNFlag(fields["flag"])
for name in {"payee", "narration", "comment", "document", "tags", "links"}:
if name in fields:
args[name] = fields[name]
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,
)
args.setdefault("comment", record.description)
txn = PartialTXN(**args)
logger.debug(f"Converted CBA CSV Record {record!r} to PartialTXN {txn!r}")
return txn
def _make_record(self, row: CBACSVRow):
dt_format = "%d/%m/%Y"
created_on = datetime.strptime(row.date, dt_format).date()
asset_amount_val = float(row.amount.strip('"'))
expense = True if asset_amount_val < 0 else False
asset_amount = Amount(asset_amount_val, self.config.asset_currency)
other_value = asset_amount_val
bean_curr = self.config.asset_currency
desc = row.description.strip('"')
# Attempt to handle currency conversions
currency = next((curr for curr in currency_table if desc.endswith(curr)), None)
if currency is not None:
bean_curr = currency_table[currency]
desc, rawamount = desc[: -len(currency)].rsplit(maxsplit=1)
try:
other_value = float(rawamount)
except ValueError:
currency = None
other_value = abs(other_value) * (1 if expense else -1)
other_amount = Amount(abs(other_value) * (1 if expense else -1), bean_curr)
if expense:
record = CBARecord(
date=created_on,
source_account="ACCOUNT",
target_account=desc.split(" ")[0].strip(),
from_source=asset_amount,
to_target=other_amount,
raw=",".join(row),
description=desc,
direction=RecordDirection.OUT,
)
else:
record = CBARecord(
date=created_on,
target_account="ACCOUNT",
source_account=desc.split(" ")[0].strip(),
to_target=asset_amount,
from_source=other_amount,
raw=",".join(row),
description=desc,
direction=RecordDirection.IN,
)
return record
def ingest_string(self, data: str) -> list[CBARecord]:
reader = csv.reader(data.splitlines())
records = []
for row in reader:
record = self._make_record(CBACSVRow(*row))
records.append(record)
return records
def ingest_file(self, path) -> list[CBARecord]:
with open(path) as f:
return self.ingest_string(f.read())

View File

@@ -0,0 +1,89 @@
import csv
from dataclasses import dataclass
import datetime as dt
from datetime import datetime
import logging
from base import Converter, PartialTXN, PartialPosting, Record, Amount
from base.converter import ConverterConfig
from base.rules import Rule, RuleSet
from base.transaction import TXNFlag
from . import available_converter
__all__ = [
"SampleRecord",
"SampleConfig",
"SampleCOnverter",
]
logger = logging.getLogger(__name__)
@dataclass(kw_only=True, frozen=True)
class SampleRecord(Record):
# Add any extra record fields needed here
_display_fields = [
("date", "Date"),
("source_account", "Source Account"),
("target_account", "Target Account"),
("from_source", "From Source"),
("to_target", "To Target"),
("raw", "Raw Original"),
]
_match_fields = ["source_account", "target_account"]
@classmethod
def sample_record(cls):
self = cls(
date=dt.date.today(),
source_account="John Doe",
target_account="Jane Austen",
from_source=Amount(314, "USD"),
to_target=Amount(314, "USD"),
fees=(),
raw="Raw Data",
)
return self
@dataclass
class SampleConfig(ConverterConfig):
asset_account: str
required = {
"asset_account",
}
@classmethod
def from_dict(cls, data: dict) -> "SampleConfig":
if (f := next((f for f in cls.required if f not in data), None)) is not None:
raise ValueError(f"Wise Configuration missing required field: {f}")
return cls(**data)
@available_converter
class SampleConverter(Converter[SampleRecord, SampleConfig]):
record_type = SampleRecord
config_type = SampleConfig
converter_name = "sample"
version = "0"
display_name = "Sample converter v0"
config_field = "SAMPLE"
def __init__(self, config: SampleConfig, **kwargs):
self.config = config
super().__init__(**kwargs)
def convert(self, record: SampleRecord, ruleset: RuleSet) -> PartialTXN:
raise NotImplementedError
@classmethod
def ingest_string(cls, data: str) -> list[SampleRecord]:
raise NotImplementedError
@classmethod
def ingest_file(cls, path) -> list[SampleRecord]:
raise NotImplementedError

View File

@@ -13,11 +13,11 @@ from base.transaction import TXNFlag
from . import available_converter from . import available_converter
__all__ = [ __all__ = [
'WiseRecordStatus', "WiseRecordStatus",
'WiseRecordDirection', "WiseRecordDirection",
'WiseRecord', "WiseRecord",
'WiseConfig', "WiseConfig",
'WiseConverter', "WiseConverter",
] ]
@@ -25,13 +25,14 @@ logger = logging.getLogger(__name__)
class WiseRecordStatus(Enum): class WiseRecordStatus(Enum):
COMPLETED = 'COMPLETED' COMPLETED = "COMPLETED"
CANCELLED = 'CANCELLED' CANCELLED = "CANCELLED"
class WiseRecordDirection(Enum): class WiseRecordDirection(Enum):
OUT = 'OUT' OUT = "OUT"
NEUTRAL = 'NEUTRAL' NEUTRAL = "NEUTRAL"
IN = 'IN' IN = "IN"
@dataclass(kw_only=True, frozen=True) @dataclass(kw_only=True, frozen=True)
@@ -46,26 +47,30 @@ class WiseRecord(Record):
exchange_rate: float exchange_rate: float
_display_fields = [ _display_fields = [
('id', "ID"), ("id", "ID"),
('status', "Status"), ("status", "Status"),
('direction', "Direction"), ("direction", "Direction"),
('created_on', "Created"), ("created_on", "Created"),
('finished_on', "Finished"), ("finished_on", "Finished"),
('from_source_net', "Source Net Amount"), ("from_source_net", "Source Net Amount"),
('source_fee', "Source Fee"), ("source_fee", "Source Fee"),
('exchange_rate', "Exchange Rate"), ("exchange_rate", "Exchange Rate"),
('exchanged_amount', "Total Exchanged"), ("exchanged_amount", "Total Exchanged"),
('target_fee', "Target Fee"), ("target_fee", "Target Fee"),
('source_currency', "Source Currency"), ("source_currency", "Source Currency"),
('target_currency', "Target Currency"), ("target_currency", "Target Currency"),
('source_account', "Source Account"), ("source_account", "Source Account"),
('target_account', "Target Account"), ("target_account", "Target Account"),
] ]
_match_fields = [ _match_fields = [
'id', 'status', 'direction', "id",
'source_currency', 'target_currency', "status",
'source_account', 'target_account', "direction",
"source_currency",
"target_currency",
"source_account",
"target_account",
] ]
@property @property
@@ -99,14 +104,14 @@ class WiseRecord(Record):
amount = amount + self.target_fee amount = amount + self.target_fee
return amount return amount
@classmethod @classmethod
def sample_record(cls): def sample_record(cls):
self = cls( self = cls(
date=dt.date.today(), date=dt.date.today(),
source_account="John Doe", source_account="John Doe",
target_account="Jane Austen", target_account="Jane Austen",
from_source=Amount(314, 'GBK'), from_source=Amount(314, "GBK"),
to_target=Amount(314, 'KBG'), to_target=Amount(314, "KBG"),
fees=tuple(), fees=tuple(),
raw="Raw Data", raw="Raw Data",
id="00000", id="00000",
@@ -114,72 +119,72 @@ class WiseRecord(Record):
direction=WiseRecordDirection.IN, direction=WiseRecordDirection.IN,
created_on=datetime.now(), created_on=datetime.now(),
finished_on=datetime.now(), finished_on=datetime.now(),
source_fee=Amount(1, 'GBK'), source_fee=Amount(1, "GBK"),
target_fee=Amount(1, 'KBG'), target_fee=Amount(1, "KBG"),
exchange_rate=1, exchange_rate=1,
) )
return self return self
@classmethod @classmethod
def from_row(cls, row): def from_row(cls, row):
id = row[0] id = row[0]
status = row[1] status = row[1]
direction = row[2] direction = row[2]
created_on = row[3] created_on = row[3]
finished_on = row[3] finished_on = row[3]
source_fee_amount = float(row[5] or 0) source_fee_amount = float(row[5] or 0)
source_fee_currency = row[6] source_fee_currency = row[6]
target_fee_amount = float(row[7] or 0) target_fee_amount = float(row[7] or 0)
target_fee_currency = row[8] target_fee_currency = row[8]
source_name = row[9] source_name = row[9]
source_amount_final = float(row[10] or 0) source_amount_final = float(row[10] or 0)
source_currency = row[11] source_currency = row[11]
target_name = row[12] target_name = row[12]
target_amount_final = float(row[13] or 0) target_amount_final = float(row[13] or 0)
target_currency = row[14] target_currency = row[14]
exchange_rate = float(row[15] or 0) exchange_rate = float(row[15] or 0)
wise_dt_format = '%Y-%m-%d %H:%M:%S'
created_on_dt = datetime.strptime(created_on, wise_dt_format) wise_dt_format = "%Y-%m-%d %H:%M:%S"
finished_on_dt = datetime.strptime(finished_on, wise_dt_format) if finished_on else None
fees = []
if source_fee_amount: created_on_dt = datetime.strptime(created_on, wise_dt_format)
source_fee = Amount(source_fee_amount, source_fee_currency) finished_on_dt = (
fees.append(source_fee) datetime.strptime(finished_on, wise_dt_format) if finished_on else None
else: )
source_fee = None
if target_fee_amount:
target_fee = Amount(target_fee_amount, target_fee_currency)
fees.append(target_fee)
else:
target_fee = None
raw = ','.join(row) fees = []
self = cls( if source_fee_amount:
date=created_on_dt.date(), source_fee = Amount(source_fee_amount, source_fee_currency)
source_account=source_name, fees.append(source_fee)
target_account=target_name, else:
from_source=Amount(source_amount_final, source_currency), source_fee = None
to_target=Amount(target_amount_final, target_currency), if target_fee_amount:
fees=tuple(fees), target_fee = Amount(target_fee_amount, target_fee_currency)
raw=raw, fees.append(target_fee)
id=id, else:
status=WiseRecordStatus(status), target_fee = None
direction=WiseRecordDirection(direction),
created_on=created_on_dt, raw = ",".join(row)
finished_on=finished_on_dt,
source_fee=source_fee, self = cls(
target_fee=target_fee, date=created_on_dt.date(),
exchange_rate=float(exchange_rate), source_account=source_name,
) target_account=target_name,
logger.debug( from_source=Amount(source_amount_final, source_currency),
f"Converted Wise row {raw} to record {self!r}" to_target=Amount(target_amount_final, target_currency),
) fees=tuple(fees),
return self raw=raw,
id=id,
status=WiseRecordStatus(status),
direction=WiseRecordDirection(direction),
created_on=created_on_dt,
finished_on=finished_on_dt,
source_fee=source_fee,
target_fee=target_fee,
exchange_rate=float(exchange_rate),
)
logger.debug(f"Converted Wise row {raw} to record {self!r}")
return self
@dataclass @dataclass
@@ -191,34 +196,35 @@ class WiseConfig(ConverterConfig):
TODO: Actual field defaults for fill-in TODO: Actual field defaults for fill-in
""" """
asset_account: str asset_account: str
fee_account: str fee_account: str
required = { required = {
'asset_account', "asset_account",
'fee_account', "fee_account",
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> 'WiseConfig': def from_dict(cls, data: dict) -> "WiseConfig":
if (f := next((f for f in cls.required if f not in data), None)) is not None: if (f := next((f for f in cls.required if f not in data), None)) is not None:
raise ValueError(f"Wise Configuration missing required field: {f}") raise ValueError(f"Wise Configuration missing required field: {f}")
return cls(**data) return cls(**data)
@available_converter @available_converter
class WiseConverter(Converter[WiseRecord, WiseConfig]): class WiseConverter(Converter[WiseRecord, WiseConfig]):
record_type = WiseRecord record_type = WiseRecord
config_type = WiseConfig config_type = WiseConfig
converter_name = 'wise' converter_name = "wise"
version = '0' version = "0"
display_name = "Wise Record Converter v0" display_name = "Wise Record Converter v0"
config_field = 'WISE' config_field = "WISE"
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: PartialTXN): ...
...
def convert(self, record: WiseRecord, ruleset: RuleSet) -> PartialTXN: def convert(self, record: WiseRecord, ruleset: RuleSet) -> PartialTXN:
fields = {} fields = {}
@@ -226,64 +232,77 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
match record.direction: match record.direction:
# Handle configured default accounts # Handle configured default accounts
case WiseRecordDirection.OUT: case WiseRecordDirection.OUT:
fields['source_account'] = self.config.asset_account.format(currency=record.source_currency) fields["source_account"] = self.config.asset_account.format(
currency=record.source_currency
)
case WiseRecordDirection.NEUTRAL: case WiseRecordDirection.NEUTRAL:
fields['source_account'] = self.config.asset_account.format(currency=record.source_currency) fields["source_account"] = self.config.asset_account.format(
fields['target_account'] = self.config.asset_account.format(currency=record.target_currency) currency=record.source_currency
)
fields["target_account"] = self.config.asset_account.format(
currency=record.target_currency
)
case WiseRecordDirection.IN: case WiseRecordDirection.IN:
fields['target_account'] = self.config.asset_account.format(currency=record.target_currency) fields["target_account"] = self.config.asset_account.format(
currency=record.target_currency
)
fields |= ruleset.apply(record.match_fields()) fields |= ruleset.apply(record.match_fields())
args = {} args = {}
args['date'] = record.date args["date"] = record.date
# Convert string flag if it exists # Convert string flag if it exists
if 'flag' in fields: if "flag" in fields:
args['flag'] = TXNFlag(fields['flag']) args["flag"] = TXNFlag(fields["flag"])
# Copy string fields over directly # Copy string fields over directly
for name in {'payee', 'narration', 'comment', 'document', 'tags', 'links'}: for name in {"payee", "narration", "comment", "document", "tags", "links"}:
if name in fields: if name in fields:
args[name] = fields[name] args[name] = fields[name]
args['source_posting'] = PartialPosting( args["source_posting"] = PartialPosting(
account=fields.get('source_account', None), account=fields.get("source_account", None),
amount=-record.from_source_net, amount=-record.from_source_net,
total_cost=record.exchanged_amount if record.source_currency != record.target_currency else None, total_cost=record.exchanged_amount
if record.source_currency != record.target_currency
else None,
) )
args['target_posting'] = PartialPosting( args["target_posting"] = PartialPosting(
account=fields.get('target_account', None), account=fields.get("target_account", None),
amount=record.to_target, amount=record.to_target,
) )
if record.source_fee: if record.source_fee:
args['source_fee_asset_posting'] = PartialPosting( args["source_fee_asset_posting"] = PartialPosting(
account=fields.get('source_fee_asset_account', fields.get('source_account', None)), account=fields.get(
amount=-record.source_fee "source_fee_asset_account", fields.get("source_account", None)
),
amount=-record.source_fee,
) )
args['source_fee_expense_posting'] = PartialPosting( args["source_fee_expense_posting"] = PartialPosting(
account=fields.get('source_fee_expense_account', self.config.fee_account), account=fields.get(
amount=record.source_fee "source_fee_expense_account", self.config.fee_account
),
amount=record.source_fee,
) )
if record.target_fee: if record.target_fee:
args['target_fee_expense_posting'] = PartialPosting( args["target_fee_expense_posting"] = PartialPosting(
account=fields.get('target_fee_expense_account', self.config.fee_account), account=fields.get(
amount=record.target_fee "target_fee_expense_account", self.config.fee_account
),
amount=record.target_fee,
) )
txn = PartialTXN(**args) txn = PartialTXN(**args)
logger.debug( logger.debug(f"Converted Wise Record {record!r} to Partial Transaction {txn!r}")
f"Converted Wise Record {record!r} to Partial Transaction {txn!r}"
)
return txn return txn
@classmethod def ingest_string(self, data: str) -> list[WiseRecord]:
def ingest_string(cls, data: str) -> list[WiseRecord]:
""" """
Parse a string of raw input into a list of records. Parse a string of raw input into a list of records.
""" """
@@ -296,12 +315,10 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
logging.info(f"Skipping record with non-complete status: {record}") logging.info(f"Skipping record with non-complete status: {record}")
else: else:
records.append(record) records.append(record)
return records return records
@classmethod def ingest_file(self, path) -> list[WiseRecord]:
def ingest_file(cls, path) -> list[WiseRecord]:
with open(path) as f: with open(path) as f:
f.readline() f.readline()
return cls.ingest_string(f.read()) return self.ingest_string(f.read())

View File

@@ -16,7 +16,14 @@ from .rowtree import RowTree
class MainWindow(ThemedTk): class MainWindow(ThemedTk):
def __init__(self, beanconfig, converter: Converter, ruleset: RuleSet, initial_files=[], **kwargs): def __init__(
self,
beanconfig,
converter: Converter,
ruleset: RuleSet,
initial_files=[],
**kwargs,
):
super().__init__(**kwargs) super().__init__(**kwargs)
self.beanconfig = beanconfig self.beanconfig = beanconfig
@@ -40,22 +47,23 @@ class MainWindow(ThemedTk):
self.initial_ingest() self.initial_ingest()
def load_styles(self): def load_styles(self):
self.tk.eval(""" # self.tk.eval("""
set base_theme_dir ../themes/awthemes-10.4.0/ # set base_theme_dir ../themes/awthemes-10.4.0/
package ifneeded awthemes 10.4.0 \ # package ifneeded awthemes 10.4.0 \
[list source [file join $base_theme_dir awthemes.tcl]] # [list source [file join $base_theme_dir awthemes.tcl]]
package ifneeded colorutils 4.8 \ # package ifneeded colorutils 4.8 \
[list source [file join $base_theme_dir colorutils.tcl]] # [list source [file join $base_theme_dir colorutils.tcl]]
package ifneeded awdark 7.12 \ # package ifneeded awdark 7.12 \
[list source [file join $base_theme_dir awdark.tcl]] # [list source [file join $base_theme_dir awdark.tcl]]
""") # """)
self.tk.call("package", "require", "awdark") # self.tk.call("package", "require", "awdark")
# style = ttk.Style(self)
# style.theme_use('awdark')
style = ttk.Style(self) style = ttk.Style(self)
style.theme_use('awdark')
def setup_menu(self): def setup_menu(self):
self.menubar = tk.Menu(self) self.menubar = tk.Menu(self)
self['menu'] = self.menubar self["menu"] = self.menubar
menu_file = tk.Menu(self.menubar, tearoff=0) menu_file = tk.Menu(self.menubar, tearoff=0)
menu_file.add_command(label="Ingest File", command=self.do_ingest_file) menu_file.add_command(label="Ingest File", command=self.do_ingest_file)
@@ -69,7 +77,9 @@ class MainWindow(ThemedTk):
menu_edit = tk.Menu(self.menubar, tearoff=0) menu_edit = tk.Menu(self.menubar, tearoff=0)
menu_edit.add_command(label="Edit Rules", command=self.do_edit_rules) menu_edit.add_command(label="Edit Rules", command=self.do_edit_rules)
menu_edit.add_command(label="Edit Preferences", command=self.do_edit_preferences) menu_edit.add_command(
label="Edit Preferences", command=self.do_edit_preferences
)
self.menubar.add_cascade(menu=menu_edit, label="Edit") self.menubar.add_cascade(menu=menu_edit, label="Edit")
@@ -81,32 +91,40 @@ class MainWindow(ThemedTk):
# sashthickness=2 # sashthickness=2
# ) # )
self.contentframe = ttk.Frame(self, padding=(3, 3, 6, 6), border=1, relief="ridge") self.contentframe = ttk.Frame(
self, padding=(3, 3, 6, 6), border=1, relief="ridge"
)
self.content = ttk.PanedWindow( self.content = ttk.PanedWindow(
self.contentframe, self.contentframe,
orient='horizontal', orient="horizontal",
# style='Custom.TPanedwindow', # style='Custom.TPanedwindow',
) )
self.rowtree = RowTree(self, base_record=self.sample_record, padding=(3, 3, 12, 12)) self.rowtree = RowTree(
self, base_record=self.sample_record, padding=(3, 3, 12, 12)
)
self.content.add(self.rowtree, weight=1) self.content.add(self.rowtree, weight=1)
self.editor = RowEditor(self, acmpl_cache=self.account_cache, padding=(3, 3, 12, 12)) self.editor = RowEditor(
self, acmpl_cache=self.account_cache, padding=(3, 3, 12, 12)
)
self.content.add(self.editor, weight=1) self.content.add(self.editor, weight=1)
self.statusbar = tk.Frame(self, relief=tk.SUNKEN) self.statusbar = tk.Frame(self, relief=tk.SUNKEN)
self.status_var_left = StringVar() self.status_var_left = StringVar()
self.status_var_left.set("Loading...") self.status_var_left.set("Loading...")
self.status_label_left = tk.Label(self.statusbar, textvariable=self.status_var_left) self.status_label_left = tk.Label(
self.statusbar, textvariable=self.status_var_left
)
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1) self.rowconfigure(0, weight=1)
self.contentframe.grid(column=0, row=0, sticky='NSEW') self.contentframe.grid(column=0, row=0, sticky="NSEW")
self.content.grid(column=0, row=0, sticky='NSEW') self.content.grid(column=0, row=0, sticky="NSEW")
self.statusbar.grid(row=1, column=0, sticky='ESW') self.statusbar.grid(row=1, column=0, sticky="ESW")
self.status_label_left.grid(row=0, column=0, sticky='E') self.status_label_left.grid(row=0, column=0, sticky="E")
self.rowconfigure(0, weight=1) self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
@@ -120,11 +138,11 @@ class MainWindow(ThemedTk):
# Better to have message passing up the chain? # Better to have message passing up the chain?
# i.e. the frame passes up the select # i.e. the frame passes up the select
self.rowtree.tree.bind('<<TreeviewSelect>>', self.row_selected) self.rowtree.tree.bind("<<TreeviewSelect>>", self.row_selected)
self.editor.bind('<<RowUpdated>>', self.row_updated) self.editor.bind("<<RowUpdated>>", self.row_updated)
self.editor.bind('<<RuleCreated>>', self.rule_created) self.editor.bind("<<RuleCreated>>", self.rule_created)
def update_status(self, message=''): def update_status(self, message=""):
self.status_var_left.set(message) self.status_var_left.set(message)
# TODO Add number of incomplete txns? # TODO Add number of incomplete txns?
# Add record count? # Add record count?
@@ -164,7 +182,7 @@ class MainWindow(ThemedTk):
txn = self.converter.convert(record, self.ruleset) txn = self.converter.convert(record, self.ruleset)
self.rows[record] = txn self.rows[record] = txn
# Tell the table to regenerate # Tell the table to regenerate
self.rowtree.update_rows(self.rows) self.rowtree.update_rows(self.rows)
self.rebuild_account_cache() self.rebuild_account_cache()
@@ -182,10 +200,10 @@ class MainWindow(ThemedTk):
self.rebuild_account_cache() self.rebuild_account_cache()
def do_ingest_file(self): def do_ingest_file(self):
# Prompt for file to ingest # Prompt for file to ingest
files = filedialog.askopenfilenames( files = filedialog.askopenfilenames(
defaultextension=".csv", defaultextension=".csv",
filetypes=[("CSV Files", ".csv"), ("All Files", "*.*")] filetypes=[("CSV Files", ".csv"), ("All Files", "*.*")],
) )
rows = {} rows = {}
for file in files: for file in files:
@@ -203,8 +221,8 @@ class MainWindow(ThemedTk):
# TODO: Feedback and confirmation # TODO: Feedback and confirmation
def do_export_txn(self): def do_export_txn(self):
# TODO: Export options # TODO: Export options
# TODO: Replace fields with defaults # TODO: Replace fields with defaults
upgraded = [] upgraded = []
for partialtxn in self.rows.values(): for partialtxn in self.rows.values():
if partialtxn.partial: if partialtxn.partial:
@@ -220,13 +238,13 @@ class MainWindow(ThemedTk):
filetypes=[ filetypes=[
("Beancount Ledger", ".ledger"), ("Beancount Ledger", ".ledger"),
("All Files", "*.*"), ("All Files", "*.*"),
] ],
) )
if path: if path:
with open(path, 'w') as f: with open(path, "w") as f:
for txn in upgraded: for txn in upgraded:
f.write(str(txn)) f.write(str(txn))
f.write('\n\n') f.write("\n\n")
message = f"Exported {len(upgraded)} transactions to {path}" message = f"Exported {len(upgraded)} transactions to {path}"
else: else:
message = "Export cancelled, no transactions exported" message = "Export cancelled, no transactions exported"
@@ -256,7 +274,9 @@ class MainWindow(ThemedTk):
as Record -> PartialTXN associations. as Record -> PartialTXN associations.
""" """
records = self.converter.ingest_file(path) records = self.converter.ingest_file(path)
rows = {record: self.converter.convert(record, self.ruleset) for record in records} rows = {
record: self.converter.convert(record, self.ruleset) for record in records
}
return rows return rows
def rebuild_account_cache(self): def rebuild_account_cache(self):
@@ -265,9 +285,9 @@ class MainWindow(ThemedTk):
i.e. the map of tx.field -> list[options] i.e. the map of tx.field -> list[options]
used for acmpl on entry. used for acmpl on entry.
""" """
# Get all the account field names # Get all the account field names
# Grab the value of each of these for all the rows we have # Grab the value of each of these for all the rows we have
# Grab the value of each of these for all the rules in the ruleset # Grab the value of each of these for all the rules in the ruleset
# 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
@@ -290,8 +310,4 @@ class MainWindow(ThemedTk):
self.account_cache.clear() self.account_cache.clear()
self.account_cache |= cache self.account_cache |= cache
def do_reapply_rules(self): ...
def do_reapply_rules(self):
...