Add basic Wise format converter

This commit is contained in:
2025-12-02 16:17:17 +10:00
parent af5a827710
commit 42b85529cb
2 changed files with 332 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
from typing import TYPE_CHECKING, Type
from base.converter import Converter
converters_available: list[Type[Converter]] = []
def converter_factory(name: str | None = None, qual_name: str | None = None) -> Type[Converter]:
if name and not qual_name:
converter = next((c for c in converters_available if c.converter_name == name), None)
if converter is None:
raise ValueError(f"No converter matching {name=}")
elif qual_name and not name:
converter = next((c for c in converters_available if c.qual_name() == qual_name), None)
if converter is None:
raise ValueError(f"No converter matching {qual_name=}")
else:
raise ValueError("Exactly one of name or qual_name must be provided.")
return converter
def available_converter(converter_cls):
converters_available.append(converter_cls)
return converter_cls
from .wise_converter import *

View File

@@ -0,0 +1,307 @@
import csv
from dataclasses import dataclass
from enum import Enum
import datetime as dt
from datetime import datetime
import logging
from base import Converter, PartialTXN, PartialPosting, Record, Amount
from base.converter import ConverterConfig
from base.rules import RuleSet
from base.transaction import TXNFlag
from . import available_converter
__all__ = [
'WiseRecordStatus',
'WiseRecordDirection',
'WiseRecord',
'WiseConfig',
'WiseConverter',
]
logger = logging.getLogger(__name__)
class WiseRecordStatus(Enum):
COMPLETED = 'COMPLETED'
CANCELLED = 'CANCELLED'
class WiseRecordDirection(Enum):
OUT = 'OUT'
NEUTRAL = 'NEUTRAL'
IN = 'IN'
@dataclass(kw_only=True, frozen=True)
class WiseRecord(Record):
id: str
status: WiseRecordStatus
direction: WiseRecordDirection
created_on: datetime
finished_on: datetime | None
source_fee: Amount | None
target_fee: Amount | None
exchange_rate: float
_display_fields = [
('id', "ID"),
('status', "Status"),
('direction', "Direction"),
('created_on', "Created"),
('finished_on', "Finished"),
('from_source_net', "Source Net Amount"),
('source_fee', "Source Fee"),
('exchange_rate', "Exchange Rate"),
('exchanged_amount', "Total Exchanged"),
('target_fee', "Target Fee"),
('source_currency', "Source Currency"),
('target_currency', "Target Currency"),
('source_account', "Source Account"),
('target_account', "Target Account"),
]
_match_fields = [
'id', 'status', 'direction',
'source_currency', 'target_currency',
'source_account', 'target_account',
]
@property
def source_currency(self):
return self.from_source.currency
@property
def target_currency(self):
return self.to_target.currency
@property
def from_source_net(self):
"""
The amount that was taken from the source account not including fees.
Use `from_source` to get the amount including fees.
"""
amount = self.from_source
if self.source_fee is not None:
amount = amount - self.source_fee
return amount
@property
def exchanged_amount(self):
"""
The amount that was sent to the target, in the target currency.
Includes any target fees.
May be used to obtain the amount exchanged to the target currency.
"""
amount = self.to_target
if self.target_fee is not None:
amount = amount + self.target_fee
return amount
@classmethod
def sample_record(cls):
self = cls(
date=dt.date.today(),
source_account="John Doe",
target_account="Jane Austen",
from_source=Amount(314, 'GBK'),
to_target=Amount(314, 'KBG'),
fees=tuple(),
raw="Raw Data",
id="00000",
status=WiseRecordStatus.COMPLETED,
direction=WiseRecordDirection.IN,
created_on=datetime.now(),
finished_on=datetime.now(),
source_fee=Amount(1, 'GBK'),
target_fee=Amount(1, 'KBG'),
exchange_rate=1,
)
return self
@classmethod
def from_row(cls, row):
id = row[0]
status = row[1]
direction = row[2]
created_on = row[3]
finished_on = row[3]
source_fee_amount = float(row[5] or 0)
source_fee_currency = row[6]
target_fee_amount = float(row[7] or 0)
target_fee_currency = row[8]
source_name = row[9]
source_amount_final = float(row[10] or 0)
source_currency = row[11]
target_name = row[12]
target_amount_final = float(row[13] or 0)
target_currency = row[14]
exchange_rate = float(row[15] or 0)
wise_dt_format = '%Y-%m-%d %H:%M:%S'
created_on_dt = datetime.strptime(created_on, wise_dt_format)
finished_on_dt = datetime.strptime(finished_on, wise_dt_format) if finished_on else None
fees = []
if source_fee_amount:
source_fee = Amount(source_fee_amount, source_fee_currency)
fees.append(source_fee)
else:
source_fee = None
if target_fee_amount:
target_fee = Amount(target_fee_amount, target_fee_currency)
fees.append(target_fee)
else:
target_fee = None
raw = ','.join(row)
self = cls(
date=created_on_dt.date(),
source_account=source_name,
target_account=target_name,
from_source=Amount(source_amount_final, source_currency),
to_target=Amount(target_amount_final, target_currency),
fees=tuple(fees),
raw=raw,
id=id,
status=WiseRecordStatus(status),
direction=WiseRecordDirection(direction),
created_on=created_on_dt,
finished_on=finished_on_dt,
source_fee=source_fee,
target_fee=target_fee,
exchange_rate=float(exchange_rate),
)
logger.debug(
f"Converted Wise row {raw} to record {self!r}"
)
return self
@dataclass
class WiseConfig(ConverterConfig):
"""
UI NOTE:
For batch mode, error if the fields don't exist.
For interactive mode, prompt config creation.
TODO: Actual field defaults for fill-in
"""
asset_account: str
fee_account: str
required = {
'asset_account',
'fee_account',
}
@classmethod
def from_dict(cls, data: dict) -> 'WiseConfig':
if (f := next((f for f in cls.required if f not in data), None)) is not None:
raise ValueError(f"Wise Configuration missing required field: {f}")
return cls(**data)
@available_converter
class WiseConverter(Converter[WiseRecord, WiseConfig]):
record_type = WiseRecord
config_type = WiseConfig
converter_name = 'wise'
version = '0'
display_name = "Wise Record Converter v0"
config_field = 'WISE'
def __init__(self, config: WiseConfig, **kwargs):
self.config = config
def annotation(self, record: WiseRecord, partial: PartialTXN):
...
def convert(self, record: WiseRecord, ruleset: RuleSet) -> PartialTXN:
fields = {}
match record.direction:
# Handle configured default accounts
case WiseRecordDirection.OUT:
fields['source_account'] = self.config.asset_account.format(currency=record.source_currency)
case WiseRecordDirection.NEUTRAL:
fields['source_account'] = self.config.asset_account.format(currency=record.source_currency)
fields['target_account'] = self.config.asset_account.format(currency=record.target_currency)
case WiseRecordDirection.IN:
fields['target_account'] = self.config.asset_account.format(currency=record.target_currency)
fields |= ruleset.apply(record.match_fields())
args = {}
args['date'] = record.date
# Convert string flag if it exists
if 'flag' in fields:
args['flag'] = TXNFlag(fields['flag'])
# Copy string fields over directly
for name in {'payee', 'narration', 'comment', 'document', 'tags', 'links'}:
if name in fields:
args[name] = fields[name]
args['source_posting'] = PartialPosting(
account=fields.get('source_account', None),
amount=-record.from_source_net,
total_cost=record.exchanged_amount if record.source_currency != record.target_currency else None,
)
args['target_posting'] = PartialPosting(
account=fields.get('target_account', None),
amount=record.to_target,
)
if record.source_fee:
args['source_fee_asset_posting'] = PartialPosting(
account=fields.get('source_fee_asset_account', fields.get('source_account', None)),
amount=-record.source_fee
)
args['source_fee_expense_posting'] = PartialPosting(
account=fields.get('source_fee_expense_account', self.config.fee_account),
amount=record.source_fee
)
if record.target_fee:
args['target_fee_expense_posting'] = PartialPosting(
account=fields.get('target_fee_expense_account', self.config.fee_account),
amount=record.target_fee
)
txn = PartialTXN(**args)
logger.debug(
f"Converted Wise Record {record!r} to Partial Transaction {txn!r}"
)
return txn
@classmethod
def ingest_string(cls, data: str) -> list[WiseRecord]:
"""
Parse a string of raw input into a list of records.
"""
reader = csv.reader(data.splitlines())
records = []
for row in reader:
record = WiseRecord.from_row(row)
if record.status is not WiseRecordStatus.COMPLETED:
logging.info(f"Skipping record with non-complete status: {record}")
else:
records.append(record)
return records
@classmethod
def ingest_file(cls, path) -> list[WiseRecord]:
with open(path) as f:
f.readline()
return cls.ingest_string(f.read())