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