Add cbastatement converter
This commit is contained in:
@@ -33,3 +33,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 *
|
||||||
|
|||||||
205
src/beanify/converters/cbastatement_converter.py
Normal file
205
src/beanify/converters/cbastatement_converter.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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__ = [
|
||||||
|
"CBASTRecord",
|
||||||
|
"CBASTConfig",
|
||||||
|
"CBASTConverter",
|
||||||
|
]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CBASTRow(NamedTuple):
|
||||||
|
date: str
|
||||||
|
description: str
|
||||||
|
debit: str
|
||||||
|
credit: str
|
||||||
|
balance: str
|
||||||
|
|
||||||
|
|
||||||
|
class RecordDirection(Enum):
|
||||||
|
OUT = "OUT"
|
||||||
|
IN = "IN"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True, frozen=True, eq=False)
|
||||||
|
class CBASTRecord(Record):
|
||||||
|
description: str
|
||||||
|
direction: RecordDirection
|
||||||
|
balance: Amount
|
||||||
|
|
||||||
|
_display_fields = [
|
||||||
|
("date", "Date"),
|
||||||
|
("source_account", "Source Account"),
|
||||||
|
("target_account", "Target Account"),
|
||||||
|
("from_source", "From Source"),
|
||||||
|
("to_target", "To Target"),
|
||||||
|
("balance", "Account Balance"),
|
||||||
|
("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"),
|
||||||
|
balance=Amount(2, "USD"),
|
||||||
|
fees=(),
|
||||||
|
raw="Raw Data",
|
||||||
|
description="Raw Description",
|
||||||
|
direction=RecordDirection.OUT,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CBASTConfig(ConverterConfig):
|
||||||
|
asset_account: str
|
||||||
|
asset_currency: str
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"asset-account",
|
||||||
|
"asset-currency",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "CBASTConfig":
|
||||||
|
if (f := next((f for f in cls.required if f not in data), None)) is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"CBA ST Converter Configuration missing required field: {f}"
|
||||||
|
)
|
||||||
|
return cls(data["asset-account"], data["asset-currency"])
|
||||||
|
|
||||||
|
|
||||||
|
@available_converter
|
||||||
|
class CBASTConverter(Converter[CBASTRecord, CBASTConfig]):
|
||||||
|
record_type = CBASTRecord
|
||||||
|
config_type = CBASTConfig
|
||||||
|
converter_name = "cbast"
|
||||||
|
version = "0"
|
||||||
|
display_name = "CBAST converter v0"
|
||||||
|
config_field = "CBAST"
|
||||||
|
|
||||||
|
def convert(self, record: CBASTRecord, 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
|
||||||
|
|
||||||
|
for name in {
|
||||||
|
"payee",
|
||||||
|
"narration",
|
||||||
|
"comment",
|
||||||
|
"document",
|
||||||
|
"tags",
|
||||||
|
"links",
|
||||||
|
"flag",
|
||||||
|
}:
|
||||||
|
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 Statement Record {record!r} to PartialTXN {txn!r}")
|
||||||
|
return txn
|
||||||
|
|
||||||
|
def _make_record(self, row: CBASTRow) -> CBASTRecord | None:
|
||||||
|
dt_format = "%Y-%m-%d"
|
||||||
|
|
||||||
|
# FIXME:: Ignoring currency conversions for now.
|
||||||
|
|
||||||
|
created_on = datetime.strptime(row.date, dt_format).date()
|
||||||
|
if row.credit and not row.debit:
|
||||||
|
# Into account
|
||||||
|
direction = RecordDirection.IN
|
||||||
|
source_account = row.description
|
||||||
|
target_account = "ACCOUNT"
|
||||||
|
rawvalue = row.credit
|
||||||
|
elif row.debit and not row.credit:
|
||||||
|
# Out of account
|
||||||
|
direction = RecordDirection.OUT
|
||||||
|
source_account = "ACCOUNT"
|
||||||
|
target_account = row.description
|
||||||
|
rawvalue = row.debit
|
||||||
|
elif not row.debit and not row.credit:
|
||||||
|
# Nothing changing
|
||||||
|
# Could make this a balance posting, but for now make a noop
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# TODO: Unhandled case for now
|
||||||
|
raise ValueError(
|
||||||
|
"Converter does not support transactions with both credit and debit."
|
||||||
|
)
|
||||||
|
|
||||||
|
amount = Amount(float(rawvalue.strip('"')), self.config.asset_currency)
|
||||||
|
record = CBASTRecord(
|
||||||
|
date=created_on,
|
||||||
|
source_account=source_account,
|
||||||
|
target_account=target_account,
|
||||||
|
from_source=amount if direction is RecordDirection.OUT else -amount,
|
||||||
|
to_target=amount if direction is RecordDirection.IN else -amount,
|
||||||
|
raw=",".join(row),
|
||||||
|
description=row.description,
|
||||||
|
direction=direction,
|
||||||
|
balance=Amount(float(row.balance or "0"), self.config.asset_currency),
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def ingest_string(self, data: str) -> list[CBASTRecord]:
|
||||||
|
reader = csv.reader(data.splitlines())
|
||||||
|
# Discard header
|
||||||
|
next(reader)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for row in reader:
|
||||||
|
record = self._make_record(CBASTRow(*row))
|
||||||
|
if record:
|
||||||
|
records.append(record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def ingest_file(self, path) -> list[CBASTRecord]:
|
||||||
|
with open(path) as f:
|
||||||
|
return self.ingest_string(f.read())
|
||||||
Reference in New Issue
Block a user