Add CBA converter
This commit is contained in:
1
src/beanify/__init__.py
Normal file
1
src/beanify/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .main import run
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
89
src/beanify/converters/sample.py
Normal file
89
src/beanify/converters/sample.py
Normal 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
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user