Add project config for beanify
This commit is contained in:
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "beanify"
|
||||||
|
description = "Interactive converter for beancount accounting with modular converters"
|
||||||
|
authors = [{name="Interitio", email="cona@thewisewolf.dev"}]
|
||||||
|
readme = "README.md"
|
||||||
|
version = "0.1.0a1"
|
||||||
|
dependencies = [
|
||||||
|
"pytz",
|
||||||
|
"platformdirs"
|
||||||
|
]
|
||||||
|
requires-python = ">= 3.10"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
debug = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
beanify = "beanify:run"
|
||||||
|
|
||||||
99
src/beanify/gui-main.py
Normal file
99
src/beanify/gui-main.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from tkinter import ttk
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from base.rules import DummyRuleInterface, JSONRuleInterface, RuleSet
|
||||||
|
from gui import MainWindow
|
||||||
|
from converters import converters_available, converter_factory
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Arguments and config needed?
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
- What converter to use
|
||||||
|
- The config file path
|
||||||
|
- Which rule interface to use (TODO)
|
||||||
|
- The rule interface path (or DB arg string)
|
||||||
|
- The file(s) to ingest from (multiple uses of --ingest)
|
||||||
|
- window.ingest_files(...) after setup
|
||||||
|
- The file(s) to output to (TODO, and maybe not needed)
|
||||||
|
|
||||||
|
Config File:
|
||||||
|
- converter
|
||||||
|
- rule interface
|
||||||
|
- rule interface path
|
||||||
|
|
||||||
|
Probably default accounts etc as well
|
||||||
|
Converter config inherits and can override these defaults
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
available = ', '.join((f"'{converter.qual_name()}'" for converter in converters_available))
|
||||||
|
parser.add_argument(
|
||||||
|
'--converter',
|
||||||
|
dest='converter',
|
||||||
|
help=f"Which converter to use. Available converters: {available}"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--config',
|
||||||
|
dest='config',
|
||||||
|
help="Ingest configuration file. Should be a toml file with a section for the selected converter."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--ingest',
|
||||||
|
dest='ingest',
|
||||||
|
help="File to ingest (must be in the correct format for the selected converter). May be provided multiple times to ingest multiple files.",
|
||||||
|
action='append'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--rules',
|
||||||
|
dest='rules',
|
||||||
|
default=None,
|
||||||
|
help="Path to JSON file with parsing rules. If not given, will use in-memory ruleset, with no saving."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
args = parser.parse_args()
|
||||||
|
config_path = args.config
|
||||||
|
converter_name = args.converter
|
||||||
|
ingest_files = args.ingest
|
||||||
|
rule_path = args.rules
|
||||||
|
|
||||||
|
with open(config_path, "rb") as f:
|
||||||
|
root_config = tomllib.load(f)
|
||||||
|
|
||||||
|
converter_cls = converter_factory(qual_name=converter_name) if converter_name else None
|
||||||
|
if converter_cls is None:
|
||||||
|
raise ValueError("Dynamic converter selection not supported yet.")
|
||||||
|
|
||||||
|
converter_config = converter_cls.config_type.from_dict(root_config[converter_cls.config_field])
|
||||||
|
converter = converter_cls(converter_config)
|
||||||
|
|
||||||
|
if rule_path:
|
||||||
|
rule_interface = JSONRuleInterface(converter_cls.qual_name(), path=rule_path)
|
||||||
|
else:
|
||||||
|
rule_interface = DummyRuleInterface(converter_cls.qual_name())
|
||||||
|
ruleset = RuleSet.load_from(rule_interface)
|
||||||
|
|
||||||
|
logger.debug("Finished configuration loading, creating GUI.")
|
||||||
|
|
||||||
|
window = MainWindow(root_config, converter, ruleset, initial_files=ingest_files, theme='clam')
|
||||||
|
# Note: Arc theme is more modern, might be nicer. But it's also bright. equilux is dark
|
||||||
|
# clam is actually useful
|
||||||
|
window.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
0
src/beanify/interfaces/pginterface.py
Normal file
0
src/beanify/interfaces/pginterface.py
Normal file
376
src/beanify/main-simple.py
Normal file
376
src/beanify/main-simple.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""
|
||||||
|
Tired of over-engineering, I just want a script which will actually convert my finances sometime this year.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
import datetime as dt
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from base import Amount, TXNFlag, Transaction, Record, TXNPosting
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
"""
|
||||||
|
TODO Note:
|
||||||
|
- Narration and payee fields should be part of the rule?
|
||||||
|
- Ability to add comments in non-batch mode, or change fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
'input',
|
||||||
|
help="CSV file with WISE transactions to read (note this needs to be in transaction format not statement format)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-o', '--output',
|
||||||
|
dest='output',
|
||||||
|
default=None,
|
||||||
|
help="Path to output file to append processed transactions. If not given will use stdout."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--expenserules',
|
||||||
|
dest='expense_rules',
|
||||||
|
default=None,
|
||||||
|
help="Path to CSV file with expense rules (Wise account name in first column, Bean expense account in second)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--incomerules',
|
||||||
|
dest='income_rules',
|
||||||
|
default=None,
|
||||||
|
help="Path to CSV file with income rules (Wise account name in first column, Bean income account in second)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_rulemap(path: str | None):
|
||||||
|
"""
|
||||||
|
Read the first two columns of a csv file into a dictionary.
|
||||||
|
|
||||||
|
Intended for reading expense and income rules into a map.
|
||||||
|
"""
|
||||||
|
rules: dict[str, str] = {} # data name -> account name
|
||||||
|
if path:
|
||||||
|
with open(path) as f:
|
||||||
|
f.readline()
|
||||||
|
reader = csv.reader(f)
|
||||||
|
for row in reader:
|
||||||
|
rules[row[0]] = row[1]
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
def add_rule(path: str, rule: tuple[str, str]):
|
||||||
|
"""
|
||||||
|
Add the provided rule to the csv file at the given path
|
||||||
|
"""
|
||||||
|
if path:
|
||||||
|
with open(path, 'a') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(rule)
|
||||||
|
|
||||||
|
class WiseRecordStatus(Enum):
|
||||||
|
COMPLETED = 'COMPLETED'
|
||||||
|
CANCELLED = 'CANCELLED'
|
||||||
|
|
||||||
|
class WiseRecordDirection(Enum):
|
||||||
|
OUT = 'OUT'
|
||||||
|
NEUTRAL = 'NEUTRAL'
|
||||||
|
IN = 'IN'
|
||||||
|
|
||||||
|
@dataclass(kw_only=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
|
||||||
|
|
||||||
|
@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 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=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
|
||||||
|
|
||||||
|
|
||||||
|
async def main_wise(record_path, expense_rule_path, income_rule_path, out_path: str | None = None):
|
||||||
|
expense_rules = read_rulemap(expense_rule_path)
|
||||||
|
income_rules = read_rulemap(income_rule_path)
|
||||||
|
|
||||||
|
transactions = []
|
||||||
|
skipped = []
|
||||||
|
|
||||||
|
with open(record_path) as f:
|
||||||
|
f.readline()
|
||||||
|
reader = csv.reader(f)
|
||||||
|
for row in reader:
|
||||||
|
# Convert csv row into a Record
|
||||||
|
record = WiseRecord.from_row(row)
|
||||||
|
|
||||||
|
if record.status is not WiseRecordStatus.COMPLETED:
|
||||||
|
logging.info(f"Skipping record with non-complete status:\n{record}")
|
||||||
|
skipped.append(record)
|
||||||
|
continue
|
||||||
|
|
||||||
|
match record.direction:
|
||||||
|
case WiseRecordDirection.OUT:
|
||||||
|
# Expense transaction
|
||||||
|
ruleset = expense_rules
|
||||||
|
source_account = wise_asset_account(record.source_currency)
|
||||||
|
if (target_account := ruleset.get(record.target_account)) is None:
|
||||||
|
# TODO: Factor this out properly
|
||||||
|
# TODO: Manually associated data can be saved against the row ID
|
||||||
|
userstr = input(
|
||||||
|
f"{record!r}\nExpense rule missing for target account '{record.target_account}'.\n"
|
||||||
|
"(a to Add Rule, s to Skip, or enter an expense account to use for this transaction): "
|
||||||
|
)
|
||||||
|
match userstr:
|
||||||
|
case 's':
|
||||||
|
print("Skipping record")
|
||||||
|
skipped.append(record)
|
||||||
|
continue
|
||||||
|
case 'a':
|
||||||
|
userstr = input(f"Enter expense account to associate to target account '{record.target_account}': ")
|
||||||
|
target_account = userstr.strip()
|
||||||
|
expense_rules[record.target_account] = target_account
|
||||||
|
if expense_rule_path:
|
||||||
|
add_rule(expense_rule_path, (record.target_account, target_account))
|
||||||
|
print(f"Associated expense account '{target_account}'")
|
||||||
|
case _:
|
||||||
|
target_account = userstr.strip()
|
||||||
|
|
||||||
|
# Build transaction
|
||||||
|
transaction = Transaction(
|
||||||
|
date=record.date,
|
||||||
|
flag=TXNFlag.INCOMPLETE,
|
||||||
|
narration="TODO",
|
||||||
|
payee=record.target_account,
|
||||||
|
comments=[record.raw],
|
||||||
|
postings=[]
|
||||||
|
)
|
||||||
|
case WiseRecordDirection.NEUTRAL:
|
||||||
|
# Neutral transaction from the asset account to itself
|
||||||
|
source_account = wise_asset_account(record.source_currency)
|
||||||
|
target_account = wise_asset_account(record.target_currency)
|
||||||
|
transaction = Transaction(
|
||||||
|
date=record.date,
|
||||||
|
flag=TXNFlag.COMPLETE,
|
||||||
|
narration="Currency Conversion",
|
||||||
|
comments=[record.raw],
|
||||||
|
postings=[]
|
||||||
|
)
|
||||||
|
case WiseRecordDirection.IN:
|
||||||
|
# Income transaction
|
||||||
|
ruleset = income_rules
|
||||||
|
target_account = wise_asset_account(record.target_currency)
|
||||||
|
if (source_account := ruleset.get(record.source_account)) is None:
|
||||||
|
# TODO: Factor this out properly
|
||||||
|
userstr = input(
|
||||||
|
f"{record!r}\nIncome rule missing for source account '{record.source_account}'.\n"
|
||||||
|
"(a to Add Rule, s to Skip, or enter an income account to use for this transaction): "
|
||||||
|
)
|
||||||
|
match userstr:
|
||||||
|
case 's':
|
||||||
|
print("Skipping record")
|
||||||
|
skipped.append(record)
|
||||||
|
continue
|
||||||
|
case 'a':
|
||||||
|
userstr = input(f"Enter income account to associate to source account '{record.source_account}': ")
|
||||||
|
source_account = userstr.strip()
|
||||||
|
income_rules[record.source_account] = source_account
|
||||||
|
if income_rule_path:
|
||||||
|
add_rule(income_rule_path, (record.source_account, source_account))
|
||||||
|
print(f"Associated income account '{source_account}'")
|
||||||
|
case _:
|
||||||
|
source_account = userstr.strip()
|
||||||
|
|
||||||
|
# Build transaction
|
||||||
|
transaction = Transaction(
|
||||||
|
date=record.date,
|
||||||
|
flag=TXNFlag.INCOMPLETE,
|
||||||
|
narration="TODO",
|
||||||
|
payee=record.source_account,
|
||||||
|
comments=[record.raw],
|
||||||
|
postings=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build postings from record
|
||||||
|
|
||||||
|
postings = []
|
||||||
|
# Source exchange amount, with cost by target total (if exchanging)
|
||||||
|
postings.append(TXNPosting(
|
||||||
|
account=source_account,
|
||||||
|
amount=-record.from_source_net,
|
||||||
|
total_cost=record.exchanged_amount if record.source_currency != record.target_currency else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Source fees:
|
||||||
|
if record.source_fee:
|
||||||
|
# Source fee asset posting
|
||||||
|
postings.append(TXNPosting(
|
||||||
|
account=source_account,
|
||||||
|
amount=-record.source_fee
|
||||||
|
))
|
||||||
|
# Source fee expense posting
|
||||||
|
postings.append(TXNPosting(
|
||||||
|
account=wise_fee_account(),
|
||||||
|
amount=record.source_fee
|
||||||
|
))
|
||||||
|
|
||||||
|
# Target fees:
|
||||||
|
if record.target_fee:
|
||||||
|
# Note asset posting included in exchange amount
|
||||||
|
# Target fee expense posting
|
||||||
|
postings.append(TXNPosting(
|
||||||
|
account=wise_fee_account(),
|
||||||
|
amount=record.target_fee
|
||||||
|
))
|
||||||
|
|
||||||
|
# Target amount not including fees
|
||||||
|
postings.append(TXNPosting(
|
||||||
|
account=target_account,
|
||||||
|
amount=record.to_target
|
||||||
|
))
|
||||||
|
|
||||||
|
transaction.postings = postings
|
||||||
|
if not transaction.check():
|
||||||
|
transaction.comments.append("WARNING: This transaction does not balance!")
|
||||||
|
|
||||||
|
# Finished building the transaction, add it to the list
|
||||||
|
logger.debug(
|
||||||
|
f"Converted Wise record {record!r} to Bean transaction {transaction!r}"
|
||||||
|
)
|
||||||
|
transactions.append(transaction)
|
||||||
|
|
||||||
|
# Print transactions or output to output file given
|
||||||
|
if out_path:
|
||||||
|
with open(out_path, 'a') as outf:
|
||||||
|
for transaction in transactions:
|
||||||
|
outf.write(str(transaction))
|
||||||
|
outf.write('\n\n')
|
||||||
|
# TODO: Add header and footer
|
||||||
|
print(f"Appended {len(transactions)} to {out_path}")
|
||||||
|
else:
|
||||||
|
for transaction in transactions:
|
||||||
|
print(str(transaction))
|
||||||
|
print('\n')
|
||||||
|
|
||||||
|
# Print skipped
|
||||||
|
# Option for skipped entries to be included as comments?
|
||||||
|
if skipped:
|
||||||
|
print(f"The following {len(skipped)} record(s) couldn't be processed:")
|
||||||
|
for record in skipped:
|
||||||
|
print(record.raw)
|
||||||
|
|
||||||
|
# TODO: Eventually, move these to the converter configuration
|
||||||
|
|
||||||
|
def wise_asset_account(currency):
|
||||||
|
return f"Assets:Adam:Wise:{currency}"
|
||||||
|
|
||||||
|
def wise_fee_account():
|
||||||
|
return "Expenses:Financial:Fees:Wise"
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
args = parser.parse_args()
|
||||||
|
record_path = args.input
|
||||||
|
expense_rule_path = args.expense_rules
|
||||||
|
income_rule_path = args.income_rules
|
||||||
|
out_path = args.output
|
||||||
|
await main_wise(record_path, expense_rule_path, income_rule_path, out_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user