Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42751393ad | |||
| 2f514402e3 | |||
| 9f37b94209 | |||
| c891ee883b | |||
| bde2b65710 | |||
| 0340d1dd1b | |||
| 1099339b35 |
@@ -44,7 +44,7 @@ class ConverterConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Converter[RecordT: Record, ConfigT: ConverterConfig]:
|
||||
class Converter[RecordT: Record, TxnT: PartialTXN, ConfigT: ConverterConfig]:
|
||||
"""
|
||||
ABC for Record -> Transaction conversion interface.
|
||||
|
||||
@@ -60,6 +60,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
|
||||
|
||||
record_type: ClassVar[Type[RecordT]]
|
||||
config_type: ClassVar[Type[ConfigT]]
|
||||
txn_type: ClassVar[Type[TxnT]]
|
||||
|
||||
def __init__(self, config: ConfigT, **kwargs):
|
||||
self.config = config
|
||||
@@ -68,7 +69,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
|
||||
def qual_name(cls):
|
||||
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.
|
||||
|
||||
@@ -76,7 +77,7 @@ class Converter[RecordT: Record, ConfigT: ConverterConfig]:
|
||||
"""
|
||||
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.
|
||||
Take a raw Record from the data and convert it into a partial transaction,
|
||||
|
||||
@@ -65,10 +65,7 @@ class PartialTXN:
|
||||
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
|
||||
@@ -81,17 +78,11 @@ class PartialTXN:
|
||||
"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
|
||||
@@ -110,48 +101,6 @@ class PartialTXN:
|
||||
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 = {}
|
||||
@@ -265,12 +214,6 @@ class PartialTXN:
|
||||
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
|
||||
|
||||
@@ -34,3 +34,4 @@ def available_converter(converter_cls):
|
||||
from .wise_converter import *
|
||||
from .cba_converter import *
|
||||
from .cbastatement_converter import *
|
||||
from .paypal_converter import *
|
||||
|
||||
@@ -97,9 +97,10 @@ class CBAConfig(ConverterConfig):
|
||||
|
||||
|
||||
@available_converter
|
||||
class CBAConverter(Converter[CBARecord, CBAConfig]):
|
||||
class CBAConverter(Converter[CBARecord, PartialTXN, CBAConfig]):
|
||||
record_type = CBARecord
|
||||
config_type = CBAConfig
|
||||
txn_type = PartialTXN
|
||||
converter_name = "cbacsv"
|
||||
version = "0"
|
||||
display_name = "CBACSV converter v0"
|
||||
@@ -205,7 +206,7 @@ class CBAConverter(Converter[CBARecord, CBAConfig]):
|
||||
|
||||
records = []
|
||||
for row in reader:
|
||||
if row[0].lower().strip() == "date":
|
||||
if "date" in row[0].lower().strip():
|
||||
# Skip header row
|
||||
continue
|
||||
record = self._make_record(CBACSVRow(*row))
|
||||
|
||||
@@ -94,9 +94,10 @@ class CBASTConfig(ConverterConfig):
|
||||
|
||||
|
||||
@available_converter
|
||||
class CBASTConverter(Converter[CBASTRecord, CBASTConfig]):
|
||||
class CBASTConverter(Converter[CBASTRecord, PartialTXN, CBASTConfig]):
|
||||
record_type = CBASTRecord
|
||||
config_type = CBASTConfig
|
||||
txn_type = PartialTXN
|
||||
converter_name = "cbast"
|
||||
version = "0"
|
||||
display_name = "CBAST converter v0"
|
||||
|
||||
@@ -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(
|
||||
"comment",
|
||||
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
|
||||
class SampleConverter(Converter[SampleRecord, SampleConfig]):
|
||||
class SampleConverter(Converter[SampleRecord, PartialTXN, SampleConfig]):
|
||||
record_type = SampleRecord
|
||||
config_type = SampleConfig
|
||||
txn_type = PartialTXN
|
||||
converter_name = "sample"
|
||||
version = "0"
|
||||
display_name = "Sample converter v0"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import csv
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import datetime as dt
|
||||
from datetime import datetime
|
||||
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
|
||||
from ..base.partial import UserInputError
|
||||
|
||||
from . import available_converter
|
||||
|
||||
@@ -27,6 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
class WiseRecordStatus(Enum):
|
||||
COMPLETED = "COMPLETED"
|
||||
CANCELLED = "CANCELLED"
|
||||
REFUNDED = "REFUNDED"
|
||||
|
||||
|
||||
class WiseRecordDirection(Enum):
|
||||
@@ -212,10 +215,107 @@ class WiseConfig(ConverterConfig):
|
||||
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
|
||||
class WiseConverter(Converter[WiseRecord, WiseConfig]):
|
||||
class WiseConverter(Converter[WiseRecord, WisePartialTXN, WiseConfig]):
|
||||
record_type = WiseRecord
|
||||
config_type = WiseConfig
|
||||
txn_type = WisePartialTXN
|
||||
converter_name = "wise"
|
||||
version = "0"
|
||||
display_name = "Wise Record Converter v0"
|
||||
@@ -224,9 +324,9 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
|
||||
def __init__(self, config: WiseConfig, **kwargs):
|
||||
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 = {}
|
||||
|
||||
match record.direction:
|
||||
@@ -301,7 +401,7 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
|
||||
amount=record.target_fee,
|
||||
)
|
||||
|
||||
txn = PartialTXN(**args)
|
||||
txn = WisePartialTXN(**args)
|
||||
|
||||
logger.debug(f"Converted Wise Record {record!r} to Partial Transaction {txn!r}")
|
||||
return txn
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pickle
|
||||
import tkinter as tk
|
||||
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="Reload Rules", command=self.do_reload_rules)
|
||||
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())
|
||||
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+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-Up>", lambda event: self.rowtree.focus_prev())
|
||||
self.bind_all("<Alt-Down>", lambda event: self.rowtree.focus_next())
|
||||
self.bind_all("<Alt-Shift-Up>", lambda event: self.rowtree.focus_prev_partial())
|
||||
self.bind_all(
|
||||
"<Alt-Shift-Down>", lambda event: self.rowtree.focus_next_partial()
|
||||
)
|
||||
self.bind_all("<Alt-k>", lambda event: self.rowtree.focus_prev())
|
||||
self.bind_all("<Alt-j>", lambda event: self.rowtree.focus_next())
|
||||
self.bind_all("<Alt-Shift-K>", lambda event: self.rowtree.focus_prev_partial())
|
||||
self.bind_all("<Alt-Shift-J>", lambda event: self.rowtree.focus_next_partial())
|
||||
|
||||
self.bind_all(
|
||||
"<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.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)
|
||||
|
||||
@@ -331,7 +337,7 @@ class MainWindow(ThemedTk):
|
||||
# Merge into a map, and update the cached map with it.
|
||||
|
||||
# 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}
|
||||
|
||||
# Read the ruleset rules for options
|
||||
@@ -359,3 +365,49 @@ class MainWindow(ThemedTk):
|
||||
"""
|
||||
self.ruleset.reload_rules()
|
||||
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 typing import Type
|
||||
|
||||
|
||||
from ..base.converter import Converter
|
||||
from ..base.partial import PartialTXN
|
||||
from ..base.record import Record
|
||||
|
||||
@@ -8,9 +10,11 @@ from . import logger
|
||||
|
||||
|
||||
class RowEditor(ttk.Frame):
|
||||
def __init__(self, master, acmpl_cache={}, **kwargs):
|
||||
def __init__(self, master, converter: Converter, acmpl_cache={}, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self.converter = converter
|
||||
|
||||
# Data state
|
||||
self.rows: list[tuple[Record, PartialTXN]] = []
|
||||
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
|
||||
fieldnames = PartialTXN.fields
|
||||
fieldnames = self.converter.txn_type.fields
|
||||
|
||||
# field -> stringvar
|
||||
txnvars = {name: StringVar() for name in fieldnames}
|
||||
|
||||
@@ -175,10 +175,10 @@ class RowTree(ttk.Frame):
|
||||
"txn.tags": ("Bean Tags", str),
|
||||
"txn.links": ("Bean Links", str),
|
||||
"txn.source_account": ("Bean Source", 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_asset_account": ("Bean Source Fee Asset Acc", str),
|
||||
# "txn.source_fee_expense_account": ("Bean Source Fee Expense Acc", 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user