Add ABCs for records and transactions
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
from .rules import RuleSet, Rule, RuleInterface
|
||||||
|
from .transaction import Transaction, Amount, TXNFlag, TXNPosting
|
||||||
|
from .record import Record
|
||||||
|
from .converter import Converter
|
||||||
|
# from .beancounter import BeanCounter
|
||||||
|
from .partial import PartialPosting, PartialTXN
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import ClassVar, Literal, Self, Type, Optional
|
||||||
|
from . import RuleSet, Record, Transaction
|
||||||
|
from .partial import PartialTXN
|
||||||
|
|
||||||
|
"""
|
||||||
|
Yet to add:
|
||||||
|
Converter configuration.
|
||||||
|
Each converter can provide its own configuration class, which is read from the main configuration file when the converter is used.
|
||||||
|
This provides such things as the fee account, the main expense account, the name to look for...
|
||||||
|
self.config.fee_account
|
||||||
|
config_section = 'CBA Converter'
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Different currencies may come from different expense accounts..
|
||||||
|
The logic is actually complex enough that the configuration may as well be in code, honestly.
|
||||||
|
Or not.. we can just have placeholders filled in for the defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SKIPT = Enum('SKIPT', (('SKIP', 'SKIP'),))
|
||||||
|
SKIP = _SKIPT.SKIP
|
||||||
|
|
||||||
|
|
||||||
|
class ConverterConfig:
|
||||||
|
"""
|
||||||
|
Base class for converter configuration.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> Self:
|
||||||
|
"""
|
||||||
|
Load configuration for a serialised dictionary.
|
||||||
|
|
||||||
|
The dictionary/MappingProxy will usually be e.g. a configuration section.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Dump configuration to a format appropriate for serialisation.
|
||||||
|
|
||||||
|
Must be the inverse of from_dict.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Converter[RecordT: Record, ConfigT: ConverterConfig]:
|
||||||
|
"""
|
||||||
|
ABC for Record -> Transaction conversion interface.
|
||||||
|
|
||||||
|
A Converter defines and controls:
|
||||||
|
- How input is parsed into Records
|
||||||
|
- How Records are converted to Transactions (in addition to the RuleSet).
|
||||||
|
"""
|
||||||
|
converter_name: str
|
||||||
|
version: str
|
||||||
|
display_name: str
|
||||||
|
config_field: str
|
||||||
|
|
||||||
|
record_type: ClassVar[Type[RecordT]]
|
||||||
|
config_type: ClassVar[Type[ConfigT]]
|
||||||
|
|
||||||
|
def __init__(self, config: ConfigT, **kwargs):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def qual_name(cls):
|
||||||
|
return f"{cls.converter_name}_v{cls.version}"
|
||||||
|
|
||||||
|
def annotation(self, record: RecordT, partial: PartialTXN) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Optional user-readable note/warning to attach to a mapped record.
|
||||||
|
|
||||||
|
Intended to show e.g. missing fields or invalid transactions.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def convert(self, record: RecordT, ruleset: RuleSet) -> PartialTXN | _SKIPT:
|
||||||
|
"""
|
||||||
|
The meat of the conversion process.
|
||||||
|
Take a raw Record from the data and convert it into a partial transaction,
|
||||||
|
or the SKIP sentinel value.
|
||||||
|
|
||||||
|
The returned partial transaction may not be complete,
|
||||||
|
thus not convertible to a full Transaction.
|
||||||
|
|
||||||
|
This method must be abstract because individual converters define their
|
||||||
|
own record-logic.
|
||||||
|
|
||||||
|
Users may either edit the resulting TXN directly to complete it,
|
||||||
|
or provide a rule linking the record fields to the transaction fields.
|
||||||
|
In the latter case, this method will be re-run to build the transaction.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ingest_string(cls, data: str) -> list[RecordT]:
|
||||||
|
"""
|
||||||
|
Parse a string of raw input into a list of records.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ingest_file(cls, path) -> list[RecordT]:
|
||||||
|
"""
|
||||||
|
Ingest a target file (from path) into Records.
|
||||||
|
"""
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return cls.ingest_string(f.read())
|
||||||
|
|||||||
269
src/base/partial.py
Normal file
269
src/base/partial.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
from typing import NamedTuple, Optional
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from .transaction import ABCPosting, Transaction, TXNPosting, TXNFlag
|
||||||
|
|
||||||
|
|
||||||
|
class UserInputError(Exception):
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
super().__init__(msg)
|
||||||
|
self.message = msg
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class PartialPosting(ABCPosting):
|
||||||
|
"""
|
||||||
|
Partial posting object, potentially without an account name.
|
||||||
|
"""
|
||||||
|
account: Optional[str] = None
|
||||||
|
|
||||||
|
def upgrade(self, default_account: Optional[str] = None) -> TXNPosting:
|
||||||
|
account = self.account if self.account is not None else default_account
|
||||||
|
if account is None:
|
||||||
|
raise ValueError("PartialPosting has no account set, cannot upgrade.")
|
||||||
|
|
||||||
|
return TXNPosting(
|
||||||
|
account=account.format(currency=self.amount.currency),
|
||||||
|
amount=self.amount,
|
||||||
|
cost=self.cost,
|
||||||
|
total_cost=self.total_cost,
|
||||||
|
price=self.price,
|
||||||
|
flag=self.flag,
|
||||||
|
comment=self.comment
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def partial(self):
|
||||||
|
return self.account is None
|
||||||
|
|
||||||
|
|
||||||
|
class TXNField(NamedTuple):
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
value: str
|
||||||
|
matchable: bool
|
||||||
|
options: tuple[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class PartialTXN:
|
||||||
|
"""
|
||||||
|
Represents a simplified fixed-form Beancount Transaction,
|
||||||
|
with strong assumptions on the transaction type.
|
||||||
|
|
||||||
|
TODO: REPR
|
||||||
|
"""
|
||||||
|
date: dt.date
|
||||||
|
flag: TXNFlag = TXNFlag.INCOMPLETE
|
||||||
|
payee: str = ''
|
||||||
|
narration: str = ''
|
||||||
|
comment: Optional[str] = None
|
||||||
|
document: Optional[str] = None
|
||||||
|
tags: str = ''
|
||||||
|
links: str = ''
|
||||||
|
source_posting: PartialPosting
|
||||||
|
source_fee_asset_posting: Optional[PartialPosting] = None
|
||||||
|
source_fee_expense_posting: Optional[PartialPosting] = None
|
||||||
|
target_posting: PartialPosting
|
||||||
|
target_fee_expense_posting: Optional[PartialPosting] = None
|
||||||
|
|
||||||
|
# Exposing set of fields which may be updated (e.g. from rules)
|
||||||
|
# Map field name -> display name
|
||||||
|
fields = {
|
||||||
|
'flag': 'Flag',
|
||||||
|
'payee': 'Payee',
|
||||||
|
'narration': 'Narration',
|
||||||
|
'comment': "Comment",
|
||||||
|
'document': "Document",
|
||||||
|
'tags': "Tags",
|
||||||
|
'links': "Links",
|
||||||
|
'source_account': "Source Account",
|
||||||
|
'source_fee_asset_account': "Source Fee Asset Account",
|
||||||
|
'source_fee_expense_account': "Source Fee Expense Account",
|
||||||
|
'target_account': "Target Account",
|
||||||
|
'target_fee_expense_account': "Target Fee Expense Account",
|
||||||
|
}
|
||||||
|
posting_fields = {
|
||||||
|
'source_posting': 'source_account',
|
||||||
|
'source_fee_asset_posting': 'source_fee_asset_account',
|
||||||
|
'source_fee_expense_posting': 'source_fee_expense_account',
|
||||||
|
'target_posting': 'target_account',
|
||||||
|
'target_fee_expense_posting': 'target_fee_expense_account',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_account(self):
|
||||||
|
return self.source_posting.account
|
||||||
|
|
||||||
|
@source_account.setter
|
||||||
|
def source_account(self, value: str):
|
||||||
|
self.source_posting.account = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_account(self):
|
||||||
|
return self.target_posting.account
|
||||||
|
|
||||||
|
@target_account.setter
|
||||||
|
def target_account(self, value: str):
|
||||||
|
self.target_posting.account = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_fee_asset_account(self):
|
||||||
|
if (posting := self.source_fee_asset_posting) is not None:
|
||||||
|
return posting.account
|
||||||
|
|
||||||
|
@source_fee_asset_account.setter
|
||||||
|
def source_fee_asset_account(self, value: str):
|
||||||
|
if (posting := self.source_fee_asset_posting) is not None:
|
||||||
|
posting.account = value
|
||||||
|
else:
|
||||||
|
raise ValueError("This TXN does not have a source fee asset posting to set.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_fee_expense_account(self):
|
||||||
|
if (posting := self.source_fee_expense_posting) is not None:
|
||||||
|
return posting.account
|
||||||
|
|
||||||
|
@source_fee_expense_account.setter
|
||||||
|
def source_fee_expense_account(self, value: str):
|
||||||
|
if (posting := self.source_fee_expense_posting) is not None:
|
||||||
|
posting.account = value
|
||||||
|
else:
|
||||||
|
raise ValueError("This TXN does not have a source fee expense posting to set.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_fee_expense_account(self):
|
||||||
|
if (posting := self.target_fee_expense_posting) is not None:
|
||||||
|
return posting.account
|
||||||
|
|
||||||
|
@target_fee_expense_account.setter
|
||||||
|
def target_fee_expense_account(self, value: str):
|
||||||
|
if (posting := self.target_fee_expense_posting) is not None:
|
||||||
|
posting.account = value
|
||||||
|
else:
|
||||||
|
raise ValueError("This TXN does not have a target fee expense posting to set.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postings(self):
|
||||||
|
postings = {}
|
||||||
|
for name in self.posting_fields:
|
||||||
|
posting = getattr(self, name)
|
||||||
|
if posting is not None:
|
||||||
|
postings[name] = posting
|
||||||
|
return postings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def partial(self):
|
||||||
|
return any(posting.partial for posting in self.postings.values())
|
||||||
|
|
||||||
|
def update(self, overwrite=True, **kwargs):
|
||||||
|
"""
|
||||||
|
Update TXN from provided field values.
|
||||||
|
|
||||||
|
With overwrite=False, only modifes fields that have not been set.
|
||||||
|
"""
|
||||||
|
for field in self.fields:
|
||||||
|
if field in kwargs and (overwrite or not getattr(self, field)):
|
||||||
|
# Note that this will error if we attempt to set
|
||||||
|
# the account name for a posting we don't have.
|
||||||
|
setattr(self, field, kwargs[field])
|
||||||
|
|
||||||
|
def upgrade(self, defaults={}) -> Transaction:
|
||||||
|
"""
|
||||||
|
Upgrade this PartialTransaction to a full Transaction using the given default fields if needed.
|
||||||
|
"""
|
||||||
|
if self.partial and defaults:
|
||||||
|
with_defaults = self.copy()
|
||||||
|
# Remove defaults for postings we don't have
|
||||||
|
we_have = self.postings.keys()
|
||||||
|
we_dont_have = set(self.posting_fields.keys()).difference(we_have)
|
||||||
|
with_defaults.update(
|
||||||
|
overwrite=False,
|
||||||
|
**{k: v for k, v in defaults.items() if k not in we_dont_have}
|
||||||
|
)
|
||||||
|
upgraded = with_defaults.upgrade()
|
||||||
|
elif self.partial:
|
||||||
|
raise ValueError("Cannot upgrade partial transaction.")
|
||||||
|
else:
|
||||||
|
upgraded = Transaction(
|
||||||
|
date=self.date,
|
||||||
|
flag=self.flag,
|
||||||
|
payee=self.payee,
|
||||||
|
narration=self.narration,
|
||||||
|
comments=[self.comment] if self.comment else [],
|
||||||
|
document=[self.document] if self.document else [],
|
||||||
|
tags=self.tags.split(),
|
||||||
|
links=self.links.split(),
|
||||||
|
postings=[p.upgrade() for p in self.postings.values()]
|
||||||
|
)
|
||||||
|
return upgraded
|
||||||
|
|
||||||
|
def copy(self):
|
||||||
|
update = {}
|
||||||
|
for posting_name in self.posting_fields:
|
||||||
|
posting = getattr(self, posting_name)
|
||||||
|
if posting is not None:
|
||||||
|
update[posting_name] = replace(posting)
|
||||||
|
return replace(self, **update)
|
||||||
|
|
||||||
|
def display_fields(self) -> list[TXNField]:
|
||||||
|
"""
|
||||||
|
The fields to display from this partial transaction.
|
||||||
|
"""
|
||||||
|
fields = []
|
||||||
|
postings = self.postings
|
||||||
|
field_postings = {n: pn for pn, n in self.posting_fields.items()}
|
||||||
|
for name, display_name in self.fields.items():
|
||||||
|
if (pname := field_postings.get(name)) and pname not in postings:
|
||||||
|
# Don't include posting accounts which aren't there
|
||||||
|
continue
|
||||||
|
match name:
|
||||||
|
case 'flag':
|
||||||
|
value = self.flag.value
|
||||||
|
case _:
|
||||||
|
value = getattr(self, name)
|
||||||
|
value = str(value) if value is not None else ''
|
||||||
|
fields.append(TXNField(
|
||||||
|
name=name,
|
||||||
|
display_name=display_name,
|
||||||
|
value=value,
|
||||||
|
matchable=True
|
||||||
|
))
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def parse_input(self, entries: dict[str, str]):
|
||||||
|
"""
|
||||||
|
Parse a map of field name -> user entered strings
|
||||||
|
into a dictionary which may be used in update()
|
||||||
|
"""
|
||||||
|
updater = {}
|
||||||
|
for name, userstr in entries.items():
|
||||||
|
userstr = userstr.strip()
|
||||||
|
# TODO: Each of these cases needs custom validation
|
||||||
|
match name:
|
||||||
|
case 'flag':
|
||||||
|
if userstr == '!':
|
||||||
|
updater['flag'] = TXNFlag.INCOMPLETE
|
||||||
|
elif userstr == '*':
|
||||||
|
updater['flag'] = TXNFlag.COMPLETE
|
||||||
|
else:
|
||||||
|
raise UserInputError(
|
||||||
|
"Transaction flag must be either '*' or '!'"
|
||||||
|
)
|
||||||
|
case 'payee' | 'narration' | 'tags' | 'links':
|
||||||
|
updater[name] = userstr
|
||||||
|
case 'comment' | 'document':
|
||||||
|
updater[name] = userstr or None
|
||||||
|
case 'source_account' | 'target_account':
|
||||||
|
updater[name] = userstr
|
||||||
|
case 'source_fee_asset_account':
|
||||||
|
updater[name] = userstr
|
||||||
|
case 'source_fee_expense_account':
|
||||||
|
updater[name] = userstr
|
||||||
|
case 'target_fee_expense_account':
|
||||||
|
updater[name] = userstr
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Unknown field {name} passed to TXN parser.")
|
||||||
|
return updater
|
||||||
96
src/base/record.py
Normal file
96
src/base/record.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from typing import ClassVar, NamedTuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from . import Amount
|
||||||
|
|
||||||
|
|
||||||
|
class RecordField(NamedTuple):
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
value: str
|
||||||
|
matchable: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True, frozen=True)
|
||||||
|
class Record:
|
||||||
|
"""
|
||||||
|
Represents a raw transaction record read by a converter from input.
|
||||||
|
|
||||||
|
This formalises and fixes the data structure to convert,
|
||||||
|
so that the rules and converter remain unchanged even if the underlying data format
|
||||||
|
(e.g. bank statement csv) changes.
|
||||||
|
|
||||||
|
Specific converters should subclass the Record for any required custom fields
|
||||||
|
and context.
|
||||||
|
|
||||||
|
The Record fields will be provided and checked for the conditional fields of Rules.
|
||||||
|
"""
|
||||||
|
date: dt.date
|
||||||
|
source_account: str
|
||||||
|
target_account: str
|
||||||
|
|
||||||
|
from_source: Amount
|
||||||
|
to_target: Amount
|
||||||
|
|
||||||
|
fees: tuple[Amount] = field(default_factory=tuple)
|
||||||
|
raw: str | None = None
|
||||||
|
comments: tuple[str] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
# List of record fields to display in UI
|
||||||
|
# List of [field name, display name]
|
||||||
|
_display_fields: ClassVar[list[tuple[str, str]]]
|
||||||
|
|
||||||
|
# List of record fields which may be included in rules
|
||||||
|
_match_fields: ClassVar[list[str]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sample_record(cls):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def display_fields(self) -> list[RecordField]:
|
||||||
|
"""
|
||||||
|
Build the fields to display from this record.
|
||||||
|
|
||||||
|
Default implementation uses the _display_fields and _match_fields
|
||||||
|
class variables.
|
||||||
|
May be overidden by subclasses which need more complex
|
||||||
|
display logic.
|
||||||
|
"""
|
||||||
|
fields = []
|
||||||
|
for name, display in self._display_fields:
|
||||||
|
value = getattr(self, name)
|
||||||
|
value = str(value) if value is not None else ''
|
||||||
|
matchable = name in self._match_fields
|
||||||
|
fields.append(RecordField(name, display, value, matchable))
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def match_fields(self) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Build the field: value pairs for matching against a Rule.
|
||||||
|
|
||||||
|
Default implementation uses the _match_fields classvar.
|
||||||
|
May be overridden by subclasses which provide more match variables,
|
||||||
|
e.g. variables which are not attributes.
|
||||||
|
"""
|
||||||
|
field_values = {}
|
||||||
|
for field_name in self._match_fields:
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
# Replace None values by empty strings
|
||||||
|
value = str(value) if value is not None else ''
|
||||||
|
field_values[field_name] = value
|
||||||
|
|
||||||
|
return field_values
|
||||||
|
|
||||||
|
def display_table(self):
|
||||||
|
"""
|
||||||
|
Tabulate the record for a simple display.
|
||||||
|
"""
|
||||||
|
fields = self.display_fields()
|
||||||
|
maxlen = max((len(f.display_name) for f in fields), default=0)
|
||||||
|
lines = []
|
||||||
|
for _, display, value, _ in fields:
|
||||||
|
lines.append(f"{display:<{maxlen}}:\t{value}")
|
||||||
|
|
||||||
|
return lines
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from typing import Self
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Rule:
|
||||||
|
__slots__ = ('conditions', 'values')
|
||||||
|
|
||||||
|
def __init__(self, conditions: dict[str, str], values: dict[str, str]):
|
||||||
|
self.conditions = conditions
|
||||||
|
self.values = values
|
||||||
|
|
||||||
|
def check(self, record: dict[str, str]) -> bool:
|
||||||
|
"""
|
||||||
|
Check whether this rule applies to the given record fields.
|
||||||
|
"""
|
||||||
|
return all(record.get(key, None) == value for key, value in self.conditions.items())
|
||||||
|
|
||||||
|
|
||||||
|
class RuleInterface:
|
||||||
|
"""
|
||||||
|
ABC for Record -> Transaction rule data interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, converter: str, **kwargs):
|
||||||
|
self.converter = converter
|
||||||
|
|
||||||
|
def load_rules(self) -> list[Rule]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def save_rules(self, rules: list[Rule]):
|
||||||
|
"""
|
||||||
|
Save the given rules to storage.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class JSONRuleInterface(RuleInterface):
|
||||||
|
"""
|
||||||
|
Serialise rules into and out of a JSON file.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
'rules': [
|
||||||
|
{
|
||||||
|
'record_fields': {},
|
||||||
|
'transaction_fields': {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
def __init__(self, converter: str, path: str, **kwargs):
|
||||||
|
self.path = path
|
||||||
|
super().__init__(converter, **kwargs)
|
||||||
|
|
||||||
|
def load_rules(self) -> list[Rule]:
|
||||||
|
import json
|
||||||
|
|
||||||
|
rules = []
|
||||||
|
if not os.path.exists(self.path):
|
||||||
|
self.save_rules([])
|
||||||
|
|
||||||
|
with open(self.path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for rule_data in data.get('rules', []):
|
||||||
|
rule = Rule(
|
||||||
|
conditions=rule_data['record_fields'],
|
||||||
|
values=rule_data['transaction_fields'],
|
||||||
|
)
|
||||||
|
rules.append(rule)
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
def save_rules(self, rules: list[Rule]):
|
||||||
|
import json
|
||||||
|
|
||||||
|
rule_data = []
|
||||||
|
for rule in rules:
|
||||||
|
rule_data.append({
|
||||||
|
'record_fields': rule.conditions,
|
||||||
|
'transaction_fields': rule.values,
|
||||||
|
})
|
||||||
|
data = json.dumps({'rules': rule_data}, indent=2)
|
||||||
|
with open(self.path, 'w') as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyRuleInterface(RuleInterface):
|
||||||
|
"""
|
||||||
|
Dummy plug for the rule interface.
|
||||||
|
Can be used for testing or if the rules are otherwise loaded internally.
|
||||||
|
"""
|
||||||
|
def load_rules(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_rules(self, rules):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSet:
|
||||||
|
def __init__(self, rules: list[Rule], interface: RuleInterface):
|
||||||
|
self.rules = rules
|
||||||
|
self.interface = interface
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_from(cls, interface: RuleInterface) -> Self:
|
||||||
|
rules = interface.load_rules()
|
||||||
|
return cls(rules, interface)
|
||||||
|
|
||||||
|
def reload_rules(self):
|
||||||
|
self.rules = self.interface.load_rules()
|
||||||
|
|
||||||
|
def save_rules(self):
|
||||||
|
self.interface.save_rules(self.rules)
|
||||||
|
|
||||||
|
def apply(self, record_fields: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Apply the ruleset to the given record.
|
||||||
|
Returns a dictionary of partial-transaction fields.
|
||||||
|
|
||||||
|
Transaction fields may be empty if no rules apply.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# TODO: Come up with some nice field cache/map for efficient rule lookup.
|
||||||
|
# Probably build a rule tree
|
||||||
|
for rule in self.rules:
|
||||||
|
if rule.check(record_fields):
|
||||||
|
result |= rule.values
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def add_rule(self, rule: Rule):
|
||||||
|
"""
|
||||||
|
Add a rule to the rule set.
|
||||||
|
"""
|
||||||
|
self.rules.append(rule)
|
||||||
|
|||||||
164
src/base/transaction.py
Normal file
164
src/base/transaction.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
from typing import ClassVar, Optional
|
||||||
|
import datetime as dt
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class TXNFlag(Enum):
|
||||||
|
COMPLETE = '*'
|
||||||
|
INCOMPLETE = '!'
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class Amount:
|
||||||
|
"""
|
||||||
|
Represents a beancount 'amount' with given currency.
|
||||||
|
"""
|
||||||
|
value: float
|
||||||
|
currency: str
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.value} {self.currency}"
|
||||||
|
|
||||||
|
def at_price(self, price: 'Amount') -> 'Amount':
|
||||||
|
return Amount(self.value * price.value, price.currency)
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
if isinstance(other, Amount):
|
||||||
|
if other.currency != self.currency:
|
||||||
|
raise ValueError("Cannot add Amounts with different currencies.")
|
||||||
|
return Amount(self.value + other.value, self.currency)
|
||||||
|
else:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
if isinstance(other, Amount):
|
||||||
|
if other.currency != self.currency:
|
||||||
|
raise ValueError("Cannot subtract Amounts with different currencies.")
|
||||||
|
return Amount(self.value - other.value, self.currency)
|
||||||
|
else:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __rsub__(self, other):
|
||||||
|
return self - other
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.value, self.currency))
|
||||||
|
|
||||||
|
def __neg__(self):
|
||||||
|
return Amount(-self.value, self.currency)
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class ABCPosting:
|
||||||
|
"""
|
||||||
|
Represents the data of a TXNPosting.
|
||||||
|
"""
|
||||||
|
amount: Amount
|
||||||
|
cost: Optional[Amount] = None
|
||||||
|
total_cost: Optional[Amount] = None
|
||||||
|
price: Optional[Amount] = None
|
||||||
|
flag: Optional[TXNFlag] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validation"""
|
||||||
|
if self.total_cost is not None and self.price is not None:
|
||||||
|
raise ValueError("Posting cannot have both price and total cost")
|
||||||
|
|
||||||
|
def weight(self) -> Amount:
|
||||||
|
"""
|
||||||
|
Return the balancing weight of this posting.
|
||||||
|
|
||||||
|
Implementation of:
|
||||||
|
https://beancount.github.io/docs/beancount_language_syntax.html#balancing-rule-the-weight-of-postings
|
||||||
|
"""
|
||||||
|
if self.cost is not None:
|
||||||
|
weight = self.amount.at_price(self.cost)
|
||||||
|
elif self.total_cost is not None:
|
||||||
|
weight = self.total_cost
|
||||||
|
elif self.price is not None:
|
||||||
|
weight = self.amount.at_price(self.price)
|
||||||
|
else:
|
||||||
|
weight = self.amount
|
||||||
|
return weight
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class TXNPosting(ABCPosting):
|
||||||
|
"""
|
||||||
|
Represents a single row of a Transaction
|
||||||
|
|
||||||
|
[Flag] Account Amount [{Cost}] [@ Price]
|
||||||
|
|
||||||
|
Note: Remember that Cost, Price, and Total Price are unsigned.
|
||||||
|
"""
|
||||||
|
account: str
|
||||||
|
def __str__(self):
|
||||||
|
parts = []
|
||||||
|
if self.flag:
|
||||||
|
parts.append(self.flag.value)
|
||||||
|
parts.append(self.account)
|
||||||
|
parts.append(str(self.amount))
|
||||||
|
if self.cost is not None:
|
||||||
|
parts.append(f"{{{self.cost}}}")
|
||||||
|
if self.price is not None:
|
||||||
|
parts.append(f"@ {self.price}")
|
||||||
|
if self.total_cost is not None:
|
||||||
|
parts.append(f"@@ {self.total_cost}")
|
||||||
|
|
||||||
|
if self.comment:
|
||||||
|
parts.append(f" ; {self.comment}")
|
||||||
|
|
||||||
|
return ' '.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction:
|
||||||
|
"""
|
||||||
|
Represents a BeanCount Transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, date, **kwargs):
|
||||||
|
self.date: dt.date = date
|
||||||
|
self.flag: TXNFlag = kwargs.get('flag', TXNFlag.COMPLETE)
|
||||||
|
self.payee: str = kwargs.get('payee', "")
|
||||||
|
self.narration: str = kwargs.get('narration', "")
|
||||||
|
self.comments: list[str] = kwargs.get('comments', [])
|
||||||
|
self.documents: list[str] = kwargs.get('documents', [])
|
||||||
|
self.tags: list[str] = kwargs.get('tags', [])
|
||||||
|
self.links: list[str] = kwargs.get('links', [])
|
||||||
|
|
||||||
|
self.postings: list[TXNPosting] = kwargs.get('postings', [])
|
||||||
|
|
||||||
|
def check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check whether this transaction balances.
|
||||||
|
"""
|
||||||
|
return sum((posting.weight() for posting in self.postings), 0) == 0
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""
|
||||||
|
Beancount ledger representation of this transaction.
|
||||||
|
"""
|
||||||
|
header = "{date} {flag} {payee} {narration} {tags} {links}".format(
|
||||||
|
date=self.date,
|
||||||
|
flag=self.flag.value,
|
||||||
|
payee=f"\"{self.payee or ''}\"",
|
||||||
|
narration=f"\"{self.narration or ''}\"",
|
||||||
|
tags=' '.join('#' + tag for tag in self.tags),
|
||||||
|
links=' '.join('^' + link for link in self.links),
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for comment in self.comments:
|
||||||
|
lines.append("; " + comment)
|
||||||
|
|
||||||
|
for document in self.documents:
|
||||||
|
lines.append("document: " + document)
|
||||||
|
|
||||||
|
for posting in self.postings:
|
||||||
|
lines.append(str(posting))
|
||||||
|
|
||||||
|
return '\n'.join((header, *(' ' + line for line in lines)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user