From a1c2a999e449054d6641bbb633954e45fcd63f90 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 25 Jan 2021 01:14:28 +0100 Subject: Add plugins and importers from private config The importers are missing tests, because not having any specifications for the import formats means we must use real, private data as test inputs --- .flake8 | 16 + .gitignore | 33 ++ beancount_extras_kris7t/importers/__init__.py | 0 beancount_extras_kris7t/importers/hetzner_pdf.py | 136 +++++ .../importers/otpbank/__init__.py | 0 .../importers/otpbank/otpbank_csv.py | 370 ++++++++++++ .../importers/otpbank/otpbank_pdf.py | 187 ++++++ beancount_extras_kris7t/importers/rules.py | 134 +++++ .../importers/transferwise/__init__.py | 0 .../importers/transferwise/__main__.py | 11 + .../importers/transferwise/client.py | 236 ++++++++ .../importers/transferwise/transferwise_json.py | 377 +++++++++++++ beancount_extras_kris7t/importers/utils.py | 125 ++++ beancount_extras_kris7t/plugins/__init__.py | 0 beancount_extras_kris7t/plugins/closing_balance.py | 70 +++ .../plugins/closing_balance_test.py | 124 ++++ .../plugins/default_tolerance.py | 47 ++ .../plugins/default_tolerance_test.py | 104 ++++ .../plugins/selective_implicit_prices.py | 157 ++++++ .../plugins/selective_implicit_prices_test.py | 410 ++++++++++++++ beancount_extras_kris7t/plugins/templates.py | 144 +++++ beancount_extras_kris7t/plugins/templates_test.py | 326 +++++++++++ .../plugins/transfer_accounts.py | 188 ++++++ .../plugins/transfer_accounts_test.py | 628 +++++++++++++++++++++ mypy.ini | 14 + poetry.lock | 401 +++++++++++-- pyproject.toml | 10 +- 27 files changed, 4201 insertions(+), 47 deletions(-) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 beancount_extras_kris7t/importers/__init__.py create mode 100644 beancount_extras_kris7t/importers/hetzner_pdf.py create mode 100644 beancount_extras_kris7t/importers/otpbank/__init__.py create mode 100644 beancount_extras_kris7t/importers/otpbank/otpbank_csv.py create mode 100644 beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py create mode 100644 beancount_extras_kris7t/importers/rules.py create mode 100644 beancount_extras_kris7t/importers/transferwise/__init__.py create mode 100644 beancount_extras_kris7t/importers/transferwise/__main__.py create mode 100644 beancount_extras_kris7t/importers/transferwise/client.py create mode 100644 beancount_extras_kris7t/importers/transferwise/transferwise_json.py create mode 100644 beancount_extras_kris7t/importers/utils.py create mode 100644 beancount_extras_kris7t/plugins/__init__.py create mode 100644 beancount_extras_kris7t/plugins/closing_balance.py create mode 100644 beancount_extras_kris7t/plugins/closing_balance_test.py create mode 100644 beancount_extras_kris7t/plugins/default_tolerance.py create mode 100644 beancount_extras_kris7t/plugins/default_tolerance_test.py create mode 100644 beancount_extras_kris7t/plugins/selective_implicit_prices.py create mode 100644 beancount_extras_kris7t/plugins/selective_implicit_prices_test.py create mode 100644 beancount_extras_kris7t/plugins/templates.py create mode 100644 beancount_extras_kris7t/plugins/templates_test.py create mode 100644 beancount_extras_kris7t/plugins/transfer_accounts.py create mode 100644 beancount_extras_kris7t/plugins/transfer_accounts_test.py create mode 100644 mypy.ini diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d000b15 --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +exclude = + .git + __pycache__ + setup.py + build + dist + releases + .venv + .tox + .mypy_cache + .pytest_cache + .vscode + .github + beancount +max-line-length = 99 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbcfed5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +*.pyc + +# Packages +*.egg +/*.egg-info +/dist/* +build +_build +.cache +*.so + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +.pytest_cache + +.DS_Store +.idea/* +.python-version +.vscode/* + +/setup.cfg +MANIFEST.in +/setup.py +.mypy_cache + +.venv +/releases/* +pip-wheel-metadata +/poetry.toml diff --git a/beancount_extras_kris7t/importers/__init__.py b/beancount_extras_kris7t/importers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beancount_extras_kris7t/importers/hetzner_pdf.py b/beancount_extras_kris7t/importers/hetzner_pdf.py new file mode 100644 index 0000000..7eb89b6 --- /dev/null +++ b/beancount_extras_kris7t/importers/hetzner_pdf.py @@ -0,0 +1,136 @@ +''' +Importer for Hetzner PDF invoices. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import datetime as dt +import logging +import re +from typing import cast, Iterable, Optional + +from beancount.core import amount as am, data +from beancount.core.amount import Amount +from beancount.core.flags import FLAG_OKAY, FLAG_WARNING +from beancount.core.number import D +from beancount.ingest.cache import _FileMemo as FileMemo +from beancount.ingest.importer import ImporterProtocol + +from pdfminer.high_level import extract_pages +from pdfminer.layout import LTPage, LTTextContainer + +from beancount_extras_kris7t.importers.utils import MISSING_AMOUNT + +INVOICE_REGEX = re.compile( + r'.*Hetzner_(?P\d{4}-\d{2}-\d{2})_(?PR\d+)\.pdf$', re.IGNORECASE) +AMOUNT_REGEX = re.compile(r'Amount due: € (?P\d+(\.\d+)?)', re.IGNORECASE) +BALANCE_REGEX = re.compile( + 'The amount has been charged to the credit balance on your client credit account.', + re.IGNORECASE) +CARD_REGEX = re.compile( + 'The invoice amount will soon be debited from your credit card.', + re.IGNORECASE) +MIXED_REGEX = re.compile( + r'The amount of € (?P\d+(\.\d+)?) has been charged to the credit balance ' + + r'on your client credit account. The remaining amount of € (?P\d(\.\d+)?) ' + + 'will be debited by credit card in the next few days.', + re.IGNORECASE) + + +def _extract_match(pages: Iterable[LTPage], pattern: re.Pattern) -> Optional[re.Match]: + for page in pages: + for element in page: + if isinstance(element, LTTextContainer): + text = element.get_text().strip().replace('\n', ' ') + if match := pattern.match(text): + return match + return None + + +class Importer(ImporterProtocol): + ''' + Importer for Hetzner PDF invoices. + ''' + + _log: logging.Logger + _liability: str + _credit_balance: str + _expense: str + + def __init__(self, liability: str, credit_balance: str, expense: str): + self._log = logging.getLogger(type(self).__qualname__) + self._liability = liability + self._credit_balance = credit_balance + self._expense = expense + + def identify(self, file: FileMemo) -> bool: + return INVOICE_REGEX.match(file.name) is not None + + def file_name(self, file: FileMemo) -> str: + if match := INVOICE_REGEX.match(file.name): + number = match.group('number') + return f'Hetzner_{number}.pdf' + else: + raise RuntimeError(f'Not an invoice: {file.name}') + + def file_account(self, file: FileMemo) -> str: + if INVOICE_REGEX.match(file.name) is None: + raise RuntimeError(f'Not an invoice: {file.name}') + else: + return self._liability + + def file_date(self, file: FileMemo) -> dt.date: + if match := INVOICE_REGEX.match(file.name): + date_str = match.group('date') + return dt.datetime.strptime(date_str, '%Y-%m-%d').date() + else: + raise RuntimeError(f'Not an invoice: {file.name}') + + def extract(self, file: FileMemo) -> data.Entries: + if match := INVOICE_REGEX.match(file.name): + invoice_number = match.group('number') + else: + self._log.warn('Not an invoice: %s', file.name) + return [] + date = self.file_date(file) + pages = [page for page in cast(Iterable[LTPage], extract_pages(file.name))] + if match := _extract_match(pages, AMOUNT_REGEX): + number = D(match.group('amount')) + assert number is not None + amount = Amount(number, 'EUR') + else: + self._log.warn('Not amount found in %s', file.name) + return [] + postings = [] + flag = FLAG_OKAY + if _extract_match(pages, BALANCE_REGEX) is not None: + postings.append( + data.Posting(self._credit_balance, -amount, None, None, None, None)) + elif _extract_match(pages, CARD_REGEX) is not None: + postings.append( + data.Posting(self._liability, -amount, None, None, None, None)) + elif match := _extract_match(pages, MIXED_REGEX): + balance_number = D(match.group('balance')) + assert balance_number is not None + balance_amount = Amount(balance_number, 'EUR') + postings.append( + data.Posting(self._credit_balance, -balance_amount, None, None, None, None)) + card_number = D(match.group('card')) + assert card_number is not None + card_amount = Amount(card_number, 'EUR') + postings.append( + data.Posting(self._liability, -card_amount, None, None, None, None)) + if am.add(balance_amount, card_amount) != amount: + self._log.warn('Payments do not cover total amount in %s', file.name) + flag = FLAG_WARNING + else: + self._log.warn('Unknown payment method in %s', file.name) + flag = FLAG_WARNING + if flag == FLAG_OKAY: + amount = MISSING_AMOUNT + postings.append( + data.Posting(self._expense, amount, None, None, None, None)) + return [ + data.Transaction(data.new_metadata(file.name, 0), date, flag, 'Hetzner', 'Invoice', + set(), {f'hetzner_{invoice_number}'}, postings) + ] diff --git a/beancount_extras_kris7t/importers/otpbank/__init__.py b/beancount_extras_kris7t/importers/otpbank/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beancount_extras_kris7t/importers/otpbank/otpbank_csv.py b/beancount_extras_kris7t/importers/otpbank/otpbank_csv.py new file mode 100644 index 0000000..9e12ec3 --- /dev/null +++ b/beancount_extras_kris7t/importers/otpbank/otpbank_csv.py @@ -0,0 +1,370 @@ +''' +Importer for OTP Bank CSV transaction history. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import csv +import datetime as dt +from decimal import Decimal +import logging +from typing import Callable, Dict, Iterable, List, NamedTuple, Optional +import re +from os import path + +import beancount.core.amount as am +from beancount.core.amount import Amount +from beancount.core import data +from beancount.core.number import ZERO +from beancount.ingest.cache import _FileMemo as FileMemo +from beancount.ingest.importer import ImporterProtocol + +from beancount_extras_kris7t.importers import utils +from beancount_extras_kris7t.importers.utils import COMMENT_META, InvalidEntry, PAYEE_META, \ + Posting +from beancount_extras_kris7t.plugins.transfer_accounts import TRANSFER_ACCOUNT_META, \ + TRANSFER_DATE_META + +OTP_BANK = 'OTP Bank' +BOOKING_DATE_META = 'booking-date' +ENTRY_TYPE_META = 'otpbank-entry-type' +OTPBANK_CSV_TAG = 'otpbank-csv' +PAYPASS_TAG = 'paypass' +SIMPLE_TAG = 'processor-simple' +CARD_REGEX = re.compile( + r'^(?P\d{4}\.\d{2}\.\d{2})\s+(?P\d+)') +PAYPASS_REGEX = re.compile(r'-ÉRINT(Ő|\?)|PPASS$', re.IGNORECASE) +SIMPLE_REGEX = re.compile(r'-SIMPLE$', re.IGNORECASE) +SMS_REGEX = re.compile( + r'^(?P\d{4}\.\d{2}\.\d{2})\s+\((?P\d+) DB SMS\)', + re.IGNORECASE) + + +def _parse_date(date_str: str) -> dt.date: + return utils.parse_date(date_str, '%Y%m%d') + + +def _parse_card_date(date_str: str) -> dt.date: + return utils.parse_date(date_str, '%Y.%m.%d') + + +def _parse_number(amount_str: str) -> Decimal: + cleaned_str = amount_str.replace('.', '').replace(',', '.') + return utils.parse_number(cleaned_str) + + +def _validate_cd(amount: Amount, cd: str) -> None: + if cd == 'T': + if amount.number >= ZERO: + raise InvalidEntry(f'Invalid debit amount: {amount}') + elif cd == 'J': + if amount.number <= ZERO: + raise InvalidEntry(f'Invalid credit amount: {amount}') + else: + raise InvalidEntry(f'Invalid credit/debit type: {cd}') + + +class Conversion(NamedTuple): + foreign_amount: Amount + foreign_rate: Amount + conversion_fee: Optional[Posting] + + +class Card(NamedTuple): + card_account: str + card_date: dt.date + + +class Row(utils.Row): + account_name: str + cd: str + native_amount: Amount + booking_date: dt.date + value_date: dt.date + _raw_payee: Optional[str] + _raw_comment: str + _conversion: Optional[Conversion] + _card: Optional[Card] + + def __init__(self, file_name: str, index: int, row: List[str], accounts: Dict[str, str]): + account_number, cd, amount_str, currency, booking_date_str, \ + value_date_str, _, _, payee, comment1, comment2, comment3, entry_type, _, _ = row + comment = f'{comment1}{comment2}{comment3}'.strip() + super().__init__(file_name, index, entry_type, payee, comment) + if account_name := accounts.get(account_number, None): + self.account_name = account_name + else: + raise InvalidEntry(f'Unknown account number {account_number}') + self.cd = cd + self.native_amount = Amount(_parse_number(amount_str), currency) + _validate_cd(self.native_amount, self.cd) + self.booking_date = _parse_date(booking_date_str) + self.value_date = _parse_date(value_date_str) + self.tags.add(OTPBANK_CSV_TAG) + self._raw_payee = payee + self._raw_comment = comment + self._conversion = None + self._card = None + + @property + def conversion(self) -> Optional[Conversion]: + return self._conversion + + @conversion.setter + def conversion(self, conversion: Optional[Conversion]) -> None: + if self._conversion: + raise InvalidEntry( + f'Conversion {self._conversion} was already set for row ' + + f' when trying to set it to {conversion}') + self._conversion = conversion + + @property + def card(self) -> Optional[Card]: + return self._card + + @card.setter + def card(self, card: Optional[Card]) -> None: + if self._card: + raise InvalidEntry( + f'Card {self._card} was already set for row ' + + f' when trying to set it to {card}') + self._card = card + + @property + def transacted_amount(self) -> Amount: + if self.conversion: + return self.conversion.foreign_amount + return self.native_amount + + @property + def _extended_meta(self) -> data.Meta: + meta = dict(self.meta) + if self.entry_type: + meta[ENTRY_TYPE_META] = self.entry_type + if self.booking_date != self.value_date: + meta[BOOKING_DATE_META] = self.booking_date + if self._raw_payee: + meta[PAYEE_META] = self._raw_payee + if self._raw_comment: + meta[COMMENT_META] = self._raw_comment + return meta + + @property + def _card_meta(self) -> Optional[data.Meta]: + if self._card: + card_meta: data.Meta = { + TRANSFER_ACCOUNT_META: self._card.card_account + } + if self._card.card_date != self.value_date: + card_meta[TRANSFER_DATE_META] = self._card.card_date + return card_meta + else: + return None + + def _to_transaction(self) -> data.Transaction: + if not self.postings: + raise InvalidEntry('No postings were extracted from this entry') + if self.payee == '': + payee = None + else: + payee = self.payee + if self.comment == '' or self.comment == self.payee: + if payee: + desc = '' + else: + desc = self.entry_type or '' + else: + desc = self.comment + if self._conversion: + foreign_rate = self._conversion.foreign_rate + conversion_fee = self._conversion.conversion_fee + else: + foreign_rate = None + conversion_fee = None + meta = self._extended_meta + card_meta = self._card_meta + postings = [ + data.Posting(self.account_name, self.native_amount, None, None, None, None) + ] + if len(self.postings) == 1 and not foreign_rate: + account, amount = self.postings[0] + postings.append( + data.Posting(account, utils.MISSING_AMOUNT, None, None, None, card_meta)) + else: + for account, amount in self.postings: + postings.append( + data.Posting(account, -amount, None, foreign_rate, None, card_meta)) + if conversion_fee and conversion_fee.amount.number != ZERO: + account, amount = conversion_fee + postings.append( + data.Posting(account, amount, None, None, None, None)) + return data.Transaction( + meta, self.value_date, self.flag, payee, desc, self.tags, self.links, postings) + + +Extractor = Callable[[Row], None] + + +def extract_foreign_currencies( + currencies: Iterable[str], conversion_fees_account: str) -> Extractor: + currencies_joined = '|'.join(currencies) + currency_regex = re.compile( + r'(?P\d+(,\d+)?)(?P' + currencies_joined + + r')\s+(?P\d+(,\d+)?)$', re.IGNORECASE) + + def do_extract(row: Row) -> None: + if currency_match := currency_regex.search(row.comment): + foreign_amount_d = _parse_number(currency_match.group('amount')) + if row.cd == 'T': + foreign_amount_d *= -1 + foreign_currency = currency_match.group('currency') + foreign_amount = Amount( + foreign_amount_d, foreign_currency.upper()) + foreign_rate_d = _parse_number(currency_match.group('rate')) + foreign_rate = Amount( + foreign_rate_d, row.native_amount.currency) + converted_amount = am.mul(foreign_rate, foreign_amount_d) + conversion_fee_amount = am.sub( + converted_amount, row.native_amount) + conversion_fee = Posting( + conversion_fees_account, conversion_fee_amount) + row.conversion = Conversion( + foreign_amount, foreign_rate, conversion_fee) + row.comment = row.comment[:currency_match.start()].strip() + return do_extract + + +def extract_cards(card_accounts: Dict[str, str]) -> Extractor: + def do_extract(row: Row) -> None: + if row.entry_type.lower() not in [ + 'vásárlás kártyával', + 'bankkártyával kapcs. díj', + ]: + return + if card_match := CARD_REGEX.search(row.comment): + card_number = card_match.group('card_number') + if card_number not in card_accounts: + raise InvalidEntry(f'No account for card {card_number}') + card_account = card_accounts[card_number] + card_date = _parse_card_date(card_match.group('date')) + row.card = Card(card_account, card_date) + row.comment = row.comment[card_match.end():].strip() + else: + raise InvalidEntry( + f'Cannot extract card information from: {row.comment}') + if paypass_match := PAYPASS_REGEX.search(row.comment): + row.comment = row.comment[:paypass_match.start()].strip() + row.tags.add(PAYPASS_TAG) + elif simple_match := PAYPASS_REGEX.search(row.comment): + row.comment = row.comment[:simple_match.start()].strip() + row.tags.add(SIMPLE_TAG) + return do_extract + + +def extract_sms(sms: str, sms_liability: str, sms_expense: str) -> Extractor: + def do_extract(row: Row) -> None: + if row.entry_type != 'OTPdirekt ÜZENETDÍJ': + return + if sms_match := SMS_REGEX.search(row.comment): + card_account = sms_liability + card_date = _parse_card_date(sms_match.group('date')) + row.card = Card(card_account, card_date) + sms_amount_d = _parse_number(sms_match.group('count')) + foreign_amount = -Amount(sms_amount_d, sms) + foreign_rate = am.div(-row.native_amount, sms_amount_d) + row.conversion = Conversion( + foreign_amount, foreign_rate, None) + row.comment = 'SMS díj' + row.payee = OTP_BANK + row.assign_to_account(sms_expense) + else: + raise InvalidEntry( + f'Cannot parse SMS transaction: {row.comment}') + return do_extract + + +class Importer(ImporterProtocol): + ''' + Importer for OTP Bank CSV transaction history. + ''' + + _log: logging.Logger + _accounts: Dict[str, str] + _extracts: List[Extractor] + + def __init__(self, + accounts: Dict[str, str], + extractors: List[Extractor]): + self._log = logging.getLogger(type(self).__qualname__) + self._accounts = {number.replace('-', ''): name for number, name in accounts.items()} + self._extractors = extractors + + def identify(self, file: FileMemo) -> bool: + _, extension = path.splitext(file.name) + if extension.lower() != '.csv': + return False + return self._find_account(file) is not None + + def _find_account(self, file: FileMemo) -> Optional[str]: + head = file.head().strip().split('\n')[0] + if head.count(';') != 14: + return None + for account_number, account_name in self._accounts.items(): + if head.startswith(f'"{account_number}"'): + return account_name + return None + + def file_name(self, file: FileMemo) -> str: + return 'otpbank.csv' + + def file_account(self, file: FileMemo) -> str: + account_name = self._find_account(file) + if not account_name: + raise RuntimeError(f'Invalid account number in {file.name}') + return account_name + + def file_date(self, file: FileMemo) -> Optional[dt.date]: + ''' + Files account statements according to the booking date of the last + transaction. + ''' + date = None + with open(file.name, 'r') as csv_file: + for row in csv.reader(csv_file, delimiter=';'): + date_str = row[4] + try: + date = _parse_date(date_str) + except InvalidEntry as exc: + self._log.error( + 'Invalid entry in %s when looking for filing date', + file.name, exc_info=exc) + return None + return date + + def extract(self, file: FileMemo) -> data.Entries: + file_name = file.name + entries: data.Entries = [] + with open(file_name, 'r') as csv_file: + last_date: Optional[dt.date] = None + last_date_str = "" + count_within_date = 1 + for index, row_str in enumerate(csv.reader(csv_file, delimiter=';')): + try: + row = Row(file_name, index, row_str, self._accounts) + if last_date != row.booking_date: + last_date = row.booking_date + last_date_str = last_date.strftime('%Y-%m-%d') + count_within_date = 1 + else: + count_within_date += 1 + row.links.add(f'otpbank_{last_date_str}_{count_within_date:03}') + self._run_row_extractors(row) + entries.append(row._to_transaction()) + except InvalidEntry as exc: + self._log.warning( + 'Skipping invalid entry %d of %s', + index, file_name, exc_info=exc) + return entries + + def _run_row_extractors(self, row: Row): + utils.run_row_extractors(row, self._extractors) diff --git a/beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py b/beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py new file mode 100644 index 0000000..c2de559 --- /dev/null +++ b/beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py @@ -0,0 +1,187 @@ +''' +Importer for OTP Bank PDF account statements. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from decimal import Decimal +import datetime as dt +import logging +import re +from typing import cast, Dict, Iterable, List, NamedTuple, Optional + +from beancount.core import data +from beancount.core.amount import Amount +from beancount.core.number import D +from beancount.ingest.cache import _FileMemo as FileMemo +from beancount.ingest.importer import ImporterProtocol + +from pdfminer.high_level import extract_pages +from pdfminer.layout import LTPage, LTTextContainer + + +STATEMENT_NAME_REGEX = re.compile( + r'.*(Banksz[a ]mla|(Ertekpapirszamla|rt `kpap ­rsz mla)_)kivonat_(?P\d[\d-]*\d)_' + + r'(?P\d{4}\.\d{2}\.\d{2})(_\d+)?\.pdf') +CHECKING_ACCOUNT_STATEMENT_NAME_REGEX = re.compile( + r'.*Banksz[a ]mlakivonat_(?P\d[\d-]*\d)_.+') +INVESTMENT_ACCOUNT_STATEMENT_NAME_REGEX = re.compile( + r'.*(Ertekpapirszamla|rt `kpap ­rsz mla)_kivonat_(?P\d[\d-]*\d)_.+') +ACCOUNT_NUMBER_REGEX = re.compile(r'SZÁMLASZÁM: (?P\d[\d-]*\d)') +CURRENCY_REGEX = re.compile(r'DEVIZANEM: (?P[A-Z]+)$') + + +class Total(NamedTuple): + date: dt.date + units: Decimal + + +def _append_total(entries: data.Entries, + meta: data.Meta, + account: str, + currency: str, + total: Optional[Total], + delta: dt.timedelta = dt.timedelta(days=0)) -> None: + if not total: + return + date, units = total + amount = Amount(units, currency) + balance = data.Balance(meta, date + delta, account, amount, None, None) + entries.append(balance) + + +def _find_label_y(page: LTPage, label: str) -> Optional[int]: + for element in page: + if isinstance(element, LTTextContainer) and element.get_text().strip() == label: + return element.bbox[1] + return None + + +def _find_match(page: LTPage, pattern: re.Pattern) -> Optional[re.Match]: + for element in page: + if isinstance(element, LTTextContainer): + text = element.get_text().strip() + if match := pattern.search(text): + return match + return None + + +class Importer(ImporterProtocol): + ''' + Importer for OTP Bank PDF account statements. + ''' + + _log: logging.Logger + _accounts: Dict[str, str] + _extract_opening: bool + + def __init__(self, accounts: Dict[str, str], extract_opening: bool = False): + self._log = logging.getLogger(type(self).__qualname__) + self._accounts = accounts + self._extract_opening = extract_opening + + def identify(self, file: FileMemo) -> bool: + if match := STATEMENT_NAME_REGEX.match(file.name): + return match.group('account') in self._accounts + else: + return False + + def file_name(self, file: FileMemo) -> str: + if match := CHECKING_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name): + account_number = match.group('account') + return f'Bankszámlakivonat_{account_number}.pdf' + elif match := INVESTMENT_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name): + account_number = match.group('account') + return f'Értékpapírszámla_kivonat_{account_number}.pdf' + else: + raise RuntimeError(f'Not an account statement: {file.name}') + + def file_account(self, file: FileMemo) -> str: + if match := STATEMENT_NAME_REGEX.match(file.name): + account_number = match.group('account') + return self._accounts[account_number] + else: + raise RuntimeError(f'Not an account statement: {file.name}') + + def file_date(self, file: FileMemo) -> dt.date: + if match := STATEMENT_NAME_REGEX.match(file.name): + date_str = match.group('date') + return dt.datetime.strptime(date_str, '%Y.%m.%d').date() + else: + raise RuntimeError(f'Not an account statement: {file.name}') + + def extract(self, file: FileMemo) -> data.Entries: + if not CHECKING_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name): + return [] + pages = [page for page in cast(Iterable[LTPage], extract_pages(file.name))] + if not pages: + return [] + entries: data.Entries = [] + meta = data.new_metadata(file.name, 1) + if account_match := _find_match(pages[0], ACCOUNT_NUMBER_REGEX): + account_name = self._accounts[account_match.group('account')] + else: + self._log.warning('No account number in %s', file.name) + account_name = self.file_account(file) + if currency_match := _find_match(pages[0], CURRENCY_REGEX): + currency = currency_match.group('currency') + else: + self._log.warning('No currency number in %s', file.name) + currency = 'HUF' + if self._extract_opening: + opening_balance = self._extract_total_from_page(pages[0], 'NYITÓ EGYENLEG') + _append_total(entries, meta, account_name, currency, opening_balance) + closing_balance = self._extract_total(pages, 'ZÁRÓ EGYENLEG') + _append_total(entries, meta, account_name, currency, closing_balance, dt.timedelta(days=1)) + return entries + + def _extract_total(self, pages: List[LTPage], label: str) -> Optional[Total]: + for page in pages: + if total := self._extract_total_from_page(page, label): + return total + self._log.error('%s was not found in the pdf file', label) + return None + + def _extract_total_from_page(self, page: LTPage, label: str) -> Optional[Total]: + if total_y := _find_label_y(page, label): + return self._extract_total_by_y(page, total_y) + return None + + def _extract_total_by_y(self, page: LTPage, total_y: int) -> Optional[Total]: + date: Optional[dt.date] = None + units: Optional[Decimal] = None + for element in page: + if isinstance(element, LTTextContainer): + x, y, x2, _ = element.bbox + if abs(y - total_y) > 0.5: + continue + elif abs(x - 34) <= 0.5: + date_str = element.get_text().strip() + if date is not None: + self._log.warning( + 'Found date %s, but date was already set to %s', + date_str, + date) + continue + try: + date = dt.datetime.strptime(date_str, '%y.%m.%d').date() + except ValueError as exc: + self._log.warning(f'Invalid date {date_str}', exc_info=exc) + elif abs(x2 - 572.68) <= 0.5: + units_str = element.get_text().strip().replace('.', '').replace(',', '.') + if units is not None: + self._log.warning( + 'Found units %s, but units were already set to %s', + units_str, + units) + try: + units = D(units_str) + except ValueError as exc: + self._log.error('Invalid units %s', units_str, exc_info=exc) + if not date: + self._log.error('Date was not found at y=%d', total_y) + return None + if not units: + self._log.error('Units were not found at y=%d', total_y) + return None + return Total(date, units) diff --git a/beancount_extras_kris7t/importers/rules.py b/beancount_extras_kris7t/importers/rules.py new file mode 100644 index 0000000..3890f24 --- /dev/null +++ b/beancount_extras_kris7t/importers/rules.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +from typing import cast, Dict, List, NamedTuple, Optional, Tuple, Union +import re + +from beancount.core.amount import Amount + +from beancount_extras_kris7t.importers.utils import Extractor, Row + +WILDCARD = re.compile('.*') + + +class When(NamedTuple): + payee: re.Pattern + text: re.Pattern + amount: Optional[Amount] + + +def _compile_regex(s: str) -> re.Pattern: + return re.compile(s, re.IGNORECASE) + + +def when(payee: Optional[Union[re.Pattern, str]] = None, + text: Optional[Union[re.Pattern, str]] = None, + amount: Optional[Amount] = None) -> When: + if not payee and not text: + raise TypeError('at least one of payee and desc must be provided') + if isinstance(payee, str): + payee_regex = _compile_regex(payee) + else: + payee_regex = payee or WILDCARD + if isinstance(text, str): + text_regex = _compile_regex(text) + else: + text_regex = text or WILDCARD + return When(payee_regex, text_regex, amount) + + +Condition = Union[str, re.Pattern, When] + + +def _compile_condition(cond: Condition) -> When: + if isinstance(cond, When): + return cond + else: + return when(text=cond) + + +class let(NamedTuple): + payee: Optional[str] = None + desc: Optional[str] = None + account: Optional[str] = None + flag: Optional[str] = None + tag: Optional[str] = None + + +Action = Union[str, + Tuple[str, str], + Tuple[str, str, str], + Tuple[str, str, str, str], + let] + + +def _compile_action(action: Action) -> let: + if isinstance(action, str): + return let(account=action) + if isinstance(action, let): + return action + elif isinstance(action, tuple): + if len(action) == 2: + payee, account = cast(Tuple[str, str], action) + return let(payee=payee, account=account) + elif len(action) == 3: + payee, desc, account = cast(Tuple[str, str, str], action) + return let(payee, desc, account) + else: + flag, payee, desc, account = cast(Tuple[str, str, str, str], action) + return let(payee, desc, account, flag) + else: + raise ValueError(f'Unknown action: {action}') + + +Rules = Dict[Condition, Action] +CompiledRules = List[Tuple[When, let]] + + +def _compile_rules(rules: Rules) -> CompiledRules: + return [(_compile_condition(cond), _compile_action(action)) + for cond, action in rules.items()] + + +def _rule_condition_matches(cond: When, row: Row) -> bool: + if row.payee: + payee_valid = cond.payee.search(row.payee) is not None + else: + payee_valid = cond.payee == WILDCARD + if cond.text == WILDCARD: + text_valid = True + else: + characteristics: List[str] = [] + if row.entry_type: + characteristics.append(row.entry_type) + if row.payee: + characteristics.append(row.payee) + if row.comment: + characteristics.append(row.comment) + row_str = ' '.join(characteristics) + text_valid = cond.text.search(row_str) is not None + amount_valid = not cond.amount or row.transacted_amount == cond.amount + return payee_valid and text_valid and amount_valid + + +def extract_rules(input_rules: Rules) -> Extractor: + compiled_rules = _compile_rules(input_rules) + + def do_extract(row: Row) -> None: + for cond, (payee, desc, account, flag, tag) in compiled_rules: + if not _rule_condition_matches(cond, row): + continue + if payee is not None: + if row.payee == row.comment: + row.comment = '' + row.payee = payee + if desc is not None: + row.comment = desc + if account is not None: + row.assign_to_account(account) + if flag is not None: + row.flag = flag + if tag is not None: + row.tags.add(tag) + if row.postings: + return + return do_extract diff --git a/beancount_extras_kris7t/importers/transferwise/__init__.py b/beancount_extras_kris7t/importers/transferwise/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beancount_extras_kris7t/importers/transferwise/__main__.py b/beancount_extras_kris7t/importers/transferwise/__main__.py new file mode 100644 index 0000000..e25580d --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/__main__.py @@ -0,0 +1,11 @@ +''' +Importer for Transferwise API transaction history. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from importers.transferwise.client import main + + +if __name__ == '__main__': + main() diff --git a/beancount_extras_kris7t/importers/transferwise/client.py b/beancount_extras_kris7t/importers/transferwise/client.py new file mode 100644 index 0000000..a4b6629 --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/client.py @@ -0,0 +1,236 @@ +''' +Importer for Transferwise API transaction history from the command line. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import datetime as dt +import logging +import os +from typing import Any, Dict, Optional, Tuple, Set + +import beancount +from beancount.core import data + +from beancount_extras_kris7t.importers.transferwise.transferwise_json import Accounts, \ + DATE_FORMAT, Importer + +LOG = logging.getLogger('importers.transferwise.client') + + +def _parse_date_arg(date_str: str) -> dt.date: + return dt.datetime.strptime(date_str, '%Y-%m-%d').date() + + +def _import_config(config_path: str) -> Importer: + import runpy + + config = runpy.run_path(config_path) + importer = config['TRANSFERWISE_CONFIG'] # type: ignore + if isinstance(importer, Importer): + LOG.info('Loaded configuration from %s', config_path) + return importer + else: + raise ValueError(f'Invalid configuration: {config_path}') + + +def _get_reference(transaction: data.Transaction) -> Optional[str]: + for link in transaction.links: + if link.startswith('transferwise_'): + return link[13:] + return None + + +def _get_last_transaction_date(ledger_path: str, skip_references: Set[str]) -> Optional[dt.date]: + from beancount.parser import parser + + LOG.info('Checking %s for already imported transactions', ledger_path) + entries, _, _ = parser.parse_file(ledger_path) + date: Optional[dt.date] = None + skip: Set[str] = set() + for entry in entries: + if isinstance(entry, data.Transaction): + reference = _get_reference(entry) + if not reference: + continue + if date is None or date < entry.date: + date = entry.date + skip.clear() + if date == entry.date: + skip.add(reference) + skip_references.update(skip) + return date + + +def _get_date_range(from_date: Optional[dt.date], + to_date: dt.date, + ledger_path: Optional[str]) -> Tuple[dt.date, dt.date, Set[str]]: + skip_references = set() + if not from_date and ledger_path: + from_date = _get_last_transaction_date(ledger_path, skip_references) + if not from_date: + from_date = to_date - dt.timedelta(days=365) + LOG.info('Fetching transactions from %s to %s', from_date, to_date) + return from_date, to_date, skip_references + + +def _get_secrets(importer: Importer, + api_key: Optional[str], + proxy_uri: Optional[str]) -> Tuple[str, Optional[str]]: + import urllib.parse + + if proxy_uri: + uri_parts = urllib.parse.urlsplit(proxy_uri) + else: + uri_parts = None + if api_key and (not uri_parts or not uri_parts.username or uri_parts.password): + return api_key, proxy_uri + + from contextlib import closing + + import secretstorage + + with closing(secretstorage.dbus_init()) as connection: + collection = secretstorage.get_default_collection(connection) + if not api_key: + items = collection.search_items({ + 'profile_id': str(importer.profile_id), + 'borderless_account_id': str(importer.borderless_account_id), + 'xdg:schema': 'com.marussy.beancount.importer.TransferwiseAPIKey', + }) + item = next(items, None) + if not item: + raise ValueError('No API key found in SecretService') + LOG.info('Found API key secret "%s" from SecretService', item.get_label()) + api_key = item.get_secret().decode('utf-8') + if uri_parts and uri_parts.username and not uri_parts.password: + host = uri_parts.hostname or uri_parts.netloc + items = collection.search_items({ + 'host': host, + 'port': str(uri_parts.port or 1080), + 'user': uri_parts.username, + 'xdg:schema': 'org.freedesktop.Secret.Generic', + }) + item = next(items, None) + if item: + LOG.info('Found proxy password secret "%s" from SecretService', item.get_label()) + password = urllib.parse.quote_from_bytes(item.get_secret()) + uri = f'{uri_parts.scheme}://{uri_parts.username}:{password}@{host}' + if uri_parts.port: + proxy_uri = f'{uri}:{uri_parts.port}' + else: + proxy_uri = uri + else: + LOG.info('No proxy password secret was found in SecretService') + assert api_key # Make pyright happy + return api_key, proxy_uri + + +def _fetch_statements(importer: Importer, + from_date: dt.date, + to_date: dt.date, + api_key: str, + proxy_uri: Optional[str]) -> Dict[str, Any]: + import json + + import requests + + now = dt.datetime.utcnow().time() + from_time_str = dt.datetime.combine(from_date, dt.datetime.min.time()).strftime(DATE_FORMAT) + to_time_str = dt.datetime.combine(to_date, now).strftime(DATE_FORMAT) + uri_prefix = f'https://api.transferwise.com/v3/profiles/{importer.profile_id}/' + \ + f'borderless-accounts/{importer.borderless_account_id}/statement.json' + \ + f'?intervalStart={from_time_str}&intervalEnd={to_time_str}&type=COMPACT¤cy=' + headers = { + 'User-Agent': f'Beancount {beancount.__version__} Transferwise importer {__copyright__}', + 'Authorization': f'Bearer {api_key}', + } + proxy_dict: Dict[str, str] = {} + if proxy_uri: + proxy_dict['https'] = proxy_uri + statements: Dict[str, Any] = {} + for currency in importer.currencies: + result = requests.get(uri_prefix + currency, headers=headers, proxies=proxy_dict) + if result.status_code != 200: + LOG.error( + 'Fetcing %s statement failed with HTTP status code %d: %s', + currency, + result.status_code, + result.text) + else: + try: + statement = json.loads(result.text) + except json.JSONDecodeError as exc: + LOG.error('Failed to decode %s statement', currency, exc_info=exc) + else: + statements[currency] = statement + LOG.info('Fetched %s statement', currency) + return statements + + +def _print_statements(from_date: dt.date, + to_date: dt.date, + entries: data.Entries) -> None: + from beancount.parser import printer + print(f'*** Transferwise from {from_date} to {to_date}', flush=True) + printer.print_entries(entries) + + +def _determine_path(dir: str, currency: str, to_date: dt.date) -> str: + date_str = to_date.strftime('%Y-%m-%d') + simple_path = os.path.join(dir, f'{date_str}.transferwise_{currency}.json') + if not os.path.exists(simple_path): + return simple_path + for i in range(2, 10): + path = os.path.join(dir, f'{date_str}.transferwise_{currency}_{i}.json') + if not os.path.exists(path): + return path + raise ValueError(f'Cannot find unused name for {simple_path}') + + +def _archive_statements(documents_path: str, + to_date: dt.date, + accounts: Accounts, + statements: Dict[str, Any]): + import json + + from beancount.core.account import sep + + for currency, statement in statements.items(): + dir = os.path.join(documents_path, *accounts.get_borderless_account(currency).split(sep)) + os.makedirs(dir, exist_ok=True) + path = _determine_path(dir, currency, to_date) + with open(path, 'w') as file: + json.dump(statement, file, indent=2) + LOG.info('Saved %s statement as %s', currency, path) + + +def main(): + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument('--verbose', '-v', action='store_true') + parser.add_argument('--from-date', '-f', required=False, type=_parse_date_arg) + parser.add_argument('--to-date', '-t', default=dt.date.today(), type=_parse_date_arg) + parser.add_argument('--api-key', '-k', required=False, type=str) + parser.add_argument('--proxy', '-p', required=False, type=str) + parser.add_argument('--archive', '-a', required=False, type=str) + parser.add_argument('config', nargs=1) + parser.add_argument('ledger', nargs='?') + args = parser.parse_args() + if args.verbose: + log_level = logging.INFO + else: + log_level = logging.WARN + logging.basicConfig(level=log_level) + importer = _import_config(args.config[0]) + from_date, to_date, skip = _get_date_range(args.from_date, args.to_date, args.ledger) + if skip: + LOG.info('Skipping %s', skip) + api_key, proxy_uri = _get_secrets(importer, args.api_key, args.proxy) + statements = _fetch_statements(importer, from_date, to_date, api_key, proxy_uri) + if args.archive: + _archive_statements(args.archive, to_date, importer.accounts, statements) + entries = importer.extract_objects(statements.values(), skip) + if entries: + _print_statements(from_date, to_date, entries) diff --git a/beancount_extras_kris7t/importers/transferwise/transferwise_json.py b/beancount_extras_kris7t/importers/transferwise/transferwise_json.py new file mode 100644 index 0000000..c42de90 --- /dev/null +++ b/beancount_extras_kris7t/importers/transferwise/transferwise_json.py @@ -0,0 +1,377 @@ +''' +Importer for Transferwise API transaction history. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from collections import defaultdict +import datetime as dt +import json +import logging +import re +from os import path +from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set + +from beancount.core import account, data +import beancount.core.amount as am +from beancount.core.amount import Amount +from beancount.core.flags import FLAG_WARNING +from beancount.core.inventory import Inventory +from beancount.core.number import ZERO +from beancount.ingest.cache import _FileMemo as FileMemo +from beancount.ingest.importer import ImporterProtocol + +import beancount_extras_kris7t.importers.utils as utils +from beancount_extras_kris7t.importers.utils import COMMENT_META, InvalidEntry, PAYEE_META + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' +DATE_FORMAT_FRACTIONAL = '%Y-%m-%dT%H:%M:%S.%fZ' +CATEGORY_META = 'transferwise-category' +ENTRY_TYPE_META = 'transferwise-entry-type' +TRANSFERWISE_JSON_TAG = 'transferwise-json' +CD_DEBIT = 'DEBIT' +CD_CREDIT = 'CREDIT' +MONEY_ADDED_TYPE = 'MONEY_ADDED' +CARD_TYPE = 'CARD' +CARD_REGEX = re.compile(r'^Card transaction of.*issued by', re.IGNORECASE) + + +def _parse_date(date_str: str) -> dt.date: + # TODO Handle time zones accurately + try: + return utils.parse_date(date_str, DATE_FORMAT) + except InvalidEntry: + return utils.parse_date(date_str, DATE_FORMAT_FRACTIONAL) + + +def _parse_json_amount(data: Any, cd: Optional[str] = None) -> Amount: + # TODO Handle precision better + amount = Amount(round(utils.parse_number(data['value']), 2), data['currency']) + if cd == CD_DEBIT: + return -amount + else: + return amount + + +def _validate_cd(amount: Amount, cd: str) -> None: + if cd == CD_DEBIT: + if amount.number >= ZERO: + raise InvalidEntry(f'Invalid debit amount: {amount}') + elif cd == CD_CREDIT: + if amount.number <= ZERO: + raise InvalidEntry(f'Invalid credit amount: {amount}') + else: + raise InvalidEntry(f'Invalid credit/debit type: {cd}') + + +class Reference(NamedTuple): + type: str + reference_number: str + + +class _ConversionResult(NamedTuple): + converted_fraction: Amount + price: Optional[Amount] + fudge: Optional[Amount] + + +class _Conversion(NamedTuple): + native_amount: Amount + converted_amount: Optional[Amount] + + def get_fraction(self, + total_converted_amount: Amount, + assigned_amount: Amount, + diverted_fees: Inventory) -> _ConversionResult: + fees = diverted_fees.get_currency_units(self.native_amount.currency) + native_amount = am.add(self.native_amount, fees) + fraction = am.div(assigned_amount, total_converted_amount.number).number + if self.converted_amount: + converted_fraction = am.mul(self.converted_amount, fraction) + price = am.div(native_amount, self.converted_amount.number) + assert price.number is not None + price = price._replace(number=round(price.number, 4)) + # TODO Do we have to calculate the fudge here? + return _ConversionResult(converted_fraction, price, None) + else: + return _ConversionResult(am.mul(native_amount, fraction), None, None) + + +class Accounts(NamedTuple): + borderless_root_asset: str + fees_expense: str + + def get_borderless_account(self, currency: str) -> str: + return account.sep.join([self.borderless_root_asset, currency]) + + +class Row(utils.Row): + cd: str + _transacted_amount: Amount + date: dt.date + _conversions: List[_Conversion] + _inputs: Inventory + _fees: Inventory + divert_fees: Optional[str] + + def __init__(self, reference: Reference, transaction_list: List[Any]): + assert len(transaction_list) >= 1 + first_transaction = transaction_list[0] + details = first_transaction['details'] + entry_type = details.get('type', None) + merchant = details.get('merchant', None) + if merchant: + payee = merchant['name'] + else: + payee = None + comment = details['description'] + super().__init__('', 0, entry_type, payee, comment) + if entry_type: + self.meta[ENTRY_TYPE_META] = entry_type + category = details.get('category', None) + if category: + self.meta[CATEGORY_META] = category + if merchant: + if 'category' in merchant and merchant['category'] == category: + del merchant['category'] + not_null_elements = [str(value).strip() for _, value in merchant.items() if value] + self.meta[PAYEE_META] = '; '.join(not_null_elements) + self.meta[COMMENT_META] = comment + self.tags.add(TRANSFERWISE_JSON_TAG) + self.links.add(f'transferwise_{reference.reference_number}') + self.date = _parse_date(first_transaction['date']) + self.cd = reference.type + self.divert_fees = None + self._conversions = [] + self._inputs = Inventory() + self._fees = Inventory() + self._compute_transacted_amount(transaction_list) + for transaction in transaction_list: + self._add_json_transaction(transaction) + + def _compute_transacted_amount(self, transaction_list: List[Any]) -> None: + first_transaction = transaction_list[0] + details = first_transaction['details'] + transacted_json = details.get('amount', None) + if transacted_json: + self._transacted_amount = _parse_json_amount(transacted_json, self.cd) + else: + if len(transaction_list) != 1: + raise InvalidEntry('Cannot determine transaction amount') + if exchange_details := first_transaction.get('exchangeDetails', None): + self._transacted_amount = _parse_json_amount(exchange_details, self.cd) + else: + self._transacted_amount = _parse_json_amount(first_transaction['amount']) + _validate_cd(self._transacted_amount, self.cd) + + def _add_json_transaction(self, transaction: Any) -> None: + if exchange := transaction.get('exchangeDetails', None): + native = _parse_json_amount(exchange['fromAmount'], self.cd) + converted = _parse_json_amount(exchange['toAmount'], self.cd) + _validate_cd(converted, self.cd) + else: + native = _parse_json_amount(transaction['amount']) + converted = None + _validate_cd(native, self.cd) + if total_fees := transaction.get('totalFees', None): + fee = -_parse_json_amount(total_fees) + if fee.number > ZERO: + raise InvalidEntry(f'Invalid transaction fee: {fee}') + if fee.number != ZERO: + self._fees.add_amount(fee) + native_after_fees = am.sub(native, fee) + else: + native_after_fees = native + self._conversions.append(_Conversion(native_after_fees, converted)) + self._inputs.add_amount(native) + + @property + def transacted_amount(self) -> Amount: + return self._transacted_amount + + def _to_transaction(self, accounts: Accounts) -> data.Transaction: + postings = self._get_postings(accounts) + return data.Transaction(self.meta, self.date, self.flag, self.payee, self.comment, + self.tags, self.links, postings) + + def _get_postings(self, accounts: Accounts) -> List[data.Posting]: + postings: List[data.Posting] = [] + for units, cost in self._inputs: + assert cost is None + postings.append(data.Posting( + accounts.get_borderless_account(units.currency), units, None, None, None, None)) + if self.divert_fees: + for units, cost in self._fees: + assert cost is None + postings.append(data.Posting( + self.divert_fees, units, None, None, None, None)) + diverted_fees = self._fees + else: + diverted_fees = Inventory() + # Also add the "fudge" amounts to the fees generated by rounding currency conversions. + all_fees = Inventory() + all_fees.add_inventory(self._fees) + for acc, assigned_units in self.postings: + for conversion in self._conversions: + units, price, fudge = conversion.get_fraction( + self._transacted_amount, assigned_units, diverted_fees) + postings.append(data.Posting(acc, -units, None, price, None, None)) + if fudge: + all_fees.add_amount(-fudge) + for units, cost in all_fees: + assert cost is None + postings.append(data.Posting( + accounts.fees_expense, -units, None, None, None, None)) + return postings + + +Extractor = Callable[[Row], None] + + +def extract_card_transaction(payment_processors: Dict[str, Optional[str]] = {}) -> Extractor: + regexes = [(re.compile(f'^\\s*{key}\\s*\\*', re.IGNORECASE), value) + for key, value in payment_processors.items()] + + def do_extract(row: Row) -> None: + if row.entry_type == CARD_TYPE and CARD_REGEX.search(row.comment): + if row.cd == CD_DEBIT: + row.comment = '' + else: + row.comment = 'Refund' + # Most manually add posting for refunded fees. + row.flag = FLAG_WARNING + if row.payee: + for key, value in regexes: + if match := key.search(row.payee): + if value: + row.tags.add(value) + row.payee = row.payee[match.end():].strip() + return do_extract + + +def extract_add_money(add_money_asset: str, add_money_fees_asset: str) -> Extractor: + def do_extract(row: Row) -> None: + if row.entry_type == MONEY_ADDED_TYPE: + row.payee = 'Transferwise' + row.divert_fees = add_money_fees_asset + row.assign_to_account(add_money_asset) + return do_extract + + +class Importer(ImporterProtocol): + _log: logging.Logger + profile_id: int + borderless_account_id: int + currencies: List[str] + accounts: Accounts + _extractors: List[Extractor] + + def __init__(self, + profile_id: int, + borderless_account_id: int, + currencies: Iterable[str], + accounts: Accounts, + extractors: List[Extractor]): + self._log = logging.getLogger(type(self).__qualname__) + self.profile_id = profile_id + self.borderless_account_id = borderless_account_id + self.currencies = list(currencies) + self.accounts = accounts + self._extractors = extractors + + def _parse_file(self, file: FileMemo) -> Any: + def parse_json(path: str) -> Any: + with open(path, 'r') as json_file: + try: + return json.load(json_file) + except json.JSONDecodeError as exc: + self._log.info('Invalid JSON: %s', path, exc_info=exc) + return None + + return file.convert(parse_json) + + def identify(self, file: FileMemo) -> bool: + _, extension = path.splitext(file.name) + if extension.lower() != '.json': + return False + contents = self._parse_file(file) + try: + query = contents['query'] + return query['accountId'] == self.borderless_account_id and \ + query['currency'] in self.currencies + except (KeyError, TypeError): + return False + + def file_name(self, file: FileMemo) -> str: + return 'statement.json' + + def file_account(self, file: FileMemo) -> str: + contents = self._parse_file(file) + try: + currency = contents['query']['currency'] + except (KeyError, TypeError) as exc: + raise ValueError(f'Invalid account statement: {file.name}') from exc + if not isinstance(currency, str): + raise ValueError(f'Invalid account statement: {file.name}') + return self.accounts.get_borderless_account(currency) + + def file_date(self, file: FileMemo) -> dt.date: + contents = self._parse_file(file) + try: + date_str = contents['query']['intervalEnd'] + except (KeyError, TypeError) as exc: + raise ValueError(f'Invalid account statement: {file.name}') from exc + if not isinstance(date_str, str): + raise ValueError(f'Invalid account statement: {file.name}') + return _parse_date(date_str) + + def extract(self, file: FileMemo) -> data.Entries: + contents = self._parse_file(file) + if contents: + return self.extract_objects([contents]) + else: + return [] + + def extract_objects(self, + statements: Iterable[Any], + skip_references: Set[str] = set()) -> data.Entries: + transactions: Dict[Reference, List[Any]] = defaultdict(lambda: []) + for statement in statements: + for transaction in statement['transactions']: + reference_number = transaction['referenceNumber'] + if reference_number in skip_references: + continue + reference = Reference(transaction['type'], reference_number) + transactions[reference].append(transaction) + entries: data.Entries = [] + for reference, transaction_list in transactions.items(): + if not transaction_list: + continue + try: + row = Row(reference, transaction_list) + except (TypeError, KeyError, InvalidEntry) as exc: + self._log.warn('Invalid entry: %s', reference, exc_info=exc) + continue + try: + utils.run_row_extractors(row, self._extractors) + except InvalidEntry as exc: + self._log.warn('Invalid entry: %s', reference, exc_info=exc) + continue + entries.append(row._to_transaction(self.accounts)) + entries.sort(key=lambda entry: entry.date) + if entries: + self._extract_closing_balances(statements, entries) + return entries + + def _extract_closing_balances(self, + statements: Iterable[Any], + entries: data.Entries) -> None: + for statement in statements: + query = statement['query'] + end_date = _parse_date(query['intervalEnd']) + dt.timedelta(days=1) + currency = query['currency'] + balance = statement['endOfStatementBalance'] + amount = Amount(utils.parse_number(balance['value']), balance['currency']) + meta = data.new_metadata(f'', 0) + account = self.accounts.get_borderless_account(currency) + entries.append(data.Balance(meta, end_date, account, amount, None, None)) diff --git a/beancount_extras_kris7t/importers/utils.py b/beancount_extras_kris7t/importers/utils.py new file mode 100644 index 0000000..f0a8134 --- /dev/null +++ b/beancount_extras_kris7t/importers/utils.py @@ -0,0 +1,125 @@ +''' +Utilities for custom importers. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from abc import ABC, abstractmethod +import datetime as dt +from decimal import Decimal +from typing import cast, Callable, Iterable, List, NamedTuple, Optional, Set, TypeVar, Union + +from beancount.core import amount as am, data +from beancount.core.amount import Amount +from beancount.core.flags import FLAG_OKAY, FLAG_WARNING +from beancount.core.number import D, ZERO + +MISSING_AMOUNT = cast(Amount, None) +COMMENT_META = 'import-raw-comment' +PAYEE_META = 'import-raw-payee' + + +class InvalidEntry(Exception): + pass + + +class Posting(NamedTuple): + account: str + amount: Amount + + +class Row(ABC): + entry_type: Optional[str] + payee: Optional[str] + comment: str + meta: data.Meta + flag: str + tags: Set[str] + links: Set[str] + _postings: Optional[List[Posting]] + + def __init__(self, + file_name: str, + line_number: int, + entry_type: Optional[str], + payee: Optional[str], + comment: str): + self.entry_type = entry_type + self.payee = payee + self.comment = comment + self.meta = data.new_metadata(file_name, line_number) + self.flag = FLAG_OKAY + self.tags = set() + self.links = set() + self._postings = None + + @property + @abstractmethod + def transacted_amount(self) -> Amount: + pass + + @property + def transacted_currency(self) -> str: + return self.transacted_amount.currency + + @property + def postings(self) -> Optional[List[Posting]]: + return self._postings + + def assign_to_accounts(self, *postings: Posting) -> None: + if self.done: + raise InvalidEntry('Transaction is alrady done processing') + self._postings = list(postings) + if not self._postings: + raise InvalidEntry('Not assigned to any accounts') + head, *rest = self._postings + sum = head.amount + for posting in rest: + sum = am.add(sum, posting.amount) + if sum != self.transacted_amount: + self.flag = FLAG_WARNING + + def assign_to_account(self, account: str) -> None: + self.assign_to_accounts(Posting(account, self.transacted_amount)) + + @property + def done(self) -> bool: + return self._postings is not None + + +Extractor = Callable[[Row], None] +TRow = TypeVar('TRow', bound=Row) + + +def run_row_extractors(row: TRow, extractors: Iterable[Callable[[TRow], None]]) -> None: + for extractor in extractors: + extractor(row) + if row.done: + return + + +def extract_unknown(expenses_account: str, income_account: str) -> Extractor: + def do_extract(row: Row) -> None: + if row.transacted_amount.number < ZERO: + row.assign_to_account(expenses_account) + else: + row.assign_to_account(income_account) + row.flag = FLAG_WARNING + return do_extract + + +def parse_date(date_str: str, format_string: str) -> dt.date: + try: + return dt.datetime.strptime(date_str, format_string).date() + except ValueError as exc: + raise InvalidEntry(f'Cannot parse date: {date_str}') from exc + + +def parse_number(in_amount: Union[str, int, float, Decimal]) -> Decimal: + try: + value = D(in_amount) + except ValueError as exc: + raise InvalidEntry(f'Cannot parse number: {in_amount}') from exc + if value is None: + raise InvalidEntry(f'Parse number returned None: {in_amount}') + return value diff --git a/beancount_extras_kris7t/plugins/__init__.py b/beancount_extras_kris7t/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beancount_extras_kris7t/plugins/closing_balance.py b/beancount_extras_kris7t/plugins/closing_balance.py new file mode 100644 index 0000000..a22e712 --- /dev/null +++ b/beancount_extras_kris7t/plugins/closing_balance.py @@ -0,0 +1,70 @@ +''' +Plugin that closes an account by transferring its whole balance to another account. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from typing import Any, Dict, List, NamedTuple, Optional, Tuple + +from beancount.core.data import Balance, Close, Directive, Entries, Meta, Posting, Transaction +from beancount.core.flags import FLAG_OKAY +from beancount.core.number import ZERO + +__plugins__ = ('close_with_balance_assertions',) + +CLOSE_TO_META = 'close-to' +CLOSING_META = 'closing' + + +class ClosingBalanceError(NamedTuple): + source: Optional[Meta] + message: str + entry: Directive + + +def close_with_balance_assertions(entries: Entries, + options_map: Dict[str, Any], + config_str: Optional[str] = None) -> \ + Tuple[Entries, List[ClosingBalanceError]]: + new_entries: Entries = [] + errors: List[ClosingBalanceError] = [] + for entry in entries: + new_entries.append(entry) + if isinstance(entry, Balance) and CLOSE_TO_META in entry.meta: + close_to_account = entry.meta[CLOSE_TO_META] + if not isinstance(close_to_account, str): + errors.append(ClosingBalanceError( + entry.meta, + f'{CLOSE_TO_META} must be a string, got {close_to_account} instead', + entry)) + continue + if entry.tolerance is not None and entry.tolerance != ZERO: + errors.append(ClosingBalanceError( + entry.meta, + f'Closing an account requires {ZERO} tolerance, got {entry.tolerance} instead', + entry)) + continue + if entry.diff_amount is not None: + errors.append(ClosingBalanceError( + entry.meta, + f'Not closing {entry.account} with {entry.diff_amount} failed balance check', + entry)) + continue + new_meta = dict(entry.meta) + del new_meta[CLOSE_TO_META] + if entry.amount.number != ZERO: + new_entries.append(Transaction( + new_meta, + entry.date, + FLAG_OKAY, + None, + f'Closing {entry.account}', + set(), + set(), + [ + Posting(entry.account, -entry.amount, None, None, None, + {CLOSING_META: True}), + Posting(close_to_account, entry.amount, None, None, None, None) + ])) + new_entries.append(Close(new_meta, entry.date, entry.account)) + return new_entries, errors diff --git a/beancount_extras_kris7t/plugins/closing_balance_test.py b/beancount_extras_kris7t/plugins/closing_balance_test.py new file mode 100644 index 0000000..40115bf --- /dev/null +++ b/beancount_extras_kris7t/plugins/closing_balance_test.py @@ -0,0 +1,124 @@ +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import unittest + +from beancount import loader +from beancount.parser import cmptest + + +class TestClosingBalance(cmptest.TestCase): + + @loader.load_doc() + def test_close_account_correct(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Equity:Opening-Balances + 2020-01-01 open Equity:Closing-Balances + + 2020-01-01 * "Opening balances" + Assets:Checking 100 USD + Equity:Opening-Balances -100 USD + + 2020-03-15 balance Assets:Checking 100 USD + close-to: Equity:Closing-Balances + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Equity:Opening-Balances + 2020-01-01 open Equity:Closing-Balances + + 2020-01-01 * "Opening balances" + Assets:Checking 100 USD + Equity:Opening-Balances -100 USD + + 2020-03-15 balance Assets:Checking 100 USD + close-to: Equity:Closing-Balances + + 2020-03-15 * "Closing Assets:Checking" + Assets:Checking -100 USD + closing: TRUE + Equity:Closing-Balances 100 USD + + 2020-03-15 close Assets:Checking + ''', entries) + + @loader.load_doc(expect_errors=True) + def test_close_account_incorrect(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Equity:Opening-Balances + 2020-01-01 open Equity:Closing-Balances + + 2020-01-01 * "Opening balances" + Assets:Checking 100 USD + Equity:Opening-Balances -100 USD + + 2020-03-15 balance Assets:Checking 80 USD + close-to: Equity:Closing-Balances + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Equity:Opening-Balances + 2020-01-01 open Equity:Closing-Balances + + 2020-01-01 * "Opening balances" + Assets:Checking 100 USD + Equity:Opening-Balances -100 USD + + 2020-03-15 balance Assets:Checking 80 USD + close-to: Equity:Closing-Balances + + 2020-03-15 * "Closing Assets:Checking" + Assets:Checking -80 USD + closing: TRUE + Equity:Closing-Balances 80 USD + + 2020-03-15 close Assets:Checking + ''', entries) + self.assertRegex(errors[0].message, '^Balance failed for \'Assets:Checking\'') + + @loader.load_doc() + def test_close_account_zero(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + + 2020-03-15 balance Assets:Checking 0 USD + close-to: Equity:Closing-Balances + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + + 2020-03-15 balance Assets:Checking 0 USD + close-to: Equity:Closing-Balances + + 2020-03-15 close Assets:Checking + ''', entries) + + @loader.load_doc(expect_errors=True) + def test_invalid_close_to(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + + 2020-03-15 balance Assets:Checking 100 USD + close-to: TRUE + ''' + self.assertRegex(errors[0].message, '^close-to must be a string') + + +if __name__ == '__main__': + unittest.main() diff --git a/beancount_extras_kris7t/plugins/default_tolerance.py b/beancount_extras_kris7t/plugins/default_tolerance.py new file mode 100644 index 0000000..52fa956 --- /dev/null +++ b/beancount_extras_kris7t/plugins/default_tolerance.py @@ -0,0 +1,47 @@ +''' +Plugin that sets the tolerance values of balance assertions to an amount determined by the account. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from decimal import Decimal +from typing import Any, Dict, List, NamedTuple, Optional, Tuple + +from beancount.core.data import Open, Balance, Directive, Entries, Meta + +__plugins__ = ('set_tolerances_to_default',) + +DEFAULT_TOLERANCE_META = 'default-balance-tolerance' + + +class DefaultToleranceError(NamedTuple): + source: Optional[Meta] + message: str + entry: Directive + + +def set_tolerances_to_default(entries: Entries, + options_map: Dict[str, Any], + config_str: Optional[str] = None) -> \ + Tuple[Entries, List[DefaultToleranceError]]: + errors: List[DefaultToleranceError] = [] + accounts: Dict[str, Optional[Decimal]] = {} + for entry in entries: + if not isinstance(entry, Open): + continue + if tolerance := entry.meta.get(DEFAULT_TOLERANCE_META, None): + if isinstance(tolerance, Decimal): + accounts[entry.account] = tolerance + else: + errors.append(DefaultToleranceError( + entry.meta, + f'{DEFAULT_TOLERANCE_META} must be decimal, got {tolerance} instead', + entry)) + new_entries: Entries = [] + for entry in entries: + if isinstance(entry, Balance) and entry.tolerance is None and entry.account in accounts: + account_tolerance = accounts[entry.account] + new_entries.append(entry._replace(tolerance=account_tolerance)) + else: + new_entries.append(entry) + return new_entries, errors diff --git a/beancount_extras_kris7t/plugins/default_tolerance_test.py b/beancount_extras_kris7t/plugins/default_tolerance_test.py new file mode 100644 index 0000000..2e5c629 --- /dev/null +++ b/beancount_extras_kris7t/plugins/default_tolerance_test.py @@ -0,0 +1,104 @@ +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import unittest + +from beancount import loader +from beancount.parser import cmptest + + +class DefaultToleranceTest(cmptest.TestCase): + + @loader.load_doc() + def test_account_with_tolerance(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: 10 + + 2020-01-01 balance Assets:Checking 0 USD + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: 10 + + 2020-01-01 balance Assets:Checking 0 ~ 10 USD + ''', entries) + + @loader.load_doc() + def test_account_with_tolerance_override(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: 10 + + 2020-01-01 balance Assets:Checking 10 ~ 20 USD + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: 10 + + 2020-01-01 balance Assets:Checking 10 ~ 20 USD + ''', entries) + + @loader.load_doc() + def test_account_with_tolerance_override_zero(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: 10 + + 2020-01-01 balance Assets:Checking 0 ~ 0 USD + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: 10 + + 2020-01-01 balance Assets:Checking 0 ~ 0 USD + ''', entries) + + @loader.load_doc() + def test_account_without_tolerance(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + + 2020-01-01 balance Assets:Checking 0 USD + + 2020-01-02 balance Assets:Checking 10 ~ 20 USD + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + + 2020-01-01 balance Assets:Checking 0 USD + + 2020-01-02 balance Assets:Checking 10 ~ 20 USD + ''', entries) + + @loader.load_doc(expect_errors=True) + def test_account_with_invalid_tolerance(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.default_tolerance" + + 2020-01-01 open Assets:Checking + default-balance-tolerance: TRUE + + 2020-01-01 balance Assets:Checking 0 USD + ''' + self.assertRegex(errors[0].message, '^default-balance-tolerance must be decimal') + + +if __name__ == '__main__': + unittest.main() diff --git a/beancount_extras_kris7t/plugins/selective_implicit_prices.py b/beancount_extras_kris7t/plugins/selective_implicit_prices.py new file mode 100644 index 0000000..07dc893 --- /dev/null +++ b/beancount_extras_kris7t/plugins/selective_implicit_prices.py @@ -0,0 +1,157 @@ +"""This plugin synthesizes Price directives for all Postings with a price or +directive or if it is an augmenting posting, has a cost directive. + +Price directives will be synthesized only for commodities with the +implicit-prices: TRUE metadata set. +""" +__copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \ + "2020 Kristóf Marussy " +__license__ = "GNU GPLv2" + +import collections +from typing import List, Tuple, Set + +from beancount.core.data import Commodity, Entries, Transaction +from beancount.core import data +from beancount.core import amount +from beancount.core import inventory +from beancount.core.position import Cost + +__plugins__ = ('add_implicit_prices',) + + +ImplicitPriceError = collections.namedtuple('ImplicitPriceError', 'source message entry') + + +METADATA_FIELD = "__implicit_prices__" +IMPLICIT_PRICES_META = "implicit-prices" + + +def fetch_commodities(entries: Entries) -> Tuple[Set[str], List[ImplicitPriceError]]: + commodities: Set[str] = set() + errors: List[ImplicitPriceError] = [] + for entry in entries: + if isinstance(entry, Commodity): + implicit_prices = entry.meta.get(IMPLICIT_PRICES_META, False) + if not isinstance(implicit_prices, bool): + errors.append(ImplicitPriceError( + entry.meta, + f'{IMPLICIT_PRICES_META} must be Boolean, got {implicit_prices} instead', + entry)) + if implicit_prices: + commodities.add(entry.currency) + return commodities, errors + + +def add_implicit_prices(entries: Entries, + unused_options_map) -> Tuple[Entries, List[ImplicitPriceError]]: + """Insert implicitly defined prices from Transactions. + + Explicit price entries are simply maintained in the output list. Prices from + postings with costs or with prices from Transaction entries are synthesized + as new Price entries in the list of entries output. + + Args: + entries: A list of directives. We're interested only in the Transaction instances. + unused_options_map: A parser options dict. + Returns: + A list of entries, possibly with more Price entries than before, and a + list of errors. + """ + new_entries: Entries = [] + errors: List[ImplicitPriceError] = [] + + commodities, fetch_errors = fetch_commodities(entries) + errors.extend(fetch_errors) + + # A dict of (date, currency, cost-currency) to price entry. + new_price_entry_map = {} + + balances = collections.defaultdict(inventory.Inventory) + for entry in entries: + # Always replicate the existing entries. + new_entries.append(entry) + + if isinstance(entry, Transaction): + # Inspect all the postings in the transaction. + for posting in entry.postings: + units = posting.units + if units.currency not in commodities: + continue + + cost = posting.cost + + # Check if the position is matching against an existing + # position. + _, booking = balances[posting.account].add_position(posting) + + # Add prices when they're explicitly specified on a posting. An + # explicitly specified price may occur in a conversion, e.g. + # Assets:Account 100 USD @ 1.10 CAD + # or, if a cost is also specified, as the current price of the + # underlying instrument, e.g. + # Assets:Account 100 HOOL {564.20} @ {581.97} USD + if posting.price is not None: + meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"]) + meta[METADATA_FIELD] = "from_price" + price_entry = data.Price(meta, entry.date, + units.currency, + posting.price) + + # Add costs, when we're not matching against an existing + # position. This happens when we're just specifying the cost, + # e.g. + # Assets:Account 100 HOOL {564.20} + elif (cost is not None and + booking != inventory.MatchResult.REDUCED): + # TODO(blais): What happens here if the account has no + # booking strategy? Do we end up inserting a price for the + # reducing leg? Check. + meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"]) + meta[METADATA_FIELD] = "from_cost" + if isinstance(cost, Cost): + price_entry = data.Price(meta, entry.date, + units.currency, + amount.Amount(cost.number, cost.currency)) + else: + errors.append( + ImplicitPriceError( + entry.meta, + f'Expected {entry} to have a Cost, got {cost} instead', + entry)) + price_entry = None + else: + price_entry = None + + if price_entry is not None: + key = (price_entry.date, + price_entry.currency, + price_entry.amount.number, # Ideally should be removed. + price_entry.amount.currency) + try: + new_price_entry_map[key] + + # Do not fail for now. We still have many valid use + # cases of duplicate prices on the same date, for + # example, stock splits, or trades on two dates with + # two separate reported prices. We need to figure out a + # more elegant solution for this in the long term. + # Keeping both for now. We should ideally not use the + # number in the de-dup key above. + # + # dup_entry = new_price_entry_map[key] + # if price_entry.amount.number == dup_entry.amount.number: + # # Skip duplicates. + # continue + # else: + # errors.append( + # ImplicitPriceError( + # entry.meta, + # "Duplicate prices for {} on {}".format(entry, + # dup_entry), + # entry)) + except KeyError: + new_price_entry_map[key] = price_entry + new_entries.append(price_entry) + + return new_entries, errors diff --git a/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py b/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py new file mode 100644 index 0000000..6ead45d --- /dev/null +++ b/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py @@ -0,0 +1,410 @@ +__copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \ + "2020 Kristóf Marussy " +__license__ = "GNU GPLv2" + +import unittest + +from beancount.core.number import D +from beancount.core import data +from beancount.parser import cmptest +from beancount import loader + +from beancount_extras_kris7t.plugins import selective_implicit_prices as implicit_prices + + +class TestImplicitPrices(cmptest.TestCase): + + @loader.load_doc() + def test_add_implicit_prices__all_cases(self, entries, _, options_map): + """ + 1702-04-02 commodity USD + implicit-prices: TRUE + + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 + 2013-01-01 open Assets:Other + + ;; An explicit price directive. + 2013-02-01 price USD 1.10 CAD + + 2013-04-01 * "A transaction with a price conversion." + Assets:Account1 150 USD @ 1.12 CAD + Assets:Other + + ;; This should book at price at the cost. + 2013-04-02 * "A transaction with a cost." + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + + ;; This one should be IGNORED because it books against the above. + 2013-04-03 * "A transaction with a cost that reduces an existing position" + Assets:Account1 -500 HOOL {520 USD} + Assets:Other + + ;; This one should generate the price, even if it is reducing. + 2013-04-04 * "A transaction with a cost that reduces existing position, with price" + Assets:Account1 -100 HOOL {520 USD} @ 530 USD + Assets:Other + + ;; This is not reducing and should also book a price at cost. + 2013-04-05 * "A transaction with another cost that is not reducing." + Assets:Account1 500 HOOL {540 USD} + Assets:Other + + ;; The price here overrides the cost and should create an entry. + 2013-04-06 * "A transaction with a cost and a price." + Assets:Account1 500 HOOL {540 USD} @ 560 USD + Assets:Other + """ + self.assertEqual(12, len(entries)) + new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) + price_entries = [entry for entry in new_entries if isinstance(entry, data.Price)] + + self.assertEqualEntries(""" + 1702-04-02 commodity USD + implicit-prices: TRUE + + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 + 2013-01-01 open Assets:Other + + 2013-02-01 price USD 1.10 CAD + + 2013-04-01 * "A transaction with a price conversion." + Assets:Account1 150 USD @ 1.12 CAD + Assets:Other -168.00 CAD + + 2013-04-01 price USD 1.12 CAD + + 2013-04-02 * "A transaction with a cost." + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + + 2013-04-02 price HOOL 520 USD + + 2013-04-03 * "A transaction with a cost that reduces an existing position" + Assets:Account1 -500 HOOL {520 USD} + Assets:Other 260000 USD + + 2013-04-04 * "A transaction with a cost that reduces existing position, with price" + Assets:Account1 -100 HOOL {520 USD} @ 530 USD + Assets:Other 52000 USD + + 2013-04-04 price HOOL 530 USD + + 2013-04-05 * "A transaction with another cost that is not reducing." + Assets:Account1 500 HOOL {540 USD} + Assets:Other -270000 USD + + 2013-04-05 price HOOL 540 USD + + 2013-04-06 * "A transaction with a cost and a price." + Assets:Account1 500 HOOL {540 USD} @ 560 USD + Assets:Other -270000 USD + + 2013-04-06 price HOOL 560 USD + """, new_entries) + + self.assertEqual(6, len(price_entries)) + expected_values = [(x[0], x[1], D(x[2])) for x in [ + ('USD', 'CAD', '1.10'), + ('USD', 'CAD', '1.12'), + ('HOOL', 'USD', '520.00'), + ('HOOL', 'USD', '530.00'), + ('HOOL', 'USD', '540.00'), + ('HOOL', 'USD', '560.00') + ]] + for expected, price in zip(expected_values, price_entries): + actual = (price.currency, price.amount.currency, price.amount.number) + self.assertEqual(expected, actual) + + @loader.load_doc() + def test_add_implicit_prices__other_account(self, entries, errors, options_map): + """ + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 "NONE" + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + + 2013-04-02 * + Assets:Account2 1500 HOOL {530 USD} + Assets:Other + + 2013-04-10 * "Reduces existing position in account 1" + Assets:Account1 -100 HOOL {520 USD} + Assets:Other 52000 USD + + 2013-04-11 * "Does not find an existing position in account 2" + Assets:Account2 -200 HOOL {531 USD} + Assets:Other 106200 USD + + """ + new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) + self.assertEqualEntries(""" + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 "NONE" + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + + 2013-04-02 * + Assets:Account2 1500 HOOL {530 USD} + Assets:Other -795000 USD + + 2013-04-01 price HOOL 520 USD + + 2013-04-02 price HOOL 530 USD + + 2013-04-10 * "Reduces existing position in account 1" + Assets:Account1 -100 HOOL {520 USD} + Assets:Other 52000 USD + + 2013-04-11 * "Does not find an existing position in account 2" + Assets:Account2 -200 HOOL {531 USD} + Assets:Other 106200 USD + + ;; Because a match was not found against the inventory, a price will be added. + 2013-04-11 price HOOL 531 USD + + """, new_entries) + + @loader.load_doc() + def test_add_implicit_prices__duplicates_on_same_transaction(self, + entries, _, options_map): + """ + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 + 2013-01-01 open Assets:Other + + 2013-04-01 * "Allowed because of same price" + Assets:Account1 1500 HOOL {520 USD} + Assets:Account2 1500 HOOL {520 USD} + Assets:Other + + 2013-04-02 * "Second one is disallowed because of different price" + Assets:Account1 1500 HOOL {520 USD} + Assets:Account2 1500 HOOL {530 USD} + Assets:Other + + """ + new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map) + self.assertEqual([], [type(error) for error in errors]) + self.assertEqualEntries(""" + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 + 2013-01-01 open Assets:Other + + 2013-04-01 * "Allowed because of same price" + Assets:Account1 1500 HOOL {520 USD} + Assets:Account2 1500 HOOL {520 USD} + Assets:Other -1560000 USD + + 2013-04-01 price HOOL 520 USD + + 2013-04-02 * "Second one is disallowed because of different price" + Assets:Account1 1500 HOOL {520 USD} + Assets:Account2 1500 HOOL {530 USD} + Assets:Other -1575000 USD + + 2013-04-02 price HOOL 520 USD + 2013-04-02 price HOOL 530 USD ;; Allowed for now. + + """, new_entries) + + @loader.load_doc() + def test_add_implicit_prices__duplicates_on_different_transactions(self, + entries, _, + options_map): + """ + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 + 2013-01-01 open Assets:Other + + 2013-04-01 * "Allowed because of same price #1" + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + + 2013-04-01 * "Allowed because of same price #2" + Assets:Account2 1500 HOOL {520 USD} + Assets:Other + + 2013-04-02 * "Second one is disallowed because of different price #1" + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + + 2013-04-02 * "Second one is disallowed because of different price #2" + Assets:Account2 1500 HOOL {530 USD} + Assets:Other + + """ + new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map) + self.assertEqual([], [type(error) for error in errors]) + self.assertEqualEntries(""" + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Account2 + 2013-01-01 open Assets:Other + + 2013-04-01 * "Allowed because of same price #1" + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + + 2013-04-01 * "Allowed because of same price #2" + Assets:Account2 1500 HOOL {520 USD} + Assets:Other -780000 USD + + 2013-04-01 price HOOL 520 USD + + 2013-04-02 * "Second one is disallowed because of different price #1" + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + + 2013-04-02 * "Second one is disallowed because of different price #2" + Assets:Account2 1500 HOOL {530 USD} + Assets:Other -795000 USD + + 2013-04-02 price HOOL 520 USD + 2013-04-02 price HOOL 530 USD ;; Allowed for now. + + """, new_entries) + + @loader.load_doc() + def test_add_implicit_prices__duplicates_overloaded(self, entries, _, options_map): + """ + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * "Allowed, sets the price for that day" + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + + 2013-04-01 * "Will be ignored, price for the day already set" + Assets:Account1 1500 HOOL {530 USD} + Assets:Other + + 2013-04-01 * "Should be ignored too, price for the day already set" + Assets:Account1 1500 HOOL {530 USD} + Assets:Other + """ + new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map) + self.assertEqual([], [type(error) for error in errors]) + self.assertEqualEntries(""" + 2013-01-01 commodity HOOL + implicit-prices: TRUE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * "Allowed, sets the price for that day" + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + + 2013-04-01 * "Will be ignored, price for the day already set" + Assets:Account1 1500 HOOL {530 USD} + Assets:Other -795000 USD + + 2013-04-01 * "Should be ignored too, price for the day already set" + Assets:Account1 1500 HOOL {530 USD} + Assets:Other -795000 USD + + 2013-04-01 price HOOL 520 USD + 2013-04-01 price HOOL 530 USD + + """, new_entries) + + @loader.load_doc() + def test_add_implicit_prices__not_enabled(self, entries, errors, options_map): + """ + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + """ + new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) + self.assertEqualEntries(""" + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + """, new_entries) + + @loader.load_doc() + def test_add_implicit_prices__disabled(self, entries, errors, options_map): + """ + 2013-01-01 commodity HOOL + implicit-prices: FALSE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + """ + new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map) + self.assertEqualEntries(""" + 2013-01-01 commodity HOOL + implicit-prices: FALSE + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other -780000 USD + """, new_entries) + + @loader.load_doc() + def test_add_implicit_prices__invalid(self, entries, errors, options_map): + """ + 2013-01-01 commodity HOOL + implicit-prices: "yes" + + 2013-01-01 open Assets:Account1 + 2013-01-01 open Assets:Other + + 2013-04-01 * + Assets:Account1 1500 HOOL {520 USD} + Assets:Other + """ + _, new_errors = implicit_prices.add_implicit_prices(entries, options_map) + self.assertRegex(new_errors[0].message, '^implicit-prices must be Boolean') + + +if __name__ == '__main__': + unittest.main() diff --git a/beancount_extras_kris7t/plugins/templates.py b/beancount_extras_kris7t/plugins/templates.py new file mode 100644 index 0000000..7d2d2c1 --- /dev/null +++ b/beancount_extras_kris7t/plugins/templates.py @@ -0,0 +1,144 @@ +''' +Plugin that closes an account by transferring its whole balance to another account. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import datetime as dt +from decimal import Decimal +from typing import Any, Dict, List, NamedTuple, Optional, Tuple + +from beancount.core import amount +from beancount.core.data import Custom, Directive, Entries, Meta, Transaction, Union +from beancount.core.number import ZERO + +__plugins__ = ('apply_templates',) + +TEMPLATE_META = 'template' +TEMPLATE_USE_CUSTOM = 'template-use' +TEMPLATE_DELETE_CUSTOM = 'template-delete' +TEMPLATE_TAG_PREFIX = 'template' + + +Templates = Dict[str, Transaction] + + +class TemplateError(NamedTuple): + source: Optional[Meta] + message: str + entry: Directive + + +def _create_transaction(date: dt.date, + meta: Meta, + template: Transaction, + scale_factor: Decimal) -> Transaction: + return template._replace( + date=date, + meta={**template.meta, **meta}, + postings=[posting._replace(units=amount.mul(posting.units, scale_factor)) + for posting in template.postings]) + + +def _use_template(entry: Custom, + templates: Templates) -> Union[Transaction, TemplateError]: + if len(entry.values) == 0: + return TemplateError(entry.meta, 'Template name missing', entry) + if len(entry.values) > 2: + return TemplateError( + entry.meta, + f'Too many {TEMPLATE_USE_CUSTOM} arguments', + entry) + template_name = entry.values[0].value + if not isinstance(template_name, str): + return TemplateError( + entry.meta, + f'Template name must be a string, got {template_name} instead', + entry) + template = templates.get(template_name, None) + if template is None: + return TemplateError( + entry.meta, + f'Unknown template: {template_name}', + entry) + if len(entry.values) == 2: + scale_factor = entry.values[1].value + if not isinstance(scale_factor, Decimal): + return TemplateError( + entry.meta, + f'Invalid scale factor {scale_factor}', + entry) + if scale_factor == ZERO: + return TemplateError( + entry.meta, + f'Scale factor must not be {ZERO}', + entry) + else: + scale_factor = Decimal(1.0) + return _create_transaction(entry.date, entry.meta, template, scale_factor) + + +def _add_template(entry: Transaction, templates: Templates) -> Optional[TemplateError]: + template_name = entry.meta[TEMPLATE_META] + if not isinstance(template_name, str): + return TemplateError( + entry.meta, + f'{TEMPLATE_META} must be a string, got {template_name} instead', + entry) + new_meta = dict(entry.meta) + del new_meta[TEMPLATE_META] + templates[template_name] = entry._replace( + meta=new_meta, + links={*entry.links, f'{TEMPLATE_TAG_PREFIX}_{template_name}'}) + return None + + +def _delete_template(entry: Custom, templates: Templates) -> Optional[TemplateError]: + if len(entry.values) != 1: + return TemplateError( + entry.meta, + f'{TEMPLATE_DELETE_CUSTOM} takes a single argument', + entry) + template_name = entry.values[0].value + if not isinstance(template_name, str): + return TemplateError( + entry.meta, + f'Template name must be a string, got {template_name} instead', + entry) + if template_name not in templates: + return TemplateError( + entry.meta, + f'Unknown template: {template_name}', + entry) + del templates[template_name] + return None + + +def apply_templates(entries: Entries, + options_map: Dict[str, Any], + config_str: Optional[str] = None) -> \ + Tuple[Entries, List[TemplateError]]: + new_entries: Entries = [] + errors: List[TemplateError] = [] + templates: Templates = {} + for entry in entries: + if isinstance(entry, Transaction) and TEMPLATE_META in entry.meta: + result = _add_template(entry, templates) + if result: + errors.append(result) + elif isinstance(entry, Custom): + if entry.type == TEMPLATE_USE_CUSTOM: + result = _use_template(entry, templates) + if isinstance(result, TemplateError): + errors.append(result) + else: + new_entries.append(result) + elif entry.type == TEMPLATE_DELETE_CUSTOM: + result = _delete_template(entry, templates) + if result: + errors.append(result) + else: + new_entries.append(entry) + else: + new_entries.append(entry) + return new_entries, errors diff --git a/beancount_extras_kris7t/plugins/templates_test.py b/beancount_extras_kris7t/plugins/templates_test.py new file mode 100644 index 0000000..5f63e6c --- /dev/null +++ b/beancount_extras_kris7t/plugins/templates_test.py @@ -0,0 +1,326 @@ +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import unittest + +from beancount import loader +from beancount.parser import cmptest +import pytest + + +class TestClosingBalance(cmptest.TestCase): + + @loader.load_doc() + def test_use_template_simple(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" #tag ^link + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-15 custom "template-use" "eating-out" + + 2020-04-12 custom "template-use" "eating-out" + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-04-12 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + ''', entries) + + @loader.load_doc() + def test_use_template_metadata(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" #tag ^link + template: "eating-out" + meta1: "data" + meta2: TRUE + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-15 custom "template-use" "eating-out" + meta1: "foo" + meta3: 3.14 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + meta1: "foo" + meta2: TRUE + meta3: 2.14 + Assets:Checking 25 USD + Expenses:Food -25 USD + ''', entries) + + @loader.load_doc() + def test_use_template_scaled(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" #tag ^link + template: "eating-out" + Assets:Checking 1 USD + Expenses:Food -1 USD + + 2020-03-15 custom "template-use" "eating-out" 25 + + 2020-04-12 custom "template-use" "eating-out" 27 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-04-12 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + Assets:Checking 27 USD + Expenses:Food -27 USD + ''', entries) + + @loader.load_doc() + def test_use_template_overwritten(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + 2020-01-01 open Expenses:Tax + + 2020-04-01 * "Eating out" #tag ^link + template: "eating-out" + Assets:Checking 1.10 USD + Expenses:Food -1 USD + Expenses:Tax -0.10 USD + + 2020-01-01 * "Eating out" #tag ^link + template: "eating-out" + Assets:Checking 1 USD + Expenses:Food -1 USD + + 2020-03-15 custom "template-use" "eating-out" 25 + + 2020-04-12 custom "template-use" "eating-out" 27 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.closing_balance" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + 2020-01-01 open Expenses:Tax + + 2020-03-15 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-04-12 * "Eating out" #tag ^link ^template_eating-out + template: "eating-out" + Assets:Checking 29.70 USD + Expenses:Food -27 USD + Expenses:Tax -2.70 USD + ''', entries) + + @loader.load_doc(expect_errors=True) + def test_invalid_name(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: TRUE + Assets:Checking 25 USD + Expenses:Food -25 USD + ''' + self.assertRegex(errors[0].message, "^template must be a string") + + @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad") + @loader.load_doc(expect_errors=True) + def test_use_missing_name(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-15 custom "template-use" + ''' + self.assertRegex(errors[0].message, "^Template name missing") + + @loader.load_doc(expect_errors=True) + def test_use_too_many_arguments(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-15 custom "template-use" "eating-out" 2 "another" + ''' + self.assertRegex(errors[0].message, "^Too many template-use arguments") + + @loader.load_doc(expect_errors=True) + def test_use_invalid_name(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-03-15 custom "template-use" TRUE + ''' + self.assertRegex(errors[0].message, "^Template name must be a string") + + @loader.load_doc(expect_errors=True) + def test_use_unknown(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-03-15 custom "template-use" "taxi" + ''' + self.assertRegex(errors[0].message, "^Unknown template") + + @loader.load_doc(expect_errors=True) + def test_use_invalid_scale_factor(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-15 custom "template-use" "eating-out" TRUE + ''' + self.assertRegex(errors[0].message, "^Invalid scale factor") + + @loader.load_doc(expect_errors=True) + def test_use_zero_scale_factor(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-15 custom "template-use" "eating-out" 0.0 + ''' + self.assertRegex(errors[0].message, "^Scale factor must not be 0") + + @loader.load_doc(expect_errors=True) + def test_template_delete(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-01 custom "template-delete" "eating-out" + + 2020-03-15 custom "template-use" "eating-out" + ''' + self.assertRegex(errors[0].message, "^Unknown template") + + @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad") + @loader.load_doc(expect_errors=True) + def test_template_delete_too_few_arguments(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-03-01 custom "template-delete" + ''' + self.assertRegex(errors[0].message, "^template-delete takes a single argument") + + @loader.load_doc(expect_errors=True) + def test_template_delete_too_many_arguments(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Expenses:Food + + 2020-01-01 * "Eating out" + template: "eating-out" + Assets:Checking 25 USD + Expenses:Food -25 USD + + 2020-03-01 custom "template-delete" "eating-out" TRUE + ''' + self.assertRegex(errors[0].message, "^template-delete takes a single argument") + + @loader.load_doc(expect_errors=True) + def test_template_delete_invalid_argument(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-03-01 custom "template-delete" TRUE + ''' + self.assertRegex(errors[0].message, "^Template name must be a string") + + @loader.load_doc(expect_errors=True) + def test_template_delete_unknown(self, entries, errors, options_map): + ''' + plugin "beancount_extras_kris7t.plugins.templates" + + 2020-03-01 custom "template-delete" "taxi" + ''' + self.assertRegex(errors[0].message, "^Unknown template") + + +if __name__ == '__main__': + unittest.main() diff --git a/beancount_extras_kris7t/plugins/transfer_accounts.py b/beancount_extras_kris7t/plugins/transfer_accounts.py new file mode 100644 index 0000000..653a54f --- /dev/null +++ b/beancount_extras_kris7t/plugins/transfer_accounts.py @@ -0,0 +1,188 @@ +''' +Plugin that splits off postings into a new transaction to simulate settlement dates. +''' +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +from collections import defaultdict +import datetime as dt +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union + +from beancount.core import amount, convert +from beancount.core.amount import Amount +from beancount.core.data import Cost, CostSpec, Directive, Entries, Meta, Posting, Open, \ + Transaction +from beancount.core.inventory import Inventory +from beancount.core.number import ZERO + +__plugins__ = ('split_entries_via_transfer_accounts',) + +TRANSFER_ACCOUNT_META = 'transfer-account' +TRANSFER_DATE_META = 'transfer-date' +TRANSFER_CONVERTED_META = 'transfer-converted' +TRANSFER_CONVERTED_DEFAULT = True + + +class TransferAccountError(NamedTuple): + source: Optional[Meta] + message: str + entry: Directive + + +class _OutgoingTransfer(NamedTuple): + accout: str + currency: str + cost: Optional[Union[Cost, CostSpec]] + price: Optional[Amount] + + +class _IncomingTransfer(NamedTuple): + account: str + date: dt.date + + +class _IncomingPostings(NamedTuple): + postings: List[Posting] + inventroy: Inventory + + +class _Splitter: + _entry: Transaction + _processed_entries: Entries + _errors: List[TransferAccountError] + _default_converted: Dict[str, bool] + _processed_postings: List[Posting] + _amounts_to_transfer: Dict[_OutgoingTransfer, Amount] + _new_transactions: Dict[_IncomingTransfer, _IncomingPostings] + + def __init__( + self, + entry: Transaction, + processed_entries: Entries, + errors: List[TransferAccountError], + default_converted: Dict[str, bool]): + self._entry = entry + self._processed_entries = processed_entries + self._errors = errors + self._default_converted = default_converted + self._processed_postings = [] + self._amounts_to_transfer = {} + self._new_transactions = defaultdict(lambda: _IncomingPostings([], Inventory())) + + def split(self) -> None: + for posting in self._entry.postings: + self._split_posting(posting) + if not self._amounts_to_transfer: + self._processed_entries.append(self._entry) + return + for (account, _, cost, price), units in self._amounts_to_transfer.items(): + if units.number != ZERO: + self._processed_postings.append(Posting(account, units, cost, price, None, None)) + self._processed_entries.append(self._entry._replace(postings=self._processed_postings)) + for (account, date), (postings, inv) in self._new_transactions.items(): + for (units, cost) in inv: + postings.append(Posting(account, -units, cost, None, None, None)) + self._processed_entries.append(self._entry._replace(date=date, postings=postings)) + + def _split_posting(self, posting: Posting) -> None: + if not posting.meta: + self._processed_postings.append(posting) + return + transfer_account = posting.meta.pop(TRANSFER_ACCOUNT_META, None) + transfer_date = posting.meta.pop(TRANSFER_DATE_META, None) + transfer_converted = posting.meta.pop(TRANSFER_CONVERTED_META, None) + if transfer_account is None: + if transfer_date is not None: + self._report_error( + f'{TRANSFER_DATE_META} was set but {TRANSFER_ACCOUNT_META} was not') + if transfer_converted is not None: + self._report_error( + f'{TRANSFER_CONVERTED_META} was set but {TRANSFER_ACCOUNT_META} was not') + self._processed_postings.append(posting) + return + if not isinstance(transfer_account, str): + self._report_error( + f'{TRANSFER_ACCOUNT_META} must be a string, got {transfer_account} instead') + self._processed_postings.append(posting) + return + if transfer_date is None: + transfer_date = self._entry.date + elif not isinstance(transfer_date, dt.date): + self._report_error( + f'{TRANSFER_DATE_META} must be a date, got {transfer_date} instead') + transfer_date = self._entry.date + transfer_converted_default = self._default_converted.get( + transfer_account, TRANSFER_CONVERTED_DEFAULT) + if transfer_converted is None: + transfer_converted = transfer_converted_default + elif not isinstance(transfer_converted, bool): + self._report_error( + f'{TRANSFER_CONVERTED_META} must be a Boolean, got {transfer_converted} instead') + transfer_converted = transfer_converted_default + elif posting.price is None and posting.cost is None: + self._report_error( + f'{TRANSFER_CONVERTED_META} was set, but there is no conversion') + assert posting.units + self._split_posting_with_options( + posting, transfer_account, transfer_date, transfer_converted) + + def _split_posting_with_options( + self, + posting: Posting, + transfer_account: str, + transfer_date: dt.date, + transfer_converted: bool) -> None: + incoming = _IncomingTransfer(transfer_account, transfer_date) + incoming_postings, inv = self._new_transactions[incoming] + converted_amount = convert.get_weight(posting) + if transfer_converted: + outgoing = _OutgoingTransfer( + transfer_account, posting.units.currency, posting.cost, posting.price) + self._accumulate_outgoing(outgoing, posting.units) + incoming_postings.append(posting._replace(price=None)) + inv.add_amount(posting.units, posting.cost) + else: + outgoing = _OutgoingTransfer(transfer_account, converted_amount.currency, None, None) + self._accumulate_outgoing(outgoing, converted_amount) + incoming_postings.append(posting) + inv.add_amount(converted_amount) + + def _accumulate_outgoing(self, outgoing: _OutgoingTransfer, units: Amount) -> None: + current_amount = self._amounts_to_transfer.get(outgoing, None) + if current_amount: + self._amounts_to_transfer[outgoing] = amount.add(current_amount, units) + else: + self._amounts_to_transfer[outgoing] = units + + def _report_error(self, message: str) -> None: + self._errors.append(TransferAccountError(self._entry.meta, message, self._entry)) + + +def split_entries_via_transfer_accounts( + entries: Entries, + options_map: Dict[str, Any], + config_str: Optional[str] = None) -> Tuple[Entries, List[TransferAccountError]]: + default_converted: Dict[str, bool] = {} + errors: List[TransferAccountError] = [] + for entry in entries: + if isinstance(entry, Open): + if not entry.meta: + continue + transfer_converted = entry.meta.get(TRANSFER_CONVERTED_META, None) + if transfer_converted is None: + continue + if not isinstance(transfer_converted, bool): + errors.append(TransferAccountError( + entry.meta, + f'{TRANSFER_CONVERTED_META} must be a Boolean,' + + f' got {transfer_converted} instead', + entry)) + default_converted[entry.account] = transfer_converted + processed_entries: Entries = [] + for entry in entries: + if isinstance(entry, Transaction): + splitter = _Splitter(entry, processed_entries, errors, default_converted) + splitter.split() + else: + processed_entries.append(entry) + return processed_entries, errors diff --git a/beancount_extras_kris7t/plugins/transfer_accounts_test.py b/beancount_extras_kris7t/plugins/transfer_accounts_test.py new file mode 100644 index 0000000..0883522 --- /dev/null +++ b/beancount_extras_kris7t/plugins/transfer_accounts_test.py @@ -0,0 +1,628 @@ +__copyright__ = 'Copyright (c) 2020 Kristóf Marussy ' +__license__ = 'GNU GPLv2' + +import unittest + +from beancount import loader +from beancount.parser import cmptest + + +class TestTransferAccounts(cmptest.TestCase): + + @loader.load_doc() + def test_same_currency(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Expenses:Taxi + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Liabilities:CreditCard 20 USD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Liabilities:CreditCard -20 USD + ''', entries) + + @loader.load_doc() + def test_missing_date(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Expenses:Taxi + transfer-account: Liabilities:CreditCard + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Liabilities:CreditCard 20 USD + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Liabilities:CreditCard -20 USD + ''', entries) + + @loader.load_doc(expect_errors=True) + def test_missing_account_with_date(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Expenses:Taxi + transfer-date: 2020-03-10 + ''' + self.assertRegex(errors[0].message, 'transfer-date was set but transfer-account was not') + + @loader.load_doc(expect_errors=True) + def test_missing_account_with_conversion(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-converted: FALSE + ''' + self.assertRegex( + errors[0].message, 'transfer-converted was set but transfer-account was not') + + @loader.load_doc(expect_errors=True) + def test_invalid_account(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Expenses:Taxi + transfer-account: 2020-03-10 + ''' + self.assertRegex(errors[0].message, 'transfer-account must be a string.*') + + @loader.load_doc(expect_errors=True) + def test_invalid_date(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Expenses:Taxi + transfer-account: Liabilities:CreditCard + transfer-date: "Ides of March" + ''' + self.assertRegex(errors[0].message, 'transfer-date must be a date.*') + + @loader.load_doc(expect_errors=True) + def test_invalid_conversion(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-converted: "indeed" + ''' + self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*') + + @loader.load_doc(expect_errors=True) + def test_invalid_account_conversion(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Liabilities:CreditCard + transfer-converted: "Indeed" + ''' + self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*') + + @loader.load_doc(expect_errors=True) + def test_redundant_conversion(self, _, errors, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -20 USD + Expenses:Taxi + transfer-account: Liabilities:CreditCard + transfer-converted: TRUE + ''' + self.assertRegex( + errors[0].message, 'transfer-converted was set, but there is no conversion.*') + + @loader.load_doc() + def test_converted_price_false(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: FALSE + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 25.60 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD @ 1.28 CAD + Liabilities:CreditCard -25.60 CAD + ''', entries) + + @loader.load_doc() + def test_converted_price_true(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: TRUE + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 20 USD @ 1.28 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Liabilities:CreditCard -20 USD + ''', entries) + + @loader.load_doc() + def test_converted_price_default(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 20 USD @ 1.28 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Liabilities:CreditCard -20 USD + ''', entries) + + @loader.load_doc() + def test_converted_price_account_false(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + transfer-converted: FALSE + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 25.60 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD @ 1.28 CAD + Liabilities:CreditCard -25.60 CAD + ''', entries) + + @loader.load_doc() + def test_converted_price_account_true(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + transfer-converted: TRUE + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 20 USD @ 1.28 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Liabilities:CreditCard -20 USD + ''', entries) + + @loader.load_doc() + def test_converted_cost_false(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD {1.28 CAD} + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: FALSE + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 25.60 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} + Liabilities:CreditCard -25.60 CAD + ''', entries) + + @loader.load_doc() + def test_converted_cost_true(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD {1.28 CAD} + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: TRUE + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15} + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} + Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15} + ''', entries) + + @loader.load_doc() + def test_converted_cost_and_price_false(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: FALSE + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 25.60 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD + Liabilities:CreditCard -25.60 CAD + ''', entries) + + @loader.load_doc() + def test_converted_cost_and_price_true(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: TRUE + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + + 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi + Assets:Checking -25.60 CAD + Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD + + 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi + Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} + Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15} + ''', entries) + + @loader.load_doc() + def test_multiple_separate(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -45 USD + Expenses:Taxi 20 USD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + Expenses:Food 25 USD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-12 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -45 USD + Liabilities:CreditCard 45 USD + + 2020-03-10 * "Night out in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Liabilities:CreditCard -20 USD + + 2020-03-12 * "Night out in Brooklyn" ^taxi + Expenses:Food 25 USD + Liabilities:CreditCard -25 USD + ''', entries) + + @loader.load_doc() + def test_multiple_merge(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -45 USD + Expenses:Taxi 20 USD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + Expenses:Food 25 USD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -45 USD + Liabilities:CreditCard 45 USD + + 2020-03-10 * "Night out in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Expenses:Food 25 USD + Liabilities:CreditCard -45 USD + ''', entries) + + @loader.load_doc() + def test_multiple_currencies_merge_converted_false(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -50.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: FALSE + Expenses:Food 25 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -50.60 CAD + Liabilities:CreditCard 50.60 CAD + + 2020-03-10 * "Night out in Brooklyn" ^taxi + Expenses:Taxi 20 USD @ 1.28 CAD + Expenses:Food 25 CAD + Liabilities:CreditCard -50.60 CAD + ''', entries) + + @loader.load_doc() + def test_multiple_currencies_merge_converted_true(self, entries, _, __): + ''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -50.60 CAD + Expenses:Taxi 20 USD @ 1.28 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + transfer-converted: TRUE + Expenses:Food 25 CAD + transfer-account: Liabilities:CreditCard + transfer-date: 2020-03-10 + ''' + self.assertEqualEntries(''' + plugin "beancount_extras_kris7t.plugins.transfer_accounts" + + 2020-01-01 open Assets:Checking + 2020-01-01 open Liabilities:CreditCard + 2020-01-01 open Expenses:Taxi + 2020-01-01 open Expenses:Food + + 2020-03-15 * "Night out in Brooklyn" ^taxi + Assets:Checking -50.60 CAD + Liabilities:CreditCard 20 USD @ 1.28 CAD + Liabilities:CreditCard 25 CAD + + 2020-03-10 * "Night out in Brooklyn" ^taxi + Expenses:Taxi 20 USD + Expenses:Food 25 CAD + Liabilities:CreditCard -20 USD + Liabilities:CreditCard -25 CAD + ''', entries) + + +if __name__ == '__main__': + unittest.main() diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..a98d7fb --- /dev/null +++ b/mypy.ini @@ -0,0 +1,14 @@ +[mypy] +check_untyped_defs = True +ignore_errors = False +ignore_missing_imports = True +strict_optional = True +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True + +[mypy-beancount.*] +ignore_missing_imports = True + +[mypy-pdfminer.*] +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index c371393..434a066 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5,7 +5,6 @@ description = "Atomic file writes." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -marker = "sys_platform == \"win32\"" [[package]] name = "attrs" @@ -16,10 +15,10 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] -dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope-interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope-interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope-interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] [[package]] name = "beancount" @@ -40,9 +39,8 @@ python-dateutil = "*" python-magic = "*" [package.source] -url = "beancount" -reference = "" type = "directory" +url = "beancount" [[package]] name = "beautifulsoup4" @@ -52,18 +50,16 @@ category = "main" optional = false python-versions = "*" +[package.dependencies] +soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} + [package.extras] html5lib = ["html5lib"] lxml = ["lxml"] -[package.dependencies] -[package.dependencies.soupsieve] -version = ">1.2" -python = ">=3.0" - [[package]] name = "cachetools" -version = "4.2.0" +version = "4.2.1" description = "Extensible memoizing collections and decorators" category = "main" optional = false @@ -77,6 +73,17 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "cffi" +version = "1.14.4" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + [[package]] name = "chardet" version = "4.0.0" @@ -92,7 +99,38 @@ description = "Cross-platform colored terminal text." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -marker = "sys_platform == \"win32\"" + +[[package]] +name = "cryptography" +version = "3.3.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" + +[package.dependencies] +cffi = ">=1.12" +six = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=3.6.0,!=3.9.0,!=3.9.1,!=3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" [[package]] name = "google-api-core" @@ -102,20 +140,19 @@ category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" -[package.extras] -grpc = ["grpcio (>=1.29.0,<2.0dev)"] -grpcgcp = ["grpcio-gcp (>=0.2.2)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] - [package.dependencies] google-auth = ">=1.21.1,<2.0dev" googleapis-common-protos = ">=1.6.0,<2.0dev" protobuf = ">=3.12.0" pytz = "*" requests = ">=2.18.0,<3.0.0dev" -setuptools = ">=40.3.0" six = ">=1.13.0" +[package.extras] +grpc = ["grpcio (>=1.29.0,<2.0dev)"] +grpcgcp = ["grpcio-gcp (>=0.2.2)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] + [[package]] name = "google-api-python-client" version = "1.12.8" @@ -140,18 +177,14 @@ category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" -[package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"] - [package.dependencies] cachetools = ">=2.0.0,<5.0" pyasn1-modules = ">=0.2.1" -setuptools = ">=40.3.0" +rsa = {version = ">=3.1.4,<5", markers = "python_version >= \"3.6\""} six = ">=1.9.0" -[package.dependencies.rsa] -version = ">=3.1.4,<5" -python = ">=3.6" +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"] [[package]] name = "google-auth-httplib2" @@ -174,12 +207,12 @@ category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -[package.extras] -grpc = ["grpcio (>=1.0.0)"] - [package.dependencies] protobuf = ">=3.6.0" +[package.extras] +grpc = ["grpcio (>=1.0.0)"] + [[package]] name = "httplib2" version = "0.18.1" @@ -204,6 +237,49 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "jeepney" +version = "0.6.0" +description = "Low-level, pure Python DBus protocol wrapper." +category = "main" +optional = true +python-versions = ">=3.6" + +[package.extras] +test = ["pytest", "pytest-trio", "pytest-asyncio", "testpath", "trio"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy" +version = "0.800" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "packaging" version = "20.8" @@ -215,6 +291,23 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" +[[package]] +name = "pdfminer-six" +version = "20201018" +description = "PDF parser and analyzer" +category = "main" +optional = false +python-versions = ">=3.4" + +[package.dependencies] +chardet = {version = "*", markers = "python_version > \"3.0\""} +cryptography = "*" +sortedcontainers = "*" + +[package.extras] +dev = ["nose", "tox"] +docs = ["sphinx", "sphinx-argparse"] + [[package]] name = "pluggy" version = "0.13.1" @@ -272,6 +365,30 @@ python-versions = "*" [package.dependencies] pyasn1 = ">=0.4.6,<0.5.0" +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycparser" +version = "2.20" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pyparsing" version = "2.4.7" @@ -288,19 +405,19 @@ category = "main" optional = false python-versions = ">=3.6" -[package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] - [package.dependencies] -atomicwrites = ">=1.0" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<1.0.0a1" py = ">=1.8.2" toml = "*" +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.8.1" @@ -336,16 +453,16 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] - [package.dependencies] certifi = ">=2017.4.17" chardet = ">=3.0.2,<5" idna = ">=2.5,<3" urllib3 = ">=1.21.1,<1.27" +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] + [[package]] name = "rsa" version = "4.7" @@ -353,11 +470,22 @@ description = "Pure-Python RSA implementation" category = "main" optional = false python-versions = ">=3.5, <4" -marker = "python_version >= \"3.6\"" [package.dependencies] pyasn1 = ">=0.1.3" +[[package]] +name = "secretstorage" +version = "3.3.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + [[package]] name = "six" version = "1.15.0" @@ -366,6 +494,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "sortedcontainers" +version = "2.3.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "soupsieve" version = "2.1" @@ -373,7 +509,6 @@ description = "A modern CSS selector implementation for Beautiful Soup." category = "main" optional = false python-versions = ">=3.5" -marker = "python_version >= \"3.0\"" [[package]] name = "toml" @@ -383,6 +518,22 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "typed-ast" +version = "1.4.2" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "uritemplate" version = "3.0.1" @@ -402,12 +553,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[extras] +transferwise = ["requests", "SecretStorage"] [metadata] -lock-version = "1.0" +lock-version = "1.1" python-versions = "^3.9" -content-hash = "cf1dc1359aafb7aaf42ede043b1d28b1bcb215cff5851d7d9f5fe2012bb796d1" +content-hash = "605e14048928ef9c444b6e0b8d4722909cf65ba42094bffa743bb1881c61d898" [metadata.files] atomicwrites = [ @@ -425,13 +579,51 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] cachetools = [ - {file = "cachetools-4.2.0-py3-none-any.whl", hash = "sha256:c6b07a6ded8c78bf36730b3dc452dfff7d95f2a12a2fed856b1a0cb13ca78c61"}, - {file = "cachetools-4.2.0.tar.gz", hash = "sha256:3796e1de094f0eaca982441c92ce96c68c89cced4cd97721ab297ea4b16db90e"}, + {file = "cachetools-4.2.1-py3-none-any.whl", hash = "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2"}, + {file = "cachetools-4.2.1.tar.gz", hash = "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, ] +cffi = [ + {file = "cffi-1.14.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775"}, + {file = "cffi-1.14.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06"}, + {file = "cffi-1.14.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26"}, + {file = "cffi-1.14.4-cp27-cp27m-win32.whl", hash = "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c"}, + {file = "cffi-1.14.4-cp27-cp27m-win_amd64.whl", hash = "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b"}, + {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d"}, + {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca"}, + {file = "cffi-1.14.4-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698"}, + {file = "cffi-1.14.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b"}, + {file = "cffi-1.14.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293"}, + {file = "cffi-1.14.4-cp35-cp35m-win32.whl", hash = "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2"}, + {file = "cffi-1.14.4-cp35-cp35m-win_amd64.whl", hash = "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7"}, + {file = "cffi-1.14.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"}, + {file = "cffi-1.14.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362"}, + {file = "cffi-1.14.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec"}, + {file = "cffi-1.14.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b"}, + {file = "cffi-1.14.4-cp36-cp36m-win32.whl", hash = "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668"}, + {file = "cffi-1.14.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009"}, + {file = "cffi-1.14.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb"}, + {file = "cffi-1.14.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d"}, + {file = "cffi-1.14.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03"}, + {file = "cffi-1.14.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01"}, + {file = "cffi-1.14.4-cp37-cp37m-win32.whl", hash = "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e"}, + {file = "cffi-1.14.4-cp37-cp37m-win_amd64.whl", hash = "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35"}, + {file = "cffi-1.14.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d"}, + {file = "cffi-1.14.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b"}, + {file = "cffi-1.14.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53"}, + {file = "cffi-1.14.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e"}, + {file = "cffi-1.14.4-cp38-cp38-win32.whl", hash = "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d"}, + {file = "cffi-1.14.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375"}, + {file = "cffi-1.14.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909"}, + {file = "cffi-1.14.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd"}, + {file = "cffi-1.14.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a"}, + {file = "cffi-1.14.4-cp39-cp39-win32.whl", hash = "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3"}, + {file = "cffi-1.14.4-cp39-cp39-win_amd64.whl", hash = "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b"}, + {file = "cffi-1.14.4.tar.gz", hash = "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c"}, +] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, @@ -440,6 +632,26 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +cryptography = [ + {file = "cryptography-3.3.1-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030"}, + {file = "cryptography-3.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0"}, + {file = "cryptography-3.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812"}, + {file = "cryptography-3.3.1-cp27-cp27m-win32.whl", hash = "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e"}, + {file = "cryptography-3.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901"}, + {file = "cryptography-3.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d"}, + {file = "cryptography-3.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5"}, + {file = "cryptography-3.3.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302"}, + {file = "cryptography-3.3.1-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244"}, + {file = "cryptography-3.3.1-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c"}, + {file = "cryptography-3.3.1-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c"}, + {file = "cryptography-3.3.1-cp36-abi3-win32.whl", hash = "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a"}, + {file = "cryptography-3.3.1-cp36-abi3-win_amd64.whl", hash = "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7"}, + {file = "cryptography-3.3.1.tar.gz", hash = "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6"}, +] +flake8 = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] google-api-core = [ {file = "google-api-core-1.25.0.tar.gz", hash = "sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae"}, {file = "google_api_core-1.25.0-py2.py3-none-any.whl", hash = "sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb"}, @@ -472,10 +684,50 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] +jeepney = [ + {file = "jeepney-0.6.0-py3-none-any.whl", hash = "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae"}, + {file = "jeepney-0.6.0.tar.gz", hash = "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy = [ + {file = "mypy-0.800-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a"}, + {file = "mypy-0.800-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641"}, + {file = "mypy-0.800-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496"}, + {file = "mypy-0.800-cp35-cp35m-win_amd64.whl", hash = "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876"}, + {file = "mypy-0.800-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9"}, + {file = "mypy-0.800-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf"}, + {file = "mypy-0.800-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363"}, + {file = "mypy-0.800-cp36-cp36m-win_amd64.whl", hash = "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b"}, + {file = "mypy-0.800-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f"}, + {file = "mypy-0.800-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19"}, + {file = "mypy-0.800-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa"}, + {file = "mypy-0.800-cp37-cp37m-win_amd64.whl", hash = "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86"}, + {file = "mypy-0.800-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf"}, + {file = "mypy-0.800-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0"}, + {file = "mypy-0.800-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b"}, + {file = "mypy-0.800-cp38-cp38-win_amd64.whl", hash = "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738"}, + {file = "mypy-0.800-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25"}, + {file = "mypy-0.800-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb"}, + {file = "mypy-0.800-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488"}, + {file = "mypy-0.800-cp39-cp39-win_amd64.whl", hash = "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07"}, + {file = "mypy-0.800-py3-none-any.whl", hash = "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653"}, + {file = "mypy-0.800.tar.gz", hash = "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] packaging = [ {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, ] +pdfminer-six = [ + {file = "pdfminer.six-20201018-py3-none-any.whl", hash = "sha256:d78877ba8d8bf957f3bb636c4f73f4f6f30f56c461993877ac22c39c20837509"}, + {file = "pdfminer.six-20201018.tar.gz", hash = "sha256:b9aac0ebeafb21c08bf65f2039f4b2c5f78a3449d0a41df711d72445649e952a"}, +] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -538,6 +790,18 @@ pyasn1-modules = [ {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, ] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pycparser = [ + {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, + {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, @@ -566,10 +830,18 @@ rsa = [ {file = "rsa-4.7-py3-none-any.whl", hash = "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"}, {file = "rsa-4.7.tar.gz", hash = "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4"}, ] +secretstorage = [ + {file = "SecretStorage-3.3.0-py3-none-any.whl", hash = "sha256:5c36f6537a523ec5f969ef9fad61c98eb9e017bc601d811e53aa25bece64892f"}, + {file = "SecretStorage-3.3.0.tar.gz", hash = "sha256:30cfdef28829dad64d6ea1ed08f8eff6aa115a77068926bcc9f5225d5a3246aa"}, +] six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] +sortedcontainers = [ + {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, + {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, +] soupsieve = [ {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"}, {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"}, @@ -578,6 +850,43 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +typed-ast = [ + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, + {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, + {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, + {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, + {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, + {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, + {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, + {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, + {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, + {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, + {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, + {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, + {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, + {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, + {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, + {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] uritemplate = [ {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, diff --git a/pyproject.toml b/pyproject.toml index da65f08..4f749a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,23 @@ [tool.poetry] name = "beancount-extras-kris7t" version = "0.1.0" -description = "Miscellaneous plugins, importers and price sources for Beancount" +description = "Miscellaneous plugins and importers for Beancount" authors = ["Kristóf Marussy "] license = "GPL-2.0-only" [tool.poetry.dependencies] python = "^3.9" beancount = { path = "beancount", develop = true } +"pdfminer.six" = "^20201018" +requests = { version = "^2.25.1", optional = true } +SecretStorage = { version = "^3.3.0", optional = true } [tool.poetry.dev-dependencies] +flake8 = "^3.8.4" +mypy = "^0.800" + +[tool.poetry.extras] +transferwise = ["requests", "SecretStorage"] [build-system] requires = ["poetry>=0.12"] -- cgit v1.2.3-54-g00ecf