Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ class PartialTXN:
|
|||||||
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 +78,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 +101,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 = {}
|
||||||
@@ -265,12 +214,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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
@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,7 @@ class WiseConverter(Converter[WiseRecord, WiseConfig]):
|
|||||||
amount=record.target_fee,
|
amount=record.target_fee,
|
||||||
)
|
)
|
||||||
|
|
||||||
txn = PartialTXN(**args)
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user