aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.flake816
-rw-r--r--.gitignore33
-rw-r--r--beancount_extras_kris7t/importers/__init__.py0
-rw-r--r--beancount_extras_kris7t/importers/hetzner_pdf.py136
-rw-r--r--beancount_extras_kris7t/importers/otpbank/__init__.py0
-rw-r--r--beancount_extras_kris7t/importers/otpbank/otpbank_csv.py370
-rw-r--r--beancount_extras_kris7t/importers/otpbank/otpbank_pdf.py187
-rw-r--r--beancount_extras_kris7t/importers/rules.py134
-rw-r--r--beancount_extras_kris7t/importers/transferwise/__init__.py0
-rw-r--r--beancount_extras_kris7t/importers/transferwise/__main__.py11
-rw-r--r--beancount_extras_kris7t/importers/transferwise/client.py236
-rw-r--r--beancount_extras_kris7t/importers/transferwise/transferwise_json.py377
-rw-r--r--beancount_extras_kris7t/importers/utils.py125
-rw-r--r--beancount_extras_kris7t/plugins/__init__.py0
-rw-r--r--beancount_extras_kris7t/plugins/closing_balance.py70
-rw-r--r--beancount_extras_kris7t/plugins/closing_balance_test.py124
-rw-r--r--beancount_extras_kris7t/plugins/default_tolerance.py47
-rw-r--r--beancount_extras_kris7t/plugins/default_tolerance_test.py104
-rw-r--r--beancount_extras_kris7t/plugins/selective_implicit_prices.py157
-rw-r--r--beancount_extras_kris7t/plugins/selective_implicit_prices_test.py410
-rw-r--r--beancount_extras_kris7t/plugins/templates.py144
-rw-r--r--beancount_extras_kris7t/plugins/templates_test.py326
-rw-r--r--beancount_extras_kris7t/plugins/transfer_accounts.py188
-rw-r--r--beancount_extras_kris7t/plugins/transfer_accounts_test.py628
-rw-r--r--mypy.ini14
-rw-r--r--poetry.lock401
-rw-r--r--pyproject.toml10
27 files changed, 4201 insertions, 47 deletions
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..d000b15
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,16 @@
1[flake8]
2exclude =
3 .git
4 __pycache__
5 setup.py
6 build
7 dist
8 releases
9 .venv
10 .tox
11 .mypy_cache
12 .pytest_cache
13 .vscode
14 .github
15 beancount
16max-line-length = 99
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dbcfed5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
1*.pyc
2
3# Packages
4*.egg
5/*.egg-info
6/dist/*
7build
8_build
9.cache
10*.so
11
12# Installer logs
13pip-log.txt
14
15# Unit test / coverage reports
16.coverage
17.tox
18.pytest_cache
19
20.DS_Store
21.idea/*
22.python-version
23.vscode/*
24
25/setup.cfg
26MANIFEST.in
27/setup.py
28.mypy_cache
29
30.venv
31/releases/*
32pip-wheel-metadata
33/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
--- /dev/null
+++ b/beancount_extras_kris7t/importers/__init__.py
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 @@
1'''
2Importer for Hetzner PDF invoices.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7import datetime as dt
8import logging
9import re
10from typing import cast, Iterable, Optional
11
12from beancount.core import amount as am, data
13from beancount.core.amount import Amount
14from beancount.core.flags import FLAG_OKAY, FLAG_WARNING
15from beancount.core.number import D
16from beancount.ingest.cache import _FileMemo as FileMemo
17from beancount.ingest.importer import ImporterProtocol
18
19from pdfminer.high_level import extract_pages
20from pdfminer.layout import LTPage, LTTextContainer
21
22from beancount_extras_kris7t.importers.utils import MISSING_AMOUNT
23
24INVOICE_REGEX = re.compile(
25 r'.*Hetzner_(?P<date>\d{4}-\d{2}-\d{2})_(?P<number>R\d+)\.pdf$', re.IGNORECASE)
26AMOUNT_REGEX = re.compile(r'Amount due: € (?P<amount>\d+(\.\d+)?)', re.IGNORECASE)
27BALANCE_REGEX = re.compile(
28 'The amount has been charged to the credit balance on your client credit account.',
29 re.IGNORECASE)
30CARD_REGEX = re.compile(
31 'The invoice amount will soon be debited from your credit card.',
32 re.IGNORECASE)
33MIXED_REGEX = re.compile(
34 r'The amount of € (?P<balance>\d+(\.\d+)?) has been charged to the credit balance ' +
35 r'on your client credit account. The remaining amount of € (?P<card>\d(\.\d+)?) ' +
36 'will be debited by credit card in the next few days.',
37 re.IGNORECASE)
38
39
40def _extract_match(pages: Iterable[LTPage], pattern: re.Pattern) -> Optional[re.Match]:
41 for page in pages:
42 for element in page:
43 if isinstance(element, LTTextContainer):
44 text = element.get_text().strip().replace('\n', ' ')
45 if match := pattern.match(text):
46 return match
47 return None
48
49
50class Importer(ImporterProtocol):
51 '''
52 Importer for Hetzner PDF invoices.
53 '''
54
55 _log: logging.Logger
56 _liability: str
57 _credit_balance: str
58 _expense: str
59
60 def __init__(self, liability: str, credit_balance: str, expense: str):
61 self._log = logging.getLogger(type(self).__qualname__)
62 self._liability = liability
63 self._credit_balance = credit_balance
64 self._expense = expense
65
66 def identify(self, file: FileMemo) -> bool:
67 return INVOICE_REGEX.match(file.name) is not None
68
69 def file_name(self, file: FileMemo) -> str:
70 if match := INVOICE_REGEX.match(file.name):
71 number = match.group('number')
72 return f'Hetzner_{number}.pdf'
73 else:
74 raise RuntimeError(f'Not an invoice: {file.name}')
75
76 def file_account(self, file: FileMemo) -> str:
77 if INVOICE_REGEX.match(file.name) is None:
78 raise RuntimeError(f'Not an invoice: {file.name}')
79 else:
80 return self._liability
81
82 def file_date(self, file: FileMemo) -> dt.date:
83 if match := INVOICE_REGEX.match(file.name):
84 date_str = match.group('date')
85 return dt.datetime.strptime(date_str, '%Y-%m-%d').date()
86 else:
87 raise RuntimeError(f'Not an invoice: {file.name}')
88
89 def extract(self, file: FileMemo) -> data.Entries:
90 if match := INVOICE_REGEX.match(file.name):
91 invoice_number = match.group('number')
92 else:
93 self._log.warn('Not an invoice: %s', file.name)
94 return []
95 date = self.file_date(file)
96 pages = [page for page in cast(Iterable[LTPage], extract_pages(file.name))]
97 if match := _extract_match(pages, AMOUNT_REGEX):
98 number = D(match.group('amount'))
99 assert number is not None
100 amount = Amount(number, 'EUR')
101 else:
102 self._log.warn('Not amount found in %s', file.name)
103 return []
104 postings = []
105 flag = FLAG_OKAY
106 if _extract_match(pages, BALANCE_REGEX) is not None:
107 postings.append(
108 data.Posting(self._credit_balance, -amount, None, None, None, None))
109 elif _extract_match(pages, CARD_REGEX) is not None:
110 postings.append(
111 data.Posting(self._liability, -amount, None, None, None, None))
112 elif match := _extract_match(pages, MIXED_REGEX):
113 balance_number = D(match.group('balance'))
114 assert balance_number is not None
115 balance_amount = Amount(balance_number, 'EUR')
116 postings.append(
117 data.Posting(self._credit_balance, -balance_amount, None, None, None, None))
118 card_number = D(match.group('card'))
119 assert card_number is not None
120 card_amount = Amount(card_number, 'EUR')
121 postings.append(
122 data.Posting(self._liability, -card_amount, None, None, None, None))
123 if am.add(balance_amount, card_amount) != amount:
124 self._log.warn('Payments do not cover total amount in %s', file.name)
125 flag = FLAG_WARNING
126 else:
127 self._log.warn('Unknown payment method in %s', file.name)
128 flag = FLAG_WARNING
129 if flag == FLAG_OKAY:
130 amount = MISSING_AMOUNT
131 postings.append(
132 data.Posting(self._expense, amount, None, None, None, None))
133 return [
134 data.Transaction(data.new_metadata(file.name, 0), date, flag, 'Hetzner', 'Invoice',
135 set(), {f'hetzner_{invoice_number}'}, postings)
136 ]
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
--- /dev/null
+++ b/beancount_extras_kris7t/importers/otpbank/__init__.py
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 @@
1'''
2Importer for OTP Bank CSV transaction history.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7import csv
8import datetime as dt
9from decimal import Decimal
10import logging
11from typing import Callable, Dict, Iterable, List, NamedTuple, Optional
12import re
13from os import path
14
15import beancount.core.amount as am
16from beancount.core.amount import Amount
17from beancount.core import data
18from beancount.core.number import ZERO
19from beancount.ingest.cache import _FileMemo as FileMemo
20from beancount.ingest.importer import ImporterProtocol
21
22from beancount_extras_kris7t.importers import utils
23from beancount_extras_kris7t.importers.utils import COMMENT_META, InvalidEntry, PAYEE_META, \
24 Posting
25from beancount_extras_kris7t.plugins.transfer_accounts import TRANSFER_ACCOUNT_META, \
26 TRANSFER_DATE_META
27
28OTP_BANK = 'OTP Bank'
29BOOKING_DATE_META = 'booking-date'
30ENTRY_TYPE_META = 'otpbank-entry-type'
31OTPBANK_CSV_TAG = 'otpbank-csv'
32PAYPASS_TAG = 'paypass'
33SIMPLE_TAG = 'processor-simple'
34CARD_REGEX = re.compile(
35 r'^(?P<date>\d{4}\.\d{2}\.\d{2})\s+(?P<card_number>\d+)')
36PAYPASS_REGEX = re.compile(r'-ÉRINT(Ő|\?)|PPASS$', re.IGNORECASE)
37SIMPLE_REGEX = re.compile(r'-SIMPLE$', re.IGNORECASE)
38SMS_REGEX = re.compile(
39 r'^(?P<date>\d{4}\.\d{2}\.\d{2})\s+\((?P<count>\d+) DB SMS\)',
40 re.IGNORECASE)
41
42
43def _parse_date(date_str: str) -> dt.date:
44 return utils.parse_date(date_str, '%Y%m%d')
45
46
47def _parse_card_date(date_str: str) -> dt.date:
48 return utils.parse_date(date_str, '%Y.%m.%d')
49
50
51def _parse_number(amount_str: str) -> Decimal:
52 cleaned_str = amount_str.replace('.', '').replace(',', '.')
53 return utils.parse_number(cleaned_str)
54
55
56def _validate_cd(amount: Amount, cd: str) -> None:
57 if cd == 'T':
58 if amount.number >= ZERO:
59 raise InvalidEntry(f'Invalid debit amount: {amount}')
60 elif cd == 'J':
61 if amount.number <= ZERO:
62 raise InvalidEntry(f'Invalid credit amount: {amount}')
63 else:
64 raise InvalidEntry(f'Invalid credit/debit type: {cd}')
65
66
67class Conversion(NamedTuple):
68 foreign_amount: Amount
69 foreign_rate: Amount
70 conversion_fee: Optional[Posting]
71
72
73class Card(NamedTuple):
74 card_account: str
75 card_date: dt.date
76
77
78class Row(utils.Row):
79 account_name: str
80 cd: str
81 native_amount: Amount
82 booking_date: dt.date
83 value_date: dt.date
84 _raw_payee: Optional[str]
85 _raw_comment: str
86 _conversion: Optional[Conversion]
87 _card: Optional[Card]
88
89 def __init__(self, file_name: str, index: int, row: List[str], accounts: Dict[str, str]):
90 account_number, cd, amount_str, currency, booking_date_str, \
91 value_date_str, _, _, payee, comment1, comment2, comment3, entry_type, _, _ = row
92 comment = f'{comment1}{comment2}{comment3}'.strip()
93 super().__init__(file_name, index, entry_type, payee, comment)
94 if account_name := accounts.get(account_number, None):
95 self.account_name = account_name
96 else:
97 raise InvalidEntry(f'Unknown account number {account_number}')
98 self.cd = cd
99 self.native_amount = Amount(_parse_number(amount_str), currency)
100 _validate_cd(self.native_amount, self.cd)
101 self.booking_date = _parse_date(booking_date_str)
102 self.value_date = _parse_date(value_date_str)
103 self.tags.add(OTPBANK_CSV_TAG)
104 self._raw_payee = payee
105 self._raw_comment = comment
106 self._conversion = None
107 self._card = None
108
109 @property
110 def conversion(self) -> Optional[Conversion]:
111 return self._conversion
112
113 @conversion.setter
114 def conversion(self, conversion: Optional[Conversion]) -> None:
115 if self._conversion:
116 raise InvalidEntry(
117 f'Conversion {self._conversion} was already set for row ' +
118 f' when trying to set it to {conversion}')
119 self._conversion = conversion
120
121 @property
122 def card(self) -> Optional[Card]:
123 return self._card
124
125 @card.setter
126 def card(self, card: Optional[Card]) -> None:
127 if self._card:
128 raise InvalidEntry(
129 f'Card {self._card} was already set for row ' +
130 f' when trying to set it to {card}')
131 self._card = card
132
133 @property
134 def transacted_amount(self) -> Amount:
135 if self.conversion:
136 return self.conversion.foreign_amount
137 return self.native_amount
138
139 @property
140 def _extended_meta(self) -> data.Meta:
141 meta = dict(self.meta)
142 if self.entry_type:
143 meta[ENTRY_TYPE_META] = self.entry_type
144 if self.booking_date != self.value_date:
145 meta[BOOKING_DATE_META] = self.booking_date
146 if self._raw_payee:
147 meta[PAYEE_META] = self._raw_payee
148 if self._raw_comment:
149 meta[COMMENT_META] = self._raw_comment
150 return meta
151
152 @property
153 def _card_meta(self) -> Optional[data.Meta]:
154 if self._card:
155 card_meta: data.Meta = {
156 TRANSFER_ACCOUNT_META: self._card.card_account
157 }
158 if self._card.card_date != self.value_date:
159 card_meta[TRANSFER_DATE_META] = self._card.card_date
160 return card_meta
161 else:
162 return None
163
164 def _to_transaction(self) -> data.Transaction:
165 if not self.postings:
166 raise InvalidEntry('No postings were extracted from this entry')
167 if self.payee == '':
168 payee = None
169 else:
170 payee = self.payee
171 if self.comment == '' or self.comment == self.payee:
172 if payee:
173 desc = ''
174 else:
175 desc = self.entry_type or ''
176 else:
177 desc = self.comment
178 if self._conversion:
179 foreign_rate = self._conversion.foreign_rate
180 conversion_fee = self._conversion.conversion_fee
181 else:
182 foreign_rate = None
183 conversion_fee = None
184 meta = self._extended_meta
185 card_meta = self._card_meta
186 postings = [
187 data.Posting(self.account_name, self.native_amount, None, None, None, None)
188 ]
189 if len(self.postings) == 1 and not foreign_rate:
190 account, amount = self.postings[0]
191 postings.append(
192 data.Posting(account, utils.MISSING_AMOUNT, None, None, None, card_meta))
193 else:
194 for account, amount in self.postings:
195 postings.append(
196 data.Posting(account, -amount, None, foreign_rate, None, card_meta))
197 if conversion_fee and conversion_fee.amount.number != ZERO:
198 account, amount = conversion_fee
199 postings.append(
200 data.Posting(account, amount, None, None, None, None))
201 return data.Transaction(
202 meta, self.value_date, self.flag, payee, desc, self.tags, self.links, postings)
203
204
205Extractor = Callable[[Row], None]
206
207
208def extract_foreign_currencies(
209 currencies: Iterable[str], conversion_fees_account: str) -> Extractor:
210 currencies_joined = '|'.join(currencies)
211 currency_regex = re.compile(
212 r'(?P<amount>\d+(,\d+)?)(?P<currency>' + currencies_joined
213 + r')\s+(?P<rate>\d+(,\d+)?)$', re.IGNORECASE)
214
215 def do_extract(row: Row) -> None:
216 if currency_match := currency_regex.search(row.comment):
217 foreign_amount_d = _parse_number(currency_match.group('amount'))
218 if row.cd == 'T':
219 foreign_amount_d *= -1
220 foreign_currency = currency_match.group('currency')
221 foreign_amount = Amount(
222 foreign_amount_d, foreign_currency.upper())
223 foreign_rate_d = _parse_number(currency_match.group('rate'))
224 foreign_rate = Amount(
225 foreign_rate_d, row.native_amount.currency)
226 converted_amount = am.mul(foreign_rate, foreign_amount_d)
227 conversion_fee_amount = am.sub(
228 converted_amount, row.native_amount)
229 conversion_fee = Posting(
230 conversion_fees_account, conversion_fee_amount)
231 row.conversion = Conversion(
232 foreign_amount, foreign_rate, conversion_fee)
233 row.comment = row.comment[:currency_match.start()].strip()
234 return do_extract
235
236
237def extract_cards(card_accounts: Dict[str, str]) -> Extractor:
238 def do_extract(row: Row) -> None:
239 if row.entry_type.lower() not in [
240 'vásárlás kártyával',
241 'bankkártyával kapcs. díj',
242 ]:
243 return
244 if card_match := CARD_REGEX.search(row.comment):
245 card_number = card_match.group('card_number')
246 if card_number not in card_accounts:
247 raise InvalidEntry(f'No account for card {card_number}')
248 card_account = card_accounts[card_number]
249 card_date = _parse_card_date(card_match.group('date'))
250 row.card = Card(card_account, card_date)
251 row.comment = row.comment[card_match.end():].strip()
252 else:
253 raise InvalidEntry(
254 f'Cannot extract card information from: {row.comment}')
255 if paypass_match := PAYPASS_REGEX.search(row.comment):
256 row.comment = row.comment[:paypass_match.start()].strip()
257 row.tags.add(PAYPASS_TAG)
258 elif simple_match := PAYPASS_REGEX.search(row.comment):
259 row.comment = row.comment[:simple_match.start()].strip()
260 row.tags.add(SIMPLE_TAG)
261 return do_extract
262
263
264def extract_sms(sms: str, sms_liability: str, sms_expense: str) -> Extractor:
265 def do_extract(row: Row) -> None:
266 if row.entry_type != 'OTPdirekt ÜZENETDÍJ':
267 return
268 if sms_match := SMS_REGEX.search(row.comment):
269 card_account = sms_liability
270 card_date = _parse_card_date(sms_match.group('date'))
271 row.card = Card(card_account, card_date)
272 sms_amount_d = _parse_number(sms_match.group('count'))
273 foreign_amount = -Amount(sms_amount_d, sms)
274 foreign_rate = am.div(-row.native_amount, sms_amount_d)
275 row.conversion = Conversion(
276 foreign_amount, foreign_rate, None)
277 row.comment = 'SMS díj'
278 row.payee = OTP_BANK
279 row.assign_to_account(sms_expense)
280 else:
281 raise InvalidEntry(
282 f'Cannot parse SMS transaction: {row.comment}')
283 return do_extract
284
285
286class Importer(ImporterProtocol):
287 '''
288 Importer for OTP Bank CSV transaction history.
289 '''
290
291 _log: logging.Logger
292 _accounts: Dict[str, str]
293 _extracts: List[Extractor]
294
295 def __init__(self,
296 accounts: Dict[str, str],
297 extractors: List[Extractor]):
298 self._log = logging.getLogger(type(self).__qualname__)
299 self._accounts = {number.replace('-', ''): name for number, name in accounts.items()}
300 self._extractors = extractors
301
302 def identify(self, file: FileMemo) -> bool:
303 _, extension = path.splitext(file.name)
304 if extension.lower() != '.csv':
305 return False
306 return self._find_account(file) is not None
307
308 def _find_account(self, file: FileMemo) -> Optional[str]:
309 head = file.head().strip().split('\n')[0]
310 if head.count(';') != 14:
311 return None
312 for account_number, account_name in self._accounts.items():
313 if head.startswith(f'"{account_number}"'):
314 return account_name
315 return None
316
317 def file_name(self, file: FileMemo) -> str:
318 return 'otpbank.csv'
319
320 def file_account(self, file: FileMemo) -> str:
321 account_name = self._find_account(file)
322 if not account_name:
323 raise RuntimeError(f'Invalid account number in {file.name}')
324 return account_name
325
326 def file_date(self, file: FileMemo) -> Optional[dt.date]:
327 '''
328 Files account statements according to the booking date of the last
329 transaction.
330 '''
331 date = None
332 with open(file.name, 'r') as csv_file:
333 for row in csv.reader(csv_file, delimiter=';'):
334 date_str = row[4]
335 try:
336 date = _parse_date(date_str)
337 except InvalidEntry as exc:
338 self._log.error(
339 'Invalid entry in %s when looking for filing date',
340 file.name, exc_info=exc)
341 return None
342 return date
343
344 def extract(self, file: FileMemo) -> data.Entries:
345 file_name = file.name
346 entries: data.Entries = []
347 with open(file_name, 'r') as csv_file:
348 last_date: Optional[dt.date] = None
349 last_date_str = ""
350 count_within_date = 1
351 for index, row_str in enumerate(csv.reader(csv_file, delimiter=';')):
352 try:
353 row = Row(file_name, index, row_str, self._accounts)
354 if last_date != row.booking_date:
355 last_date = row.booking_date
356 last_date_str = last_date.strftime('%Y-%m-%d')
357 count_within_date = 1
358 else:
359 count_within_date += 1
360 row.links.add(f'otpbank_{last_date_str}_{count_within_date:03}')
361 self._run_row_extractors(row)
362 entries.append(row._to_transaction())
363 except InvalidEntry as exc:
364 self._log.warning(
365 'Skipping invalid entry %d of %s',
366 index, file_name, exc_info=exc)
367 return entries
368
369 def _run_row_extractors(self, row: Row):
370 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 @@
1'''
2Importer for OTP Bank PDF account statements.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from decimal import Decimal
8import datetime as dt
9import logging
10import re
11from typing import cast, Dict, Iterable, List, NamedTuple, Optional
12
13from beancount.core import data
14from beancount.core.amount import Amount
15from beancount.core.number import D
16from beancount.ingest.cache import _FileMemo as FileMemo
17from beancount.ingest.importer import ImporterProtocol
18
19from pdfminer.high_level import extract_pages
20from pdfminer.layout import LTPage, LTTextContainer
21
22
23STATEMENT_NAME_REGEX = re.compile(
24 r'.*(Banksz[a ]mla|(Ertekpapirszamla|rt `kpap ­rsz mla)_)kivonat_(?P<account>\d[\d-]*\d)_' +
25 r'(?P<date>\d{4}\.\d{2}\.\d{2})(_\d+)?\.pdf')
26CHECKING_ACCOUNT_STATEMENT_NAME_REGEX = re.compile(
27 r'.*Banksz[a ]mlakivonat_(?P<account>\d[\d-]*\d)_.+')
28INVESTMENT_ACCOUNT_STATEMENT_NAME_REGEX = re.compile(
29 r'.*(Ertekpapirszamla|rt `kpap ­rsz mla)_kivonat_(?P<account>\d[\d-]*\d)_.+')
30ACCOUNT_NUMBER_REGEX = re.compile(r'SZÁMLASZÁM: (?P<account>\d[\d-]*\d)')
31CURRENCY_REGEX = re.compile(r'DEVIZANEM: (?P<currency>[A-Z]+)$')
32
33
34class Total(NamedTuple):
35 date: dt.date
36 units: Decimal
37
38
39def _append_total(entries: data.Entries,
40 meta: data.Meta,
41 account: str,
42 currency: str,
43 total: Optional[Total],
44 delta: dt.timedelta = dt.timedelta(days=0)) -> None:
45 if not total:
46 return
47 date, units = total
48 amount = Amount(units, currency)
49 balance = data.Balance(meta, date + delta, account, amount, None, None)
50 entries.append(balance)
51
52
53def _find_label_y(page: LTPage, label: str) -> Optional[int]:
54 for element in page:
55 if isinstance(element, LTTextContainer) and element.get_text().strip() == label:
56 return element.bbox[1]
57 return None
58
59
60def _find_match(page: LTPage, pattern: re.Pattern) -> Optional[re.Match]:
61 for element in page:
62 if isinstance(element, LTTextContainer):
63 text = element.get_text().strip()
64 if match := pattern.search(text):
65 return match
66 return None
67
68
69class Importer(ImporterProtocol):
70 '''
71 Importer for OTP Bank PDF account statements.
72 '''
73
74 _log: logging.Logger
75 _accounts: Dict[str, str]
76 _extract_opening: bool
77
78 def __init__(self, accounts: Dict[str, str], extract_opening: bool = False):
79 self._log = logging.getLogger(type(self).__qualname__)
80 self._accounts = accounts
81 self._extract_opening = extract_opening
82
83 def identify(self, file: FileMemo) -> bool:
84 if match := STATEMENT_NAME_REGEX.match(file.name):
85 return match.group('account') in self._accounts
86 else:
87 return False
88
89 def file_name(self, file: FileMemo) -> str:
90 if match := CHECKING_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name):
91 account_number = match.group('account')
92 return f'Bankszámlakivonat_{account_number}.pdf'
93 elif match := INVESTMENT_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name):
94 account_number = match.group('account')
95 return f'Értékpapírszámla_kivonat_{account_number}.pdf'
96 else:
97 raise RuntimeError(f'Not an account statement: {file.name}')
98
99 def file_account(self, file: FileMemo) -> str:
100 if match := STATEMENT_NAME_REGEX.match(file.name):
101 account_number = match.group('account')
102 return self._accounts[account_number]
103 else:
104 raise RuntimeError(f'Not an account statement: {file.name}')
105
106 def file_date(self, file: FileMemo) -> dt.date:
107 if match := STATEMENT_NAME_REGEX.match(file.name):
108 date_str = match.group('date')
109 return dt.datetime.strptime(date_str, '%Y.%m.%d').date()
110 else:
111 raise RuntimeError(f'Not an account statement: {file.name}')
112
113 def extract(self, file: FileMemo) -> data.Entries:
114 if not CHECKING_ACCOUNT_STATEMENT_NAME_REGEX.match(file.name):
115 return []
116 pages = [page for page in cast(Iterable[LTPage], extract_pages(file.name))]
117 if not pages:
118 return []
119 entries: data.Entries = []
120 meta = data.new_metadata(file.name, 1)
121 if account_match := _find_match(pages[0], ACCOUNT_NUMBER_REGEX):
122 account_name = self._accounts[account_match.group('account')]
123 else:
124 self._log.warning('No account number in %s', file.name)
125 account_name = self.file_account(file)
126 if currency_match := _find_match(pages[0], CURRENCY_REGEX):
127 currency = currency_match.group('currency')
128 else:
129 self._log.warning('No currency number in %s', file.name)
130 currency = 'HUF'
131 if self._extract_opening:
132 opening_balance = self._extract_total_from_page(pages[0], 'NYITÓ EGYENLEG')
133 _append_total(entries, meta, account_name, currency, opening_balance)
134 closing_balance = self._extract_total(pages, 'ZÁRÓ EGYENLEG')
135 _append_total(entries, meta, account_name, currency, closing_balance, dt.timedelta(days=1))
136 return entries
137
138 def _extract_total(self, pages: List[LTPage], label: str) -> Optional[Total]:
139 for page in pages:
140 if total := self._extract_total_from_page(page, label):
141 return total
142 self._log.error('%s was not found in the pdf file', label)
143 return None
144
145 def _extract_total_from_page(self, page: LTPage, label: str) -> Optional[Total]:
146 if total_y := _find_label_y(page, label):
147 return self._extract_total_by_y(page, total_y)
148 return None
149
150 def _extract_total_by_y(self, page: LTPage, total_y: int) -> Optional[Total]:
151 date: Optional[dt.date] = None
152 units: Optional[Decimal] = None
153 for element in page:
154 if isinstance(element, LTTextContainer):
155 x, y, x2, _ = element.bbox
156 if abs(y - total_y) > 0.5:
157 continue
158 elif abs(x - 34) <= 0.5:
159 date_str = element.get_text().strip()
160 if date is not None:
161 self._log.warning(
162 'Found date %s, but date was already set to %s',
163 date_str,
164 date)
165 continue
166 try:
167 date = dt.datetime.strptime(date_str, '%y.%m.%d').date()
168 except ValueError as exc:
169 self._log.warning(f'Invalid date {date_str}', exc_info=exc)
170 elif abs(x2 - 572.68) <= 0.5:
171 units_str = element.get_text().strip().replace('.', '').replace(',', '.')
172 if units is not None:
173 self._log.warning(
174 'Found units %s, but units were already set to %s',
175 units_str,
176 units)
177 try:
178 units = D(units_str)
179 except ValueError as exc:
180 self._log.error('Invalid units %s', units_str, exc_info=exc)
181 if not date:
182 self._log.error('Date was not found at y=%d', total_y)
183 return None
184 if not units:
185 self._log.error('Units were not found at y=%d', total_y)
186 return None
187 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 @@
1#!/usr/bin/env python3
2
3from typing import cast, Dict, List, NamedTuple, Optional, Tuple, Union
4import re
5
6from beancount.core.amount import Amount
7
8from beancount_extras_kris7t.importers.utils import Extractor, Row
9
10WILDCARD = re.compile('.*')
11
12
13class When(NamedTuple):
14 payee: re.Pattern
15 text: re.Pattern
16 amount: Optional[Amount]
17
18
19def _compile_regex(s: str) -> re.Pattern:
20 return re.compile(s, re.IGNORECASE)
21
22
23def when(payee: Optional[Union[re.Pattern, str]] = None,
24 text: Optional[Union[re.Pattern, str]] = None,
25 amount: Optional[Amount] = None) -> When:
26 if not payee and not text:
27 raise TypeError('at least one of payee and desc must be provided')
28 if isinstance(payee, str):
29 payee_regex = _compile_regex(payee)
30 else:
31 payee_regex = payee or WILDCARD
32 if isinstance(text, str):
33 text_regex = _compile_regex(text)
34 else:
35 text_regex = text or WILDCARD
36 return When(payee_regex, text_regex, amount)
37
38
39Condition = Union[str, re.Pattern, When]
40
41
42def _compile_condition(cond: Condition) -> When:
43 if isinstance(cond, When):
44 return cond
45 else:
46 return when(text=cond)
47
48
49class let(NamedTuple):
50 payee: Optional[str] = None
51 desc: Optional[str] = None
52 account: Optional[str] = None
53 flag: Optional[str] = None
54 tag: Optional[str] = None
55
56
57Action = Union[str,
58 Tuple[str, str],
59 Tuple[str, str, str],
60 Tuple[str, str, str, str],
61 let]
62
63
64def _compile_action(action: Action) -> let:
65 if isinstance(action, str):
66 return let(account=action)
67 if isinstance(action, let):
68 return action
69 elif isinstance(action, tuple):
70 if len(action) == 2:
71 payee, account = cast(Tuple[str, str], action)
72 return let(payee=payee, account=account)
73 elif len(action) == 3:
74 payee, desc, account = cast(Tuple[str, str, str], action)
75 return let(payee, desc, account)
76 else:
77 flag, payee, desc, account = cast(Tuple[str, str, str, str], action)
78 return let(payee, desc, account, flag)
79 else:
80 raise ValueError(f'Unknown action: {action}')
81
82
83Rules = Dict[Condition, Action]
84CompiledRules = List[Tuple[When, let]]
85
86
87def _compile_rules(rules: Rules) -> CompiledRules:
88 return [(_compile_condition(cond), _compile_action(action))
89 for cond, action in rules.items()]
90
91
92def _rule_condition_matches(cond: When, row: Row) -> bool:
93 if row.payee:
94 payee_valid = cond.payee.search(row.payee) is not None
95 else:
96 payee_valid = cond.payee == WILDCARD
97 if cond.text == WILDCARD:
98 text_valid = True
99 else:
100 characteristics: List[str] = []
101 if row.entry_type:
102 characteristics.append(row.entry_type)
103 if row.payee:
104 characteristics.append(row.payee)
105 if row.comment:
106 characteristics.append(row.comment)
107 row_str = ' '.join(characteristics)
108 text_valid = cond.text.search(row_str) is not None
109 amount_valid = not cond.amount or row.transacted_amount == cond.amount
110 return payee_valid and text_valid and amount_valid
111
112
113def extract_rules(input_rules: Rules) -> Extractor:
114 compiled_rules = _compile_rules(input_rules)
115
116 def do_extract(row: Row) -> None:
117 for cond, (payee, desc, account, flag, tag) in compiled_rules:
118 if not _rule_condition_matches(cond, row):
119 continue
120 if payee is not None:
121 if row.payee == row.comment:
122 row.comment = ''
123 row.payee = payee
124 if desc is not None:
125 row.comment = desc
126 if account is not None:
127 row.assign_to_account(account)
128 if flag is not None:
129 row.flag = flag
130 if tag is not None:
131 row.tags.add(tag)
132 if row.postings:
133 return
134 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
--- /dev/null
+++ b/beancount_extras_kris7t/importers/transferwise/__init__.py
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 @@
1'''
2Importer for Transferwise API transaction history.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from importers.transferwise.client import main
8
9
10if __name__ == '__main__':
11 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 @@
1'''
2Importer for Transferwise API transaction history from the command line.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7import datetime as dt
8import logging
9import os
10from typing import Any, Dict, Optional, Tuple, Set
11
12import beancount
13from beancount.core import data
14
15from beancount_extras_kris7t.importers.transferwise.transferwise_json import Accounts, \
16 DATE_FORMAT, Importer
17
18LOG = logging.getLogger('importers.transferwise.client')
19
20
21def _parse_date_arg(date_str: str) -> dt.date:
22 return dt.datetime.strptime(date_str, '%Y-%m-%d').date()
23
24
25def _import_config(config_path: str) -> Importer:
26 import runpy
27
28 config = runpy.run_path(config_path)
29 importer = config['TRANSFERWISE_CONFIG'] # type: ignore
30 if isinstance(importer, Importer):
31 LOG.info('Loaded configuration from %s', config_path)
32 return importer
33 else:
34 raise ValueError(f'Invalid configuration: {config_path}')
35
36
37def _get_reference(transaction: data.Transaction) -> Optional[str]:
38 for link in transaction.links:
39 if link.startswith('transferwise_'):
40 return link[13:]
41 return None
42
43
44def _get_last_transaction_date(ledger_path: str, skip_references: Set[str]) -> Optional[dt.date]:
45 from beancount.parser import parser
46
47 LOG.info('Checking %s for already imported transactions', ledger_path)
48 entries, _, _ = parser.parse_file(ledger_path)
49 date: Optional[dt.date] = None
50 skip: Set[str] = set()
51 for entry in entries:
52 if isinstance(entry, data.Transaction):
53 reference = _get_reference(entry)
54 if not reference:
55 continue
56 if date is None or date < entry.date:
57 date = entry.date
58 skip.clear()
59 if date == entry.date:
60 skip.add(reference)
61 skip_references.update(skip)
62 return date
63
64
65def _get_date_range(from_date: Optional[dt.date],
66 to_date: dt.date,
67 ledger_path: Optional[str]) -> Tuple[dt.date, dt.date, Set[str]]:
68 skip_references = set()
69 if not from_date and ledger_path:
70 from_date = _get_last_transaction_date(ledger_path, skip_references)
71 if not from_date:
72 from_date = to_date - dt.timedelta(days=365)
73 LOG.info('Fetching transactions from %s to %s', from_date, to_date)
74 return from_date, to_date, skip_references
75
76
77def _get_secrets(importer: Importer,
78 api_key: Optional[str],
79 proxy_uri: Optional[str]) -> Tuple[str, Optional[str]]:
80 import urllib.parse
81
82 if proxy_uri:
83 uri_parts = urllib.parse.urlsplit(proxy_uri)
84 else:
85 uri_parts = None
86 if api_key and (not uri_parts or not uri_parts.username or uri_parts.password):
87 return api_key, proxy_uri
88
89 from contextlib import closing
90
91 import secretstorage
92
93 with closing(secretstorage.dbus_init()) as connection:
94 collection = secretstorage.get_default_collection(connection)
95 if not api_key:
96 items = collection.search_items({
97 'profile_id': str(importer.profile_id),
98 'borderless_account_id': str(importer.borderless_account_id),
99 'xdg:schema': 'com.marussy.beancount.importer.TransferwiseAPIKey',
100 })
101 item = next(items, None)
102 if not item:
103 raise ValueError('No API key found in SecretService')
104 LOG.info('Found API key secret "%s" from SecretService', item.get_label())
105 api_key = item.get_secret().decode('utf-8')
106 if uri_parts and uri_parts.username and not uri_parts.password:
107 host = uri_parts.hostname or uri_parts.netloc
108 items = collection.search_items({
109 'host': host,
110 'port': str(uri_parts.port or 1080),
111 'user': uri_parts.username,
112 'xdg:schema': 'org.freedesktop.Secret.Generic',
113 })
114 item = next(items, None)
115 if item:
116 LOG.info('Found proxy password secret "%s" from SecretService', item.get_label())
117 password = urllib.parse.quote_from_bytes(item.get_secret())
118 uri = f'{uri_parts.scheme}://{uri_parts.username}:{password}@{host}'
119 if uri_parts.port:
120 proxy_uri = f'{uri}:{uri_parts.port}'
121 else:
122 proxy_uri = uri
123 else:
124 LOG.info('No proxy password secret was found in SecretService')
125 assert api_key # Make pyright happy
126 return api_key, proxy_uri
127
128
129def _fetch_statements(importer: Importer,
130 from_date: dt.date,
131 to_date: dt.date,
132 api_key: str,
133 proxy_uri: Optional[str]) -> Dict[str, Any]:
134 import json
135
136 import requests
137
138 now = dt.datetime.utcnow().time()
139 from_time_str = dt.datetime.combine(from_date, dt.datetime.min.time()).strftime(DATE_FORMAT)
140 to_time_str = dt.datetime.combine(to_date, now).strftime(DATE_FORMAT)
141 uri_prefix = f'https://api.transferwise.com/v3/profiles/{importer.profile_id}/' + \
142 f'borderless-accounts/{importer.borderless_account_id}/statement.json' + \
143 f'?intervalStart={from_time_str}&intervalEnd={to_time_str}&type=COMPACT&currency='
144 headers = {
145 'User-Agent': f'Beancount {beancount.__version__} Transferwise importer {__copyright__}',
146 'Authorization': f'Bearer {api_key}',
147 }
148 proxy_dict: Dict[str, str] = {}
149 if proxy_uri:
150 proxy_dict['https'] = proxy_uri
151 statements: Dict[str, Any] = {}
152 for currency in importer.currencies:
153 result = requests.get(uri_prefix + currency, headers=headers, proxies=proxy_dict)
154 if result.status_code != 200:
155 LOG.error(
156 'Fetcing %s statement failed with HTTP status code %d: %s',
157 currency,
158 result.status_code,
159 result.text)
160 else:
161 try:
162 statement = json.loads(result.text)
163 except json.JSONDecodeError as exc:
164 LOG.error('Failed to decode %s statement', currency, exc_info=exc)
165 else:
166 statements[currency] = statement
167 LOG.info('Fetched %s statement', currency)
168 return statements
169
170
171def _print_statements(from_date: dt.date,
172 to_date: dt.date,
173 entries: data.Entries) -> None:
174 from beancount.parser import printer
175 print(f'*** Transferwise from {from_date} to {to_date}', flush=True)
176 printer.print_entries(entries)
177
178
179def _determine_path(dir: str, currency: str, to_date: dt.date) -> str:
180 date_str = to_date.strftime('%Y-%m-%d')
181 simple_path = os.path.join(dir, f'{date_str}.transferwise_{currency}.json')
182 if not os.path.exists(simple_path):
183 return simple_path
184 for i in range(2, 10):
185 path = os.path.join(dir, f'{date_str}.transferwise_{currency}_{i}.json')
186 if not os.path.exists(path):
187 return path
188 raise ValueError(f'Cannot find unused name for {simple_path}')
189
190
191def _archive_statements(documents_path: str,
192 to_date: dt.date,
193 accounts: Accounts,
194 statements: Dict[str, Any]):
195 import json
196
197 from beancount.core.account import sep
198
199 for currency, statement in statements.items():
200 dir = os.path.join(documents_path, *accounts.get_borderless_account(currency).split(sep))
201 os.makedirs(dir, exist_ok=True)
202 path = _determine_path(dir, currency, to_date)
203 with open(path, 'w') as file:
204 json.dump(statement, file, indent=2)
205 LOG.info('Saved %s statement as %s', currency, path)
206
207
208def main():
209 import argparse
210
211 parser = argparse.ArgumentParser()
212 parser.add_argument('--verbose', '-v', action='store_true')
213 parser.add_argument('--from-date', '-f', required=False, type=_parse_date_arg)
214 parser.add_argument('--to-date', '-t', default=dt.date.today(), type=_parse_date_arg)
215 parser.add_argument('--api-key', '-k', required=False, type=str)
216 parser.add_argument('--proxy', '-p', required=False, type=str)
217 parser.add_argument('--archive', '-a', required=False, type=str)
218 parser.add_argument('config', nargs=1)
219 parser.add_argument('ledger', nargs='?')
220 args = parser.parse_args()
221 if args.verbose:
222 log_level = logging.INFO
223 else:
224 log_level = logging.WARN
225 logging.basicConfig(level=log_level)
226 importer = _import_config(args.config[0])
227 from_date, to_date, skip = _get_date_range(args.from_date, args.to_date, args.ledger)
228 if skip:
229 LOG.info('Skipping %s', skip)
230 api_key, proxy_uri = _get_secrets(importer, args.api_key, args.proxy)
231 statements = _fetch_statements(importer, from_date, to_date, api_key, proxy_uri)
232 if args.archive:
233 _archive_statements(args.archive, to_date, importer.accounts, statements)
234 entries = importer.extract_objects(statements.values(), skip)
235 if entries:
236 _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 @@
1'''
2Importer for Transferwise API transaction history.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from collections import defaultdict
8import datetime as dt
9import json
10import logging
11import re
12from os import path
13from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set
14
15from beancount.core import account, data
16import beancount.core.amount as am
17from beancount.core.amount import Amount
18from beancount.core.flags import FLAG_WARNING
19from beancount.core.inventory import Inventory
20from beancount.core.number import ZERO
21from beancount.ingest.cache import _FileMemo as FileMemo
22from beancount.ingest.importer import ImporterProtocol
23
24import beancount_extras_kris7t.importers.utils as utils
25from beancount_extras_kris7t.importers.utils import COMMENT_META, InvalidEntry, PAYEE_META
26
27DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
28DATE_FORMAT_FRACTIONAL = '%Y-%m-%dT%H:%M:%S.%fZ'
29CATEGORY_META = 'transferwise-category'
30ENTRY_TYPE_META = 'transferwise-entry-type'
31TRANSFERWISE_JSON_TAG = 'transferwise-json'
32CD_DEBIT = 'DEBIT'
33CD_CREDIT = 'CREDIT'
34MONEY_ADDED_TYPE = 'MONEY_ADDED'
35CARD_TYPE = 'CARD'
36CARD_REGEX = re.compile(r'^Card transaction of.*issued by', re.IGNORECASE)
37
38
39def _parse_date(date_str: str) -> dt.date:
40 # TODO Handle time zones accurately
41 try:
42 return utils.parse_date(date_str, DATE_FORMAT)
43 except InvalidEntry:
44 return utils.parse_date(date_str, DATE_FORMAT_FRACTIONAL)
45
46
47def _parse_json_amount(data: Any, cd: Optional[str] = None) -> Amount:
48 # TODO Handle precision better
49 amount = Amount(round(utils.parse_number(data['value']), 2), data['currency'])
50 if cd == CD_DEBIT:
51 return -amount
52 else:
53 return amount
54
55
56def _validate_cd(amount: Amount, cd: str) -> None:
57 if cd == CD_DEBIT:
58 if amount.number >= ZERO:
59 raise InvalidEntry(f'Invalid debit amount: {amount}')
60 elif cd == CD_CREDIT:
61 if amount.number <= ZERO:
62 raise InvalidEntry(f'Invalid credit amount: {amount}')
63 else:
64 raise InvalidEntry(f'Invalid credit/debit type: {cd}')
65
66
67class Reference(NamedTuple):
68 type: str
69 reference_number: str
70
71
72class _ConversionResult(NamedTuple):
73 converted_fraction: Amount
74 price: Optional[Amount]
75 fudge: Optional[Amount]
76
77
78class _Conversion(NamedTuple):
79 native_amount: Amount
80 converted_amount: Optional[Amount]
81
82 def get_fraction(self,
83 total_converted_amount: Amount,
84 assigned_amount: Amount,
85 diverted_fees: Inventory) -> _ConversionResult:
86 fees = diverted_fees.get_currency_units(self.native_amount.currency)
87 native_amount = am.add(self.native_amount, fees)
88 fraction = am.div(assigned_amount, total_converted_amount.number).number
89 if self.converted_amount:
90 converted_fraction = am.mul(self.converted_amount, fraction)
91 price = am.div(native_amount, self.converted_amount.number)
92 assert price.number is not None
93 price = price._replace(number=round(price.number, 4))
94 # TODO Do we have to calculate the fudge here?
95 return _ConversionResult(converted_fraction, price, None)
96 else:
97 return _ConversionResult(am.mul(native_amount, fraction), None, None)
98
99
100class Accounts(NamedTuple):
101 borderless_root_asset: str
102 fees_expense: str
103
104 def get_borderless_account(self, currency: str) -> str:
105 return account.sep.join([self.borderless_root_asset, currency])
106
107
108class Row(utils.Row):
109 cd: str
110 _transacted_amount: Amount
111 date: dt.date
112 _conversions: List[_Conversion]
113 _inputs: Inventory
114 _fees: Inventory
115 divert_fees: Optional[str]
116
117 def __init__(self, reference: Reference, transaction_list: List[Any]):
118 assert len(transaction_list) >= 1
119 first_transaction = transaction_list[0]
120 details = first_transaction['details']
121 entry_type = details.get('type', None)
122 merchant = details.get('merchant', None)
123 if merchant:
124 payee = merchant['name']
125 else:
126 payee = None
127 comment = details['description']
128 super().__init__('<transferwise>', 0, entry_type, payee, comment)
129 if entry_type:
130 self.meta[ENTRY_TYPE_META] = entry_type
131 category = details.get('category', None)
132 if category:
133 self.meta[CATEGORY_META] = category
134 if merchant:
135 if 'category' in merchant and merchant['category'] == category:
136 del merchant['category']
137 not_null_elements = [str(value).strip() for _, value in merchant.items() if value]
138 self.meta[PAYEE_META] = '; '.join(not_null_elements)
139 self.meta[COMMENT_META] = comment
140 self.tags.add(TRANSFERWISE_JSON_TAG)
141 self.links.add(f'transferwise_{reference.reference_number}')
142 self.date = _parse_date(first_transaction['date'])
143 self.cd = reference.type
144 self.divert_fees = None
145 self._conversions = []
146 self._inputs = Inventory()
147 self._fees = Inventory()
148 self._compute_transacted_amount(transaction_list)
149 for transaction in transaction_list:
150 self._add_json_transaction(transaction)
151
152 def _compute_transacted_amount(self, transaction_list: List[Any]) -> None:
153 first_transaction = transaction_list[0]
154 details = first_transaction['details']
155 transacted_json = details.get('amount', None)
156 if transacted_json:
157 self._transacted_amount = _parse_json_amount(transacted_json, self.cd)
158 else:
159 if len(transaction_list) != 1:
160 raise InvalidEntry('Cannot determine transaction amount')
161 if exchange_details := first_transaction.get('exchangeDetails', None):
162 self._transacted_amount = _parse_json_amount(exchange_details, self.cd)
163 else:
164 self._transacted_amount = _parse_json_amount(first_transaction['amount'])
165 _validate_cd(self._transacted_amount, self.cd)
166
167 def _add_json_transaction(self, transaction: Any) -> None:
168 if exchange := transaction.get('exchangeDetails', None):
169 native = _parse_json_amount(exchange['fromAmount'], self.cd)
170 converted = _parse_json_amount(exchange['toAmount'], self.cd)
171 _validate_cd(converted, self.cd)
172 else:
173 native = _parse_json_amount(transaction['amount'])
174 converted = None
175 _validate_cd(native, self.cd)
176 if total_fees := transaction.get('totalFees', None):
177 fee = -_parse_json_amount(total_fees)
178 if fee.number > ZERO:
179 raise InvalidEntry(f'Invalid transaction fee: {fee}')
180 if fee.number != ZERO:
181 self._fees.add_amount(fee)
182 native_after_fees = am.sub(native, fee)
183 else:
184 native_after_fees = native
185 self._conversions.append(_Conversion(native_after_fees, converted))
186 self._inputs.add_amount(native)
187
188 @property
189 def transacted_amount(self) -> Amount:
190 return self._transacted_amount
191
192 def _to_transaction(self, accounts: Accounts) -> data.Transaction:
193 postings = self._get_postings(accounts)
194 return data.Transaction(self.meta, self.date, self.flag, self.payee, self.comment,
195 self.tags, self.links, postings)
196
197 def _get_postings(self, accounts: Accounts) -> List[data.Posting]:
198 postings: List[data.Posting] = []
199 for units, cost in self._inputs:
200 assert cost is None
201 postings.append(data.Posting(
202 accounts.get_borderless_account(units.currency), units, None, None, None, None))
203 if self.divert_fees:
204 for units, cost in self._fees:
205 assert cost is None
206 postings.append(data.Posting(
207 self.divert_fees, units, None, None, None, None))
208 diverted_fees = self._fees
209 else:
210 diverted_fees = Inventory()
211 # Also add the "fudge" amounts to the fees generated by rounding currency conversions.
212 all_fees = Inventory()
213 all_fees.add_inventory(self._fees)
214 for acc, assigned_units in self.postings:
215 for conversion in self._conversions:
216 units, price, fudge = conversion.get_fraction(
217 self._transacted_amount, assigned_units, diverted_fees)
218 postings.append(data.Posting(acc, -units, None, price, None, None))
219 if fudge:
220 all_fees.add_amount(-fudge)
221 for units, cost in all_fees:
222 assert cost is None
223 postings.append(data.Posting(
224 accounts.fees_expense, -units, None, None, None, None))
225 return postings
226
227
228Extractor = Callable[[Row], None]
229
230
231def extract_card_transaction(payment_processors: Dict[str, Optional[str]] = {}) -> Extractor:
232 regexes = [(re.compile(f'^\\s*{key}\\s*\\*', re.IGNORECASE), value)
233 for key, value in payment_processors.items()]
234
235 def do_extract(row: Row) -> None:
236 if row.entry_type == CARD_TYPE and CARD_REGEX.search(row.comment):
237 if row.cd == CD_DEBIT:
238 row.comment = ''
239 else:
240 row.comment = 'Refund'
241 # Most manually add posting for refunded fees.
242 row.flag = FLAG_WARNING
243 if row.payee:
244 for key, value in regexes:
245 if match := key.search(row.payee):
246 if value:
247 row.tags.add(value)
248 row.payee = row.payee[match.end():].strip()
249 return do_extract
250
251
252def extract_add_money(add_money_asset: str, add_money_fees_asset: str) -> Extractor:
253 def do_extract(row: Row) -> None:
254 if row.entry_type == MONEY_ADDED_TYPE:
255 row.payee = 'Transferwise'
256 row.divert_fees = add_money_fees_asset
257 row.assign_to_account(add_money_asset)
258 return do_extract
259
260
261class Importer(ImporterProtocol):
262 _log: logging.Logger
263 profile_id: int
264 borderless_account_id: int
265 currencies: List[str]
266 accounts: Accounts
267 _extractors: List[Extractor]
268
269 def __init__(self,
270 profile_id: int,
271 borderless_account_id: int,
272 currencies: Iterable[str],
273 accounts: Accounts,
274 extractors: List[Extractor]):
275 self._log = logging.getLogger(type(self).__qualname__)
276 self.profile_id = profile_id
277 self.borderless_account_id = borderless_account_id
278 self.currencies = list(currencies)
279 self.accounts = accounts
280 self._extractors = extractors
281
282 def _parse_file(self, file: FileMemo) -> Any:
283 def parse_json(path: str) -> Any:
284 with open(path, 'r') as json_file:
285 try:
286 return json.load(json_file)
287 except json.JSONDecodeError as exc:
288 self._log.info('Invalid JSON: %s', path, exc_info=exc)
289 return None
290
291 return file.convert(parse_json)
292
293 def identify(self, file: FileMemo) -> bool:
294 _, extension = path.splitext(file.name)
295 if extension.lower() != '.json':
296 return False
297 contents = self._parse_file(file)
298 try:
299 query = contents['query']
300 return query['accountId'] == self.borderless_account_id and \
301 query['currency'] in self.currencies
302 except (KeyError, TypeError):
303 return False
304
305 def file_name(self, file: FileMemo) -> str:
306 return 'statement.json'
307
308 def file_account(self, file: FileMemo) -> str:
309 contents = self._parse_file(file)
310 try:
311 currency = contents['query']['currency']
312 except (KeyError, TypeError) as exc:
313 raise ValueError(f'Invalid account statement: {file.name}') from exc
314 if not isinstance(currency, str):
315 raise ValueError(f'Invalid account statement: {file.name}')
316 return self.accounts.get_borderless_account(currency)
317
318 def file_date(self, file: FileMemo) -> dt.date:
319 contents = self._parse_file(file)
320 try:
321 date_str = contents['query']['intervalEnd']
322 except (KeyError, TypeError) as exc:
323 raise ValueError(f'Invalid account statement: {file.name}') from exc
324 if not isinstance(date_str, str):
325 raise ValueError(f'Invalid account statement: {file.name}')
326 return _parse_date(date_str)
327
328 def extract(self, file: FileMemo) -> data.Entries:
329 contents = self._parse_file(file)
330 if contents:
331 return self.extract_objects([contents])
332 else:
333 return []
334
335 def extract_objects(self,
336 statements: Iterable[Any],
337 skip_references: Set[str] = set()) -> data.Entries:
338 transactions: Dict[Reference, List[Any]] = defaultdict(lambda: [])
339 for statement in statements:
340 for transaction in statement['transactions']:
341 reference_number = transaction['referenceNumber']
342 if reference_number in skip_references:
343 continue
344 reference = Reference(transaction['type'], reference_number)
345 transactions[reference].append(transaction)
346 entries: data.Entries = []
347 for reference, transaction_list in transactions.items():
348 if not transaction_list:
349 continue
350 try:
351 row = Row(reference, transaction_list)
352 except (TypeError, KeyError, InvalidEntry) as exc:
353 self._log.warn('Invalid entry: %s', reference, exc_info=exc)
354 continue
355 try:
356 utils.run_row_extractors(row, self._extractors)
357 except InvalidEntry as exc:
358 self._log.warn('Invalid entry: %s', reference, exc_info=exc)
359 continue
360 entries.append(row._to_transaction(self.accounts))
361 entries.sort(key=lambda entry: entry.date)
362 if entries:
363 self._extract_closing_balances(statements, entries)
364 return entries
365
366 def _extract_closing_balances(self,
367 statements: Iterable[Any],
368 entries: data.Entries) -> None:
369 for statement in statements:
370 query = statement['query']
371 end_date = _parse_date(query['intervalEnd']) + dt.timedelta(days=1)
372 currency = query['currency']
373 balance = statement['endOfStatementBalance']
374 amount = Amount(utils.parse_number(balance['value']), balance['currency'])
375 meta = data.new_metadata(f'<transferwise:{currency}>', 0)
376 account = self.accounts.get_borderless_account(currency)
377 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 @@
1'''
2Utilities for custom importers.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from abc import ABC, abstractmethod
8import datetime as dt
9from decimal import Decimal
10from typing import cast, Callable, Iterable, List, NamedTuple, Optional, Set, TypeVar, Union
11
12from beancount.core import amount as am, data
13from beancount.core.amount import Amount
14from beancount.core.flags import FLAG_OKAY, FLAG_WARNING
15from beancount.core.number import D, ZERO
16
17MISSING_AMOUNT = cast(Amount, None)
18COMMENT_META = 'import-raw-comment'
19PAYEE_META = 'import-raw-payee'
20
21
22class InvalidEntry(Exception):
23 pass
24
25
26class Posting(NamedTuple):
27 account: str
28 amount: Amount
29
30
31class Row(ABC):
32 entry_type: Optional[str]
33 payee: Optional[str]
34 comment: str
35 meta: data.Meta
36 flag: str
37 tags: Set[str]
38 links: Set[str]
39 _postings: Optional[List[Posting]]
40
41 def __init__(self,
42 file_name: str,
43 line_number: int,
44 entry_type: Optional[str],
45 payee: Optional[str],
46 comment: str):
47 self.entry_type = entry_type
48 self.payee = payee
49 self.comment = comment
50 self.meta = data.new_metadata(file_name, line_number)
51 self.flag = FLAG_OKAY
52 self.tags = set()
53 self.links = set()
54 self._postings = None
55
56 @property
57 @abstractmethod
58 def transacted_amount(self) -> Amount:
59 pass
60
61 @property
62 def transacted_currency(self) -> str:
63 return self.transacted_amount.currency
64
65 @property
66 def postings(self) -> Optional[List[Posting]]:
67 return self._postings
68
69 def assign_to_accounts(self, *postings: Posting) -> None:
70 if self.done:
71 raise InvalidEntry('Transaction is alrady done processing')
72 self._postings = list(postings)
73 if not self._postings:
74 raise InvalidEntry('Not assigned to any accounts')
75 head, *rest = self._postings
76 sum = head.amount
77 for posting in rest:
78 sum = am.add(sum, posting.amount)
79 if sum != self.transacted_amount:
80 self.flag = FLAG_WARNING
81
82 def assign_to_account(self, account: str) -> None:
83 self.assign_to_accounts(Posting(account, self.transacted_amount))
84
85 @property
86 def done(self) -> bool:
87 return self._postings is not None
88
89
90Extractor = Callable[[Row], None]
91TRow = TypeVar('TRow', bound=Row)
92
93
94def run_row_extractors(row: TRow, extractors: Iterable[Callable[[TRow], None]]) -> None:
95 for extractor in extractors:
96 extractor(row)
97 if row.done:
98 return
99
100
101def extract_unknown(expenses_account: str, income_account: str) -> Extractor:
102 def do_extract(row: Row) -> None:
103 if row.transacted_amount.number < ZERO:
104 row.assign_to_account(expenses_account)
105 else:
106 row.assign_to_account(income_account)
107 row.flag = FLAG_WARNING
108 return do_extract
109
110
111def parse_date(date_str: str, format_string: str) -> dt.date:
112 try:
113 return dt.datetime.strptime(date_str, format_string).date()
114 except ValueError as exc:
115 raise InvalidEntry(f'Cannot parse date: {date_str}') from exc
116
117
118def parse_number(in_amount: Union[str, int, float, Decimal]) -> Decimal:
119 try:
120 value = D(in_amount)
121 except ValueError as exc:
122 raise InvalidEntry(f'Cannot parse number: {in_amount}') from exc
123 if value is None:
124 raise InvalidEntry(f'Parse number returned None: {in_amount}')
125 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
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/__init__.py
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 @@
1'''
2Plugin that closes an account by transferring its whole balance to another account.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from typing import Any, Dict, List, NamedTuple, Optional, Tuple
8
9from beancount.core.data import Balance, Close, Directive, Entries, Meta, Posting, Transaction
10from beancount.core.flags import FLAG_OKAY
11from beancount.core.number import ZERO
12
13__plugins__ = ('close_with_balance_assertions',)
14
15CLOSE_TO_META = 'close-to'
16CLOSING_META = 'closing'
17
18
19class ClosingBalanceError(NamedTuple):
20 source: Optional[Meta]
21 message: str
22 entry: Directive
23
24
25def close_with_balance_assertions(entries: Entries,
26 options_map: Dict[str, Any],
27 config_str: Optional[str] = None) -> \
28 Tuple[Entries, List[ClosingBalanceError]]:
29 new_entries: Entries = []
30 errors: List[ClosingBalanceError] = []
31 for entry in entries:
32 new_entries.append(entry)
33 if isinstance(entry, Balance) and CLOSE_TO_META in entry.meta:
34 close_to_account = entry.meta[CLOSE_TO_META]
35 if not isinstance(close_to_account, str):
36 errors.append(ClosingBalanceError(
37 entry.meta,
38 f'{CLOSE_TO_META} must be a string, got {close_to_account} instead',
39 entry))
40 continue
41 if entry.tolerance is not None and entry.tolerance != ZERO:
42 errors.append(ClosingBalanceError(
43 entry.meta,
44 f'Closing an account requires {ZERO} tolerance, got {entry.tolerance} instead',
45 entry))
46 continue
47 if entry.diff_amount is not None:
48 errors.append(ClosingBalanceError(
49 entry.meta,
50 f'Not closing {entry.account} with {entry.diff_amount} failed balance check',
51 entry))
52 continue
53 new_meta = dict(entry.meta)
54 del new_meta[CLOSE_TO_META]
55 if entry.amount.number != ZERO:
56 new_entries.append(Transaction(
57 new_meta,
58 entry.date,
59 FLAG_OKAY,
60 None,
61 f'Closing {entry.account}',
62 set(),
63 set(),
64 [
65 Posting(entry.account, -entry.amount, None, None, None,
66 {CLOSING_META: True}),
67 Posting(close_to_account, entry.amount, None, None, None, None)
68 ]))
69 new_entries.append(Close(new_meta, entry.date, entry.account))
70 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 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8
9
10class TestClosingBalance(cmptest.TestCase):
11
12 @loader.load_doc()
13 def test_close_account_correct(self, entries, errors, options_map):
14 '''
15 plugin "beancount_extras_kris7t.plugins.closing_balance"
16
17 2020-01-01 open Assets:Checking
18 2020-01-01 open Equity:Opening-Balances
19 2020-01-01 open Equity:Closing-Balances
20
21 2020-01-01 * "Opening balances"
22 Assets:Checking 100 USD
23 Equity:Opening-Balances -100 USD
24
25 2020-03-15 balance Assets:Checking 100 USD
26 close-to: Equity:Closing-Balances
27 '''
28 self.assertEqualEntries('''
29 plugin "beancount_extras_kris7t.plugins.closing_balance"
30
31 2020-01-01 open Assets:Checking
32 2020-01-01 open Equity:Opening-Balances
33 2020-01-01 open Equity:Closing-Balances
34
35 2020-01-01 * "Opening balances"
36 Assets:Checking 100 USD
37 Equity:Opening-Balances -100 USD
38
39 2020-03-15 balance Assets:Checking 100 USD
40 close-to: Equity:Closing-Balances
41
42 2020-03-15 * "Closing Assets:Checking"
43 Assets:Checking -100 USD
44 closing: TRUE
45 Equity:Closing-Balances 100 USD
46
47 2020-03-15 close Assets:Checking
48 ''', entries)
49
50 @loader.load_doc(expect_errors=True)
51 def test_close_account_incorrect(self, entries, errors, options_map):
52 '''
53 plugin "beancount_extras_kris7t.plugins.closing_balance"
54
55 2020-01-01 open Assets:Checking
56 2020-01-01 open Equity:Opening-Balances
57 2020-01-01 open Equity:Closing-Balances
58
59 2020-01-01 * "Opening balances"
60 Assets:Checking 100 USD
61 Equity:Opening-Balances -100 USD
62
63 2020-03-15 balance Assets:Checking 80 USD
64 close-to: Equity:Closing-Balances
65 '''
66 self.assertEqualEntries('''
67 plugin "beancount_extras_kris7t.plugins.closing_balance"
68
69 2020-01-01 open Assets:Checking
70 2020-01-01 open Equity:Opening-Balances
71 2020-01-01 open Equity:Closing-Balances
72
73 2020-01-01 * "Opening balances"
74 Assets:Checking 100 USD
75 Equity:Opening-Balances -100 USD
76
77 2020-03-15 balance Assets:Checking 80 USD
78 close-to: Equity:Closing-Balances
79
80 2020-03-15 * "Closing Assets:Checking"
81 Assets:Checking -80 USD
82 closing: TRUE
83 Equity:Closing-Balances 80 USD
84
85 2020-03-15 close Assets:Checking
86 ''', entries)
87 self.assertRegex(errors[0].message, '^Balance failed for \'Assets:Checking\'')
88
89 @loader.load_doc()
90 def test_close_account_zero(self, entries, errors, options_map):
91 '''
92 plugin "beancount_extras_kris7t.plugins.closing_balance"
93
94 2020-01-01 open Assets:Checking
95
96 2020-03-15 balance Assets:Checking 0 USD
97 close-to: Equity:Closing-Balances
98 '''
99 self.assertEqualEntries('''
100 plugin "beancount_extras_kris7t.plugins.closing_balance"
101
102 2020-01-01 open Assets:Checking
103
104 2020-03-15 balance Assets:Checking 0 USD
105 close-to: Equity:Closing-Balances
106
107 2020-03-15 close Assets:Checking
108 ''', entries)
109
110 @loader.load_doc(expect_errors=True)
111 def test_invalid_close_to(self, entries, errors, options_map):
112 '''
113 plugin "beancount_extras_kris7t.plugins.closing_balance"
114
115 2020-01-01 open Assets:Checking
116
117 2020-03-15 balance Assets:Checking 100 USD
118 close-to: TRUE
119 '''
120 self.assertRegex(errors[0].message, '^close-to must be a string')
121
122
123if __name__ == '__main__':
124 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 @@
1'''
2Plugin that sets the tolerance values of balance assertions to an amount determined by the account.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from decimal import Decimal
8from typing import Any, Dict, List, NamedTuple, Optional, Tuple
9
10from beancount.core.data import Open, Balance, Directive, Entries, Meta
11
12__plugins__ = ('set_tolerances_to_default',)
13
14DEFAULT_TOLERANCE_META = 'default-balance-tolerance'
15
16
17class DefaultToleranceError(NamedTuple):
18 source: Optional[Meta]
19 message: str
20 entry: Directive
21
22
23def set_tolerances_to_default(entries: Entries,
24 options_map: Dict[str, Any],
25 config_str: Optional[str] = None) -> \
26 Tuple[Entries, List[DefaultToleranceError]]:
27 errors: List[DefaultToleranceError] = []
28 accounts: Dict[str, Optional[Decimal]] = {}
29 for entry in entries:
30 if not isinstance(entry, Open):
31 continue
32 if tolerance := entry.meta.get(DEFAULT_TOLERANCE_META, None):
33 if isinstance(tolerance, Decimal):
34 accounts[entry.account] = tolerance
35 else:
36 errors.append(DefaultToleranceError(
37 entry.meta,
38 f'{DEFAULT_TOLERANCE_META} must be decimal, got {tolerance} instead',
39 entry))
40 new_entries: Entries = []
41 for entry in entries:
42 if isinstance(entry, Balance) and entry.tolerance is None and entry.account in accounts:
43 account_tolerance = accounts[entry.account]
44 new_entries.append(entry._replace(tolerance=account_tolerance))
45 else:
46 new_entries.append(entry)
47 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 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8
9
10class DefaultToleranceTest(cmptest.TestCase):
11
12 @loader.load_doc()
13 def test_account_with_tolerance(self, entries, errors, options_map):
14 '''
15 plugin "beancount_extras_kris7t.plugins.default_tolerance"
16
17 2020-01-01 open Assets:Checking
18 default-balance-tolerance: 10
19
20 2020-01-01 balance Assets:Checking 0 USD
21 '''
22 self.assertEqualEntries('''
23 plugin "beancount_extras_kris7t.plugins.default_tolerance"
24
25 2020-01-01 open Assets:Checking
26 default-balance-tolerance: 10
27
28 2020-01-01 balance Assets:Checking 0 ~ 10 USD
29 ''', entries)
30
31 @loader.load_doc()
32 def test_account_with_tolerance_override(self, entries, errors, options_map):
33 '''
34 plugin "beancount_extras_kris7t.plugins.default_tolerance"
35
36 2020-01-01 open Assets:Checking
37 default-balance-tolerance: 10
38
39 2020-01-01 balance Assets:Checking 10 ~ 20 USD
40 '''
41 self.assertEqualEntries('''
42 plugin "beancount_extras_kris7t.plugins.default_tolerance"
43
44 2020-01-01 open Assets:Checking
45 default-balance-tolerance: 10
46
47 2020-01-01 balance Assets:Checking 10 ~ 20 USD
48 ''', entries)
49
50 @loader.load_doc()
51 def test_account_with_tolerance_override_zero(self, entries, errors, options_map):
52 '''
53 plugin "beancount_extras_kris7t.plugins.default_tolerance"
54
55 2020-01-01 open Assets:Checking
56 default-balance-tolerance: 10
57
58 2020-01-01 balance Assets:Checking 0 ~ 0 USD
59 '''
60 self.assertEqualEntries('''
61 plugin "beancount_extras_kris7t.plugins.default_tolerance"
62
63 2020-01-01 open Assets:Checking
64 default-balance-tolerance: 10
65
66 2020-01-01 balance Assets:Checking 0 ~ 0 USD
67 ''', entries)
68
69 @loader.load_doc()
70 def test_account_without_tolerance(self, entries, errors, options_map):
71 '''
72 plugin "beancount_extras_kris7t.plugins.default_tolerance"
73
74 2020-01-01 open Assets:Checking
75
76 2020-01-01 balance Assets:Checking 0 USD
77
78 2020-01-02 balance Assets:Checking 10 ~ 20 USD
79 '''
80 self.assertEqualEntries('''
81 plugin "beancount_extras_kris7t.plugins.default_tolerance"
82
83 2020-01-01 open Assets:Checking
84
85 2020-01-01 balance Assets:Checking 0 USD
86
87 2020-01-02 balance Assets:Checking 10 ~ 20 USD
88 ''', entries)
89
90 @loader.load_doc(expect_errors=True)
91 def test_account_with_invalid_tolerance(self, entries, errors, options_map):
92 '''
93 plugin "beancount_extras_kris7t.plugins.default_tolerance"
94
95 2020-01-01 open Assets:Checking
96 default-balance-tolerance: TRUE
97
98 2020-01-01 balance Assets:Checking 0 USD
99 '''
100 self.assertRegex(errors[0].message, '^default-balance-tolerance must be decimal')
101
102
103if __name__ == '__main__':
104 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 @@
1"""This plugin synthesizes Price directives for all Postings with a price or
2directive or if it is an augmenting posting, has a cost directive.
3
4Price directives will be synthesized only for commodities with the
5implicit-prices: TRUE metadata set.
6"""
7__copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \
8 "2020 Kristóf Marussy <kristof@marussy.com>"
9__license__ = "GNU GPLv2"
10
11import collections
12from typing import List, Tuple, Set
13
14from beancount.core.data import Commodity, Entries, Transaction
15from beancount.core import data
16from beancount.core import amount
17from beancount.core import inventory
18from beancount.core.position import Cost
19
20__plugins__ = ('add_implicit_prices',)
21
22
23ImplicitPriceError = collections.namedtuple('ImplicitPriceError', 'source message entry')
24
25
26METADATA_FIELD = "__implicit_prices__"
27IMPLICIT_PRICES_META = "implicit-prices"
28
29
30def fetch_commodities(entries: Entries) -> Tuple[Set[str], List[ImplicitPriceError]]:
31 commodities: Set[str] = set()
32 errors: List[ImplicitPriceError] = []
33 for entry in entries:
34 if isinstance(entry, Commodity):
35 implicit_prices = entry.meta.get(IMPLICIT_PRICES_META, False)
36 if not isinstance(implicit_prices, bool):
37 errors.append(ImplicitPriceError(
38 entry.meta,
39 f'{IMPLICIT_PRICES_META} must be Boolean, got {implicit_prices} instead',
40 entry))
41 if implicit_prices:
42 commodities.add(entry.currency)
43 return commodities, errors
44
45
46def add_implicit_prices(entries: Entries,
47 unused_options_map) -> Tuple[Entries, List[ImplicitPriceError]]:
48 """Insert implicitly defined prices from Transactions.
49
50 Explicit price entries are simply maintained in the output list. Prices from
51 postings with costs or with prices from Transaction entries are synthesized
52 as new Price entries in the list of entries output.
53
54 Args:
55 entries: A list of directives. We're interested only in the Transaction instances.
56 unused_options_map: A parser options dict.
57 Returns:
58 A list of entries, possibly with more Price entries than before, and a
59 list of errors.
60 """
61 new_entries: Entries = []
62 errors: List[ImplicitPriceError] = []
63
64 commodities, fetch_errors = fetch_commodities(entries)
65 errors.extend(fetch_errors)
66
67 # A dict of (date, currency, cost-currency) to price entry.
68 new_price_entry_map = {}
69
70 balances = collections.defaultdict(inventory.Inventory)
71 for entry in entries:
72 # Always replicate the existing entries.
73 new_entries.append(entry)
74
75 if isinstance(entry, Transaction):
76 # Inspect all the postings in the transaction.
77 for posting in entry.postings:
78 units = posting.units
79 if units.currency not in commodities:
80 continue
81
82 cost = posting.cost
83
84 # Check if the position is matching against an existing
85 # position.
86 _, booking = balances[posting.account].add_position(posting)
87
88 # Add prices when they're explicitly specified on a posting. An
89 # explicitly specified price may occur in a conversion, e.g.
90 # Assets:Account 100 USD @ 1.10 CAD
91 # or, if a cost is also specified, as the current price of the
92 # underlying instrument, e.g.
93 # Assets:Account 100 HOOL {564.20} @ {581.97} USD
94 if posting.price is not None:
95 meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"])
96 meta[METADATA_FIELD] = "from_price"
97 price_entry = data.Price(meta, entry.date,
98 units.currency,
99 posting.price)
100
101 # Add costs, when we're not matching against an existing
102 # position. This happens when we're just specifying the cost,
103 # e.g.
104 # Assets:Account 100 HOOL {564.20}
105 elif (cost is not None and
106 booking != inventory.MatchResult.REDUCED):
107 # TODO(blais): What happens here if the account has no
108 # booking strategy? Do we end up inserting a price for the
109 # reducing leg? Check.
110 meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"])
111 meta[METADATA_FIELD] = "from_cost"
112 if isinstance(cost, Cost):
113 price_entry = data.Price(meta, entry.date,
114 units.currency,
115 amount.Amount(cost.number, cost.currency))
116 else:
117 errors.append(
118 ImplicitPriceError(
119 entry.meta,
120 f'Expected {entry} to have a Cost, got {cost} instead',
121 entry))
122 price_entry = None
123 else:
124 price_entry = None
125
126 if price_entry is not None:
127 key = (price_entry.date,
128 price_entry.currency,
129 price_entry.amount.number, # Ideally should be removed.
130 price_entry.amount.currency)
131 try:
132 new_price_entry_map[key]
133
134 # Do not fail for now. We still have many valid use
135 # cases of duplicate prices on the same date, for
136 # example, stock splits, or trades on two dates with
137 # two separate reported prices. We need to figure out a
138 # more elegant solution for this in the long term.
139 # Keeping both for now. We should ideally not use the
140 # number in the de-dup key above.
141 #
142 # dup_entry = new_price_entry_map[key]
143 # if price_entry.amount.number == dup_entry.amount.number:
144 # # Skip duplicates.
145 # continue
146 # else:
147 # errors.append(
148 # ImplicitPriceError(
149 # entry.meta,
150 # "Duplicate prices for {} on {}".format(entry,
151 # dup_entry),
152 # entry))
153 except KeyError:
154 new_price_entry_map[key] = price_entry
155 new_entries.append(price_entry)
156
157 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 @@
1__copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \
2 "2020 Kristóf Marussy <kristof@marussy.com>"
3__license__ = "GNU GPLv2"
4
5import unittest
6
7from beancount.core.number import D
8from beancount.core import data
9from beancount.parser import cmptest
10from beancount import loader
11
12from beancount_extras_kris7t.plugins import selective_implicit_prices as implicit_prices
13
14
15class TestImplicitPrices(cmptest.TestCase):
16
17 @loader.load_doc()
18 def test_add_implicit_prices__all_cases(self, entries, _, options_map):
19 """
20 1702-04-02 commodity USD
21 implicit-prices: TRUE
22
23 2013-01-01 commodity HOOL
24 implicit-prices: TRUE
25
26 2013-01-01 open Assets:Account1
27 2013-01-01 open Assets:Account2
28 2013-01-01 open Assets:Other
29
30 ;; An explicit price directive.
31 2013-02-01 price USD 1.10 CAD
32
33 2013-04-01 * "A transaction with a price conversion."
34 Assets:Account1 150 USD @ 1.12 CAD
35 Assets:Other
36
37 ;; This should book at price at the cost.
38 2013-04-02 * "A transaction with a cost."
39 Assets:Account1 1500 HOOL {520 USD}
40 Assets:Other
41
42 ;; This one should be IGNORED because it books against the above.
43 2013-04-03 * "A transaction with a cost that reduces an existing position"
44 Assets:Account1 -500 HOOL {520 USD}
45 Assets:Other
46
47 ;; This one should generate the price, even if it is reducing.
48 2013-04-04 * "A transaction with a cost that reduces existing position, with price"
49 Assets:Account1 -100 HOOL {520 USD} @ 530 USD
50 Assets:Other
51
52 ;; This is not reducing and should also book a price at cost.
53 2013-04-05 * "A transaction with another cost that is not reducing."
54 Assets:Account1 500 HOOL {540 USD}
55 Assets:Other
56
57 ;; The price here overrides the cost and should create an entry.
58 2013-04-06 * "A transaction with a cost and a price."
59 Assets:Account1 500 HOOL {540 USD} @ 560 USD
60 Assets:Other
61 """
62 self.assertEqual(12, len(entries))
63 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
64 price_entries = [entry for entry in new_entries if isinstance(entry, data.Price)]
65
66 self.assertEqualEntries("""
67 1702-04-02 commodity USD
68 implicit-prices: TRUE
69
70 2013-01-01 commodity HOOL
71 implicit-prices: TRUE
72
73 2013-01-01 open Assets:Account1
74 2013-01-01 open Assets:Account2
75 2013-01-01 open Assets:Other
76
77 2013-02-01 price USD 1.10 CAD
78
79 2013-04-01 * "A transaction with a price conversion."
80 Assets:Account1 150 USD @ 1.12 CAD
81 Assets:Other -168.00 CAD
82
83 2013-04-01 price USD 1.12 CAD
84
85 2013-04-02 * "A transaction with a cost."
86 Assets:Account1 1500 HOOL {520 USD}
87 Assets:Other -780000 USD
88
89 2013-04-02 price HOOL 520 USD
90
91 2013-04-03 * "A transaction with a cost that reduces an existing position"
92 Assets:Account1 -500 HOOL {520 USD}
93 Assets:Other 260000 USD
94
95 2013-04-04 * "A transaction with a cost that reduces existing position, with price"
96 Assets:Account1 -100 HOOL {520 USD} @ 530 USD
97 Assets:Other 52000 USD
98
99 2013-04-04 price HOOL 530 USD
100
101 2013-04-05 * "A transaction with another cost that is not reducing."
102 Assets:Account1 500 HOOL {540 USD}
103 Assets:Other -270000 USD
104
105 2013-04-05 price HOOL 540 USD
106
107 2013-04-06 * "A transaction with a cost and a price."
108 Assets:Account1 500 HOOL {540 USD} @ 560 USD
109 Assets:Other -270000 USD
110
111 2013-04-06 price HOOL 560 USD
112 """, new_entries)
113
114 self.assertEqual(6, len(price_entries))
115 expected_values = [(x[0], x[1], D(x[2])) for x in [
116 ('USD', 'CAD', '1.10'),
117 ('USD', 'CAD', '1.12'),
118 ('HOOL', 'USD', '520.00'),
119 ('HOOL', 'USD', '530.00'),
120 ('HOOL', 'USD', '540.00'),
121 ('HOOL', 'USD', '560.00')
122 ]]
123 for expected, price in zip(expected_values, price_entries):
124 actual = (price.currency, price.amount.currency, price.amount.number)
125 self.assertEqual(expected, actual)
126
127 @loader.load_doc()
128 def test_add_implicit_prices__other_account(self, entries, errors, options_map):
129 """
130 2013-01-01 commodity HOOL
131 implicit-prices: TRUE
132
133 2013-01-01 open Assets:Account1
134 2013-01-01 open Assets:Account2 "NONE"
135 2013-01-01 open Assets:Other
136
137 2013-04-01 *
138 Assets:Account1 1500 HOOL {520 USD}
139 Assets:Other
140
141 2013-04-02 *
142 Assets:Account2 1500 HOOL {530 USD}
143 Assets:Other
144
145 2013-04-10 * "Reduces existing position in account 1"
146 Assets:Account1 -100 HOOL {520 USD}
147 Assets:Other 52000 USD
148
149 2013-04-11 * "Does not find an existing position in account 2"
150 Assets:Account2 -200 HOOL {531 USD}
151 Assets:Other 106200 USD
152
153 """
154 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
155 self.assertEqualEntries("""
156 2013-01-01 commodity HOOL
157 implicit-prices: TRUE
158
159 2013-01-01 open Assets:Account1
160 2013-01-01 open Assets:Account2 "NONE"
161 2013-01-01 open Assets:Other
162
163 2013-04-01 *
164 Assets:Account1 1500 HOOL {520 USD}
165 Assets:Other -780000 USD
166
167 2013-04-02 *
168 Assets:Account2 1500 HOOL {530 USD}
169 Assets:Other -795000 USD
170
171 2013-04-01 price HOOL 520 USD
172
173 2013-04-02 price HOOL 530 USD
174
175 2013-04-10 * "Reduces existing position in account 1"
176 Assets:Account1 -100 HOOL {520 USD}
177 Assets:Other 52000 USD
178
179 2013-04-11 * "Does not find an existing position in account 2"
180 Assets:Account2 -200 HOOL {531 USD}
181 Assets:Other 106200 USD
182
183 ;; Because a match was not found against the inventory, a price will be added.
184 2013-04-11 price HOOL 531 USD
185
186 """, new_entries)
187
188 @loader.load_doc()
189 def test_add_implicit_prices__duplicates_on_same_transaction(self,
190 entries, _, options_map):
191 """
192 2013-01-01 commodity HOOL
193 implicit-prices: TRUE
194
195 2013-01-01 open Assets:Account1
196 2013-01-01 open Assets:Account2
197 2013-01-01 open Assets:Other
198
199 2013-04-01 * "Allowed because of same price"
200 Assets:Account1 1500 HOOL {520 USD}
201 Assets:Account2 1500 HOOL {520 USD}
202 Assets:Other
203
204 2013-04-02 * "Second one is disallowed because of different price"
205 Assets:Account1 1500 HOOL {520 USD}
206 Assets:Account2 1500 HOOL {530 USD}
207 Assets:Other
208
209 """
210 new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map)
211 self.assertEqual([], [type(error) for error in errors])
212 self.assertEqualEntries("""
213 2013-01-01 commodity HOOL
214 implicit-prices: TRUE
215
216 2013-01-01 open Assets:Account1
217 2013-01-01 open Assets:Account2
218 2013-01-01 open Assets:Other
219
220 2013-04-01 * "Allowed because of same price"
221 Assets:Account1 1500 HOOL {520 USD}
222 Assets:Account2 1500 HOOL {520 USD}
223 Assets:Other -1560000 USD
224
225 2013-04-01 price HOOL 520 USD
226
227 2013-04-02 * "Second one is disallowed because of different price"
228 Assets:Account1 1500 HOOL {520 USD}
229 Assets:Account2 1500 HOOL {530 USD}
230 Assets:Other -1575000 USD
231
232 2013-04-02 price HOOL 520 USD
233 2013-04-02 price HOOL 530 USD ;; Allowed for now.
234
235 """, new_entries)
236
237 @loader.load_doc()
238 def test_add_implicit_prices__duplicates_on_different_transactions(self,
239 entries, _,
240 options_map):
241 """
242 2013-01-01 commodity HOOL
243 implicit-prices: TRUE
244
245 2013-01-01 open Assets:Account1
246 2013-01-01 open Assets:Account2
247 2013-01-01 open Assets:Other
248
249 2013-04-01 * "Allowed because of same price #1"
250 Assets:Account1 1500 HOOL {520 USD}
251 Assets:Other
252
253 2013-04-01 * "Allowed because of same price #2"
254 Assets:Account2 1500 HOOL {520 USD}
255 Assets:Other
256
257 2013-04-02 * "Second one is disallowed because of different price #1"
258 Assets:Account1 1500 HOOL {520 USD}
259 Assets:Other
260
261 2013-04-02 * "Second one is disallowed because of different price #2"
262 Assets:Account2 1500 HOOL {530 USD}
263 Assets:Other
264
265 """
266 new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map)
267 self.assertEqual([], [type(error) for error in errors])
268 self.assertEqualEntries("""
269 2013-01-01 commodity HOOL
270 implicit-prices: TRUE
271
272 2013-01-01 open Assets:Account1
273 2013-01-01 open Assets:Account2
274 2013-01-01 open Assets:Other
275
276 2013-04-01 * "Allowed because of same price #1"
277 Assets:Account1 1500 HOOL {520 USD}
278 Assets:Other -780000 USD
279
280 2013-04-01 * "Allowed because of same price #2"
281 Assets:Account2 1500 HOOL {520 USD}
282 Assets:Other -780000 USD
283
284 2013-04-01 price HOOL 520 USD
285
286 2013-04-02 * "Second one is disallowed because of different price #1"
287 Assets:Account1 1500 HOOL {520 USD}
288 Assets:Other -780000 USD
289
290 2013-04-02 * "Second one is disallowed because of different price #2"
291 Assets:Account2 1500 HOOL {530 USD}
292 Assets:Other -795000 USD
293
294 2013-04-02 price HOOL 520 USD
295 2013-04-02 price HOOL 530 USD ;; Allowed for now.
296
297 """, new_entries)
298
299 @loader.load_doc()
300 def test_add_implicit_prices__duplicates_overloaded(self, entries, _, options_map):
301 """
302 2013-01-01 commodity HOOL
303 implicit-prices: TRUE
304
305 2013-01-01 open Assets:Account1
306 2013-01-01 open Assets:Other
307
308 2013-04-01 * "Allowed, sets the price for that day"
309 Assets:Account1 1500 HOOL {520 USD}
310 Assets:Other
311
312 2013-04-01 * "Will be ignored, price for the day already set"
313 Assets:Account1 1500 HOOL {530 USD}
314 Assets:Other
315
316 2013-04-01 * "Should be ignored too, price for the day already set"
317 Assets:Account1 1500 HOOL {530 USD}
318 Assets:Other
319 """
320 new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map)
321 self.assertEqual([], [type(error) for error in errors])
322 self.assertEqualEntries("""
323 2013-01-01 commodity HOOL
324 implicit-prices: TRUE
325
326 2013-01-01 open Assets:Account1
327 2013-01-01 open Assets:Other
328
329 2013-04-01 * "Allowed, sets the price for that day"
330 Assets:Account1 1500 HOOL {520 USD}
331 Assets:Other -780000 USD
332
333 2013-04-01 * "Will be ignored, price for the day already set"
334 Assets:Account1 1500 HOOL {530 USD}
335 Assets:Other -795000 USD
336
337 2013-04-01 * "Should be ignored too, price for the day already set"
338 Assets:Account1 1500 HOOL {530 USD}
339 Assets:Other -795000 USD
340
341 2013-04-01 price HOOL 520 USD
342 2013-04-01 price HOOL 530 USD
343
344 """, new_entries)
345
346 @loader.load_doc()
347 def test_add_implicit_prices__not_enabled(self, entries, errors, options_map):
348 """
349 2013-01-01 open Assets:Account1
350 2013-01-01 open Assets:Other
351
352 2013-04-01 *
353 Assets:Account1 1500 HOOL {520 USD}
354 Assets:Other
355 """
356 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
357 self.assertEqualEntries("""
358 2013-01-01 open Assets:Account1
359 2013-01-01 open Assets:Other
360
361 2013-04-01 *
362 Assets:Account1 1500 HOOL {520 USD}
363 Assets:Other -780000 USD
364 """, new_entries)
365
366 @loader.load_doc()
367 def test_add_implicit_prices__disabled(self, entries, errors, options_map):
368 """
369 2013-01-01 commodity HOOL
370 implicit-prices: FALSE
371
372 2013-01-01 open Assets:Account1
373 2013-01-01 open Assets:Other
374
375 2013-04-01 *
376 Assets:Account1 1500 HOOL {520 USD}
377 Assets:Other
378 """
379 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
380 self.assertEqualEntries("""
381 2013-01-01 commodity HOOL
382 implicit-prices: FALSE
383
384 2013-01-01 open Assets:Account1
385 2013-01-01 open Assets:Other
386
387 2013-04-01 *
388 Assets:Account1 1500 HOOL {520 USD}
389 Assets:Other -780000 USD
390 """, new_entries)
391
392 @loader.load_doc()
393 def test_add_implicit_prices__invalid(self, entries, errors, options_map):
394 """
395 2013-01-01 commodity HOOL
396 implicit-prices: "yes"
397
398 2013-01-01 open Assets:Account1
399 2013-01-01 open Assets:Other
400
401 2013-04-01 *
402 Assets:Account1 1500 HOOL {520 USD}
403 Assets:Other
404 """
405 _, new_errors = implicit_prices.add_implicit_prices(entries, options_map)
406 self.assertRegex(new_errors[0].message, '^implicit-prices must be Boolean')
407
408
409if __name__ == '__main__':
410 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 @@
1'''
2Plugin that closes an account by transferring its whole balance to another account.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7import datetime as dt
8from decimal import Decimal
9from typing import Any, Dict, List, NamedTuple, Optional, Tuple
10
11from beancount.core import amount
12from beancount.core.data import Custom, Directive, Entries, Meta, Transaction, Union
13from beancount.core.number import ZERO
14
15__plugins__ = ('apply_templates',)
16
17TEMPLATE_META = 'template'
18TEMPLATE_USE_CUSTOM = 'template-use'
19TEMPLATE_DELETE_CUSTOM = 'template-delete'
20TEMPLATE_TAG_PREFIX = 'template'
21
22
23Templates = Dict[str, Transaction]
24
25
26class TemplateError(NamedTuple):
27 source: Optional[Meta]
28 message: str
29 entry: Directive
30
31
32def _create_transaction(date: dt.date,
33 meta: Meta,
34 template: Transaction,
35 scale_factor: Decimal) -> Transaction:
36 return template._replace(
37 date=date,
38 meta={**template.meta, **meta},
39 postings=[posting._replace(units=amount.mul(posting.units, scale_factor))
40 for posting in template.postings])
41
42
43def _use_template(entry: Custom,
44 templates: Templates) -> Union[Transaction, TemplateError]:
45 if len(entry.values) == 0:
46 return TemplateError(entry.meta, 'Template name missing', entry)
47 if len(entry.values) > 2:
48 return TemplateError(
49 entry.meta,
50 f'Too many {TEMPLATE_USE_CUSTOM} arguments',
51 entry)
52 template_name = entry.values[0].value
53 if not isinstance(template_name, str):
54 return TemplateError(
55 entry.meta,
56 f'Template name must be a string, got {template_name} instead',
57 entry)
58 template = templates.get(template_name, None)
59 if template is None:
60 return TemplateError(
61 entry.meta,
62 f'Unknown template: {template_name}',
63 entry)
64 if len(entry.values) == 2:
65 scale_factor = entry.values[1].value
66 if not isinstance(scale_factor, Decimal):
67 return TemplateError(
68 entry.meta,
69 f'Invalid scale factor {scale_factor}',
70 entry)
71 if scale_factor == ZERO:
72 return TemplateError(
73 entry.meta,
74 f'Scale factor must not be {ZERO}',
75 entry)
76 else:
77 scale_factor = Decimal(1.0)
78 return _create_transaction(entry.date, entry.meta, template, scale_factor)
79
80
81def _add_template(entry: Transaction, templates: Templates) -> Optional[TemplateError]:
82 template_name = entry.meta[TEMPLATE_META]
83 if not isinstance(template_name, str):
84 return TemplateError(
85 entry.meta,
86 f'{TEMPLATE_META} must be a string, got {template_name} instead',
87 entry)
88 new_meta = dict(entry.meta)
89 del new_meta[TEMPLATE_META]
90 templates[template_name] = entry._replace(
91 meta=new_meta,
92 links={*entry.links, f'{TEMPLATE_TAG_PREFIX}_{template_name}'})
93 return None
94
95
96def _delete_template(entry: Custom, templates: Templates) -> Optional[TemplateError]:
97 if len(entry.values) != 1:
98 return TemplateError(
99 entry.meta,
100 f'{TEMPLATE_DELETE_CUSTOM} takes a single argument',
101 entry)
102 template_name = entry.values[0].value
103 if not isinstance(template_name, str):
104 return TemplateError(
105 entry.meta,
106 f'Template name must be a string, got {template_name} instead',
107 entry)
108 if template_name not in templates:
109 return TemplateError(
110 entry.meta,
111 f'Unknown template: {template_name}',
112 entry)
113 del templates[template_name]
114 return None
115
116
117def apply_templates(entries: Entries,
118 options_map: Dict[str, Any],
119 config_str: Optional[str] = None) -> \
120 Tuple[Entries, List[TemplateError]]:
121 new_entries: Entries = []
122 errors: List[TemplateError] = []
123 templates: Templates = {}
124 for entry in entries:
125 if isinstance(entry, Transaction) and TEMPLATE_META in entry.meta:
126 result = _add_template(entry, templates)
127 if result:
128 errors.append(result)
129 elif isinstance(entry, Custom):
130 if entry.type == TEMPLATE_USE_CUSTOM:
131 result = _use_template(entry, templates)
132 if isinstance(result, TemplateError):
133 errors.append(result)
134 else:
135 new_entries.append(result)
136 elif entry.type == TEMPLATE_DELETE_CUSTOM:
137 result = _delete_template(entry, templates)
138 if result:
139 errors.append(result)
140 else:
141 new_entries.append(entry)
142 else:
143 new_entries.append(entry)
144 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 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8import pytest
9
10
11class TestClosingBalance(cmptest.TestCase):
12
13 @loader.load_doc()
14 def test_use_template_simple(self, entries, errors, options_map):
15 '''
16 plugin "beancount_extras_kris7t.plugins.templates"
17
18 2020-01-01 open Assets:Checking
19 2020-01-01 open Expenses:Food
20
21 2020-01-01 * "Eating out" #tag ^link
22 template: "eating-out"
23 Assets:Checking 25 USD
24 Expenses:Food -25 USD
25
26 2020-03-15 custom "template-use" "eating-out"
27
28 2020-04-12 custom "template-use" "eating-out"
29 '''
30 self.assertEqualEntries('''
31 plugin "beancount_extras_kris7t.plugins.closing_balance"
32
33 2020-01-01 open Assets:Checking
34 2020-01-01 open Expenses:Food
35
36 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
37 template: "eating-out"
38 Assets:Checking 25 USD
39 Expenses:Food -25 USD
40
41 2020-04-12 * "Eating out" #tag ^link ^template_eating-out
42 template: "eating-out"
43 Assets:Checking 25 USD
44 Expenses:Food -25 USD
45 ''', entries)
46
47 @loader.load_doc()
48 def test_use_template_metadata(self, entries, errors, options_map):
49 '''
50 plugin "beancount_extras_kris7t.plugins.templates"
51
52 2020-01-01 open Assets:Checking
53 2020-01-01 open Expenses:Food
54
55 2020-01-01 * "Eating out" #tag ^link
56 template: "eating-out"
57 meta1: "data"
58 meta2: TRUE
59 Assets:Checking 25 USD
60 Expenses:Food -25 USD
61
62 2020-03-15 custom "template-use" "eating-out"
63 meta1: "foo"
64 meta3: 3.14
65 '''
66 self.assertEqualEntries('''
67 plugin "beancount_extras_kris7t.plugins.closing_balance"
68
69 2020-01-01 open Assets:Checking
70 2020-01-01 open Expenses:Food
71
72 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
73 template: "eating-out"
74 meta1: "foo"
75 meta2: TRUE
76 meta3: 2.14
77 Assets:Checking 25 USD
78 Expenses:Food -25 USD
79 ''', entries)
80
81 @loader.load_doc()
82 def test_use_template_scaled(self, entries, errors, options_map):
83 '''
84 plugin "beancount_extras_kris7t.plugins.templates"
85
86 2020-01-01 open Assets:Checking
87 2020-01-01 open Expenses:Food
88
89 2020-01-01 * "Eating out" #tag ^link
90 template: "eating-out"
91 Assets:Checking 1 USD
92 Expenses:Food -1 USD
93
94 2020-03-15 custom "template-use" "eating-out" 25
95
96 2020-04-12 custom "template-use" "eating-out" 27
97 '''
98 self.assertEqualEntries('''
99 plugin "beancount_extras_kris7t.plugins.closing_balance"
100
101 2020-01-01 open Assets:Checking
102 2020-01-01 open Expenses:Food
103
104 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
105 template: "eating-out"
106 Assets:Checking 25 USD
107 Expenses:Food -25 USD
108
109 2020-04-12 * "Eating out" #tag ^link ^template_eating-out
110 template: "eating-out"
111 Assets:Checking 27 USD
112 Expenses:Food -27 USD
113 ''', entries)
114
115 @loader.load_doc()
116 def test_use_template_overwritten(self, entries, errors, options_map):
117 '''
118 plugin "beancount_extras_kris7t.plugins.templates"
119
120 2020-01-01 open Assets:Checking
121 2020-01-01 open Expenses:Food
122 2020-01-01 open Expenses:Tax
123
124 2020-04-01 * "Eating out" #tag ^link
125 template: "eating-out"
126 Assets:Checking 1.10 USD
127 Expenses:Food -1 USD
128 Expenses:Tax -0.10 USD
129
130 2020-01-01 * "Eating out" #tag ^link
131 template: "eating-out"
132 Assets:Checking 1 USD
133 Expenses:Food -1 USD
134
135 2020-03-15 custom "template-use" "eating-out" 25
136
137 2020-04-12 custom "template-use" "eating-out" 27
138 '''
139 self.assertEqualEntries('''
140 plugin "beancount_extras_kris7t.plugins.closing_balance"
141
142 2020-01-01 open Assets:Checking
143 2020-01-01 open Expenses:Food
144 2020-01-01 open Expenses:Tax
145
146 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
147 template: "eating-out"
148 Assets:Checking 25 USD
149 Expenses:Food -25 USD
150
151 2020-04-12 * "Eating out" #tag ^link ^template_eating-out
152 template: "eating-out"
153 Assets:Checking 29.70 USD
154 Expenses:Food -27 USD
155 Expenses:Tax -2.70 USD
156 ''', entries)
157
158 @loader.load_doc(expect_errors=True)
159 def test_invalid_name(self, entries, errors, options_map):
160 '''
161 plugin "beancount_extras_kris7t.plugins.templates"
162
163 2020-01-01 open Assets:Checking
164 2020-01-01 open Expenses:Food
165
166 2020-01-01 * "Eating out"
167 template: TRUE
168 Assets:Checking 25 USD
169 Expenses:Food -25 USD
170 '''
171 self.assertRegex(errors[0].message, "^template must be a string")
172
173 @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad")
174 @loader.load_doc(expect_errors=True)
175 def test_use_missing_name(self, entries, errors, options_map):
176 '''
177 plugin "beancount_extras_kris7t.plugins.templates"
178
179 2020-01-01 open Assets:Checking
180 2020-01-01 open Expenses:Food
181
182 2020-01-01 * "Eating out"
183 template: "eating-out"
184 Assets:Checking 25 USD
185 Expenses:Food -25 USD
186
187 2020-03-15 custom "template-use"
188 '''
189 self.assertRegex(errors[0].message, "^Template name missing")
190
191 @loader.load_doc(expect_errors=True)
192 def test_use_too_many_arguments(self, entries, errors, options_map):
193 '''
194 plugin "beancount_extras_kris7t.plugins.templates"
195
196 2020-01-01 open Assets:Checking
197 2020-01-01 open Expenses:Food
198
199 2020-01-01 * "Eating out"
200 template: "eating-out"
201 Assets:Checking 25 USD
202 Expenses:Food -25 USD
203
204 2020-03-15 custom "template-use" "eating-out" 2 "another"
205 '''
206 self.assertRegex(errors[0].message, "^Too many template-use arguments")
207
208 @loader.load_doc(expect_errors=True)
209 def test_use_invalid_name(self, entries, errors, options_map):
210 '''
211 plugin "beancount_extras_kris7t.plugins.templates"
212
213 2020-03-15 custom "template-use" TRUE
214 '''
215 self.assertRegex(errors[0].message, "^Template name must be a string")
216
217 @loader.load_doc(expect_errors=True)
218 def test_use_unknown(self, entries, errors, options_map):
219 '''
220 plugin "beancount_extras_kris7t.plugins.templates"
221
222 2020-03-15 custom "template-use" "taxi"
223 '''
224 self.assertRegex(errors[0].message, "^Unknown template")
225
226 @loader.load_doc(expect_errors=True)
227 def test_use_invalid_scale_factor(self, entries, errors, options_map):
228 '''
229 plugin "beancount_extras_kris7t.plugins.templates"
230
231 2020-01-01 open Assets:Checking
232 2020-01-01 open Expenses:Food
233
234 2020-01-01 * "Eating out"
235 template: "eating-out"
236 Assets:Checking 25 USD
237 Expenses:Food -25 USD
238
239 2020-03-15 custom "template-use" "eating-out" TRUE
240 '''
241 self.assertRegex(errors[0].message, "^Invalid scale factor")
242
243 @loader.load_doc(expect_errors=True)
244 def test_use_zero_scale_factor(self, entries, errors, options_map):
245 '''
246 plugin "beancount_extras_kris7t.plugins.templates"
247
248 2020-01-01 open Assets:Checking
249 2020-01-01 open Expenses:Food
250
251 2020-01-01 * "Eating out"
252 template: "eating-out"
253 Assets:Checking 25 USD
254 Expenses:Food -25 USD
255
256 2020-03-15 custom "template-use" "eating-out" 0.0
257 '''
258 self.assertRegex(errors[0].message, "^Scale factor must not be 0")
259
260 @loader.load_doc(expect_errors=True)
261 def test_template_delete(self, entries, errors, options_map):
262 '''
263 plugin "beancount_extras_kris7t.plugins.templates"
264
265 2020-01-01 open Assets:Checking
266 2020-01-01 open Expenses:Food
267
268 2020-01-01 * "Eating out"
269 template: "eating-out"
270 Assets:Checking 25 USD
271 Expenses:Food -25 USD
272
273 2020-03-01 custom "template-delete" "eating-out"
274
275 2020-03-15 custom "template-use" "eating-out"
276 '''
277 self.assertRegex(errors[0].message, "^Unknown template")
278
279 @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad")
280 @loader.load_doc(expect_errors=True)
281 def test_template_delete_too_few_arguments(self, entries, errors, options_map):
282 '''
283 plugin "beancount_extras_kris7t.plugins.templates"
284
285 2020-03-01 custom "template-delete"
286 '''
287 self.assertRegex(errors[0].message, "^template-delete takes a single argument")
288
289 @loader.load_doc(expect_errors=True)
290 def test_template_delete_too_many_arguments(self, entries, errors, options_map):
291 '''
292 plugin "beancount_extras_kris7t.plugins.templates"
293
294 2020-01-01 open Assets:Checking
295 2020-01-01 open Expenses:Food
296
297 2020-01-01 * "Eating out"
298 template: "eating-out"
299 Assets:Checking 25 USD
300 Expenses:Food -25 USD
301
302 2020-03-01 custom "template-delete" "eating-out" TRUE
303 '''
304 self.assertRegex(errors[0].message, "^template-delete takes a single argument")
305
306 @loader.load_doc(expect_errors=True)
307 def test_template_delete_invalid_argument(self, entries, errors, options_map):
308 '''
309 plugin "beancount_extras_kris7t.plugins.templates"
310
311 2020-03-01 custom "template-delete" TRUE
312 '''
313 self.assertRegex(errors[0].message, "^Template name must be a string")
314
315 @loader.load_doc(expect_errors=True)
316 def test_template_delete_unknown(self, entries, errors, options_map):
317 '''
318 plugin "beancount_extras_kris7t.plugins.templates"
319
320 2020-03-01 custom "template-delete" "taxi"
321 '''
322 self.assertRegex(errors[0].message, "^Unknown template")
323
324
325if __name__ == '__main__':
326 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 @@
1'''
2Plugin that splits off postings into a new transaction to simulate settlement dates.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from collections import defaultdict
8import datetime as dt
9from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union
10
11from beancount.core import amount, convert
12from beancount.core.amount import Amount
13from beancount.core.data import Cost, CostSpec, Directive, Entries, Meta, Posting, Open, \
14 Transaction
15from beancount.core.inventory import Inventory
16from beancount.core.number import ZERO
17
18__plugins__ = ('split_entries_via_transfer_accounts',)
19
20TRANSFER_ACCOUNT_META = 'transfer-account'
21TRANSFER_DATE_META = 'transfer-date'
22TRANSFER_CONVERTED_META = 'transfer-converted'
23TRANSFER_CONVERTED_DEFAULT = True
24
25
26class TransferAccountError(NamedTuple):
27 source: Optional[Meta]
28 message: str
29 entry: Directive
30
31
32class _OutgoingTransfer(NamedTuple):
33 accout: str
34 currency: str
35 cost: Optional[Union[Cost, CostSpec]]
36 price: Optional[Amount]
37
38
39class _IncomingTransfer(NamedTuple):
40 account: str
41 date: dt.date
42
43
44class _IncomingPostings(NamedTuple):
45 postings: List[Posting]
46 inventroy: Inventory
47
48
49class _Splitter:
50 _entry: Transaction
51 _processed_entries: Entries
52 _errors: List[TransferAccountError]
53 _default_converted: Dict[str, bool]
54 _processed_postings: List[Posting]
55 _amounts_to_transfer: Dict[_OutgoingTransfer, Amount]
56 _new_transactions: Dict[_IncomingTransfer, _IncomingPostings]
57
58 def __init__(
59 self,
60 entry: Transaction,
61 processed_entries: Entries,
62 errors: List[TransferAccountError],
63 default_converted: Dict[str, bool]):
64 self._entry = entry
65 self._processed_entries = processed_entries
66 self._errors = errors
67 self._default_converted = default_converted
68 self._processed_postings = []
69 self._amounts_to_transfer = {}
70 self._new_transactions = defaultdict(lambda: _IncomingPostings([], Inventory()))
71
72 def split(self) -> None:
73 for posting in self._entry.postings:
74 self._split_posting(posting)
75 if not self._amounts_to_transfer:
76 self._processed_entries.append(self._entry)
77 return
78 for (account, _, cost, price), units in self._amounts_to_transfer.items():
79 if units.number != ZERO:
80 self._processed_postings.append(Posting(account, units, cost, price, None, None))
81 self._processed_entries.append(self._entry._replace(postings=self._processed_postings))
82 for (account, date), (postings, inv) in self._new_transactions.items():
83 for (units, cost) in inv:
84 postings.append(Posting(account, -units, cost, None, None, None))
85 self._processed_entries.append(self._entry._replace(date=date, postings=postings))
86
87 def _split_posting(self, posting: Posting) -> None:
88 if not posting.meta:
89 self._processed_postings.append(posting)
90 return
91 transfer_account = posting.meta.pop(TRANSFER_ACCOUNT_META, None)
92 transfer_date = posting.meta.pop(TRANSFER_DATE_META, None)
93 transfer_converted = posting.meta.pop(TRANSFER_CONVERTED_META, None)
94 if transfer_account is None:
95 if transfer_date is not None:
96 self._report_error(
97 f'{TRANSFER_DATE_META} was set but {TRANSFER_ACCOUNT_META} was not')
98 if transfer_converted is not None:
99 self._report_error(
100 f'{TRANSFER_CONVERTED_META} was set but {TRANSFER_ACCOUNT_META} was not')
101 self._processed_postings.append(posting)
102 return
103 if not isinstance(transfer_account, str):
104 self._report_error(
105 f'{TRANSFER_ACCOUNT_META} must be a string, got {transfer_account} instead')
106 self._processed_postings.append(posting)
107 return
108 if transfer_date is None:
109 transfer_date = self._entry.date
110 elif not isinstance(transfer_date, dt.date):
111 self._report_error(
112 f'{TRANSFER_DATE_META} must be a date, got {transfer_date} instead')
113 transfer_date = self._entry.date
114 transfer_converted_default = self._default_converted.get(
115 transfer_account, TRANSFER_CONVERTED_DEFAULT)
116 if transfer_converted is None:
117 transfer_converted = transfer_converted_default
118 elif not isinstance(transfer_converted, bool):
119 self._report_error(
120 f'{TRANSFER_CONVERTED_META} must be a Boolean, got {transfer_converted} instead')
121 transfer_converted = transfer_converted_default
122 elif posting.price is None and posting.cost is None:
123 self._report_error(
124 f'{TRANSFER_CONVERTED_META} was set, but there is no conversion')
125 assert posting.units
126 self._split_posting_with_options(
127 posting, transfer_account, transfer_date, transfer_converted)
128
129 def _split_posting_with_options(
130 self,
131 posting: Posting,
132 transfer_account: str,
133 transfer_date: dt.date,
134 transfer_converted: bool) -> None:
135 incoming = _IncomingTransfer(transfer_account, transfer_date)
136 incoming_postings, inv = self._new_transactions[incoming]
137 converted_amount = convert.get_weight(posting)
138 if transfer_converted:
139 outgoing = _OutgoingTransfer(
140 transfer_account, posting.units.currency, posting.cost, posting.price)
141 self._accumulate_outgoing(outgoing, posting.units)
142 incoming_postings.append(posting._replace(price=None))
143 inv.add_amount(posting.units, posting.cost)
144 else:
145 outgoing = _OutgoingTransfer(transfer_account, converted_amount.currency, None, None)
146 self._accumulate_outgoing(outgoing, converted_amount)
147 incoming_postings.append(posting)
148 inv.add_amount(converted_amount)
149
150 def _accumulate_outgoing(self, outgoing: _OutgoingTransfer, units: Amount) -> None:
151 current_amount = self._amounts_to_transfer.get(outgoing, None)
152 if current_amount:
153 self._amounts_to_transfer[outgoing] = amount.add(current_amount, units)
154 else:
155 self._amounts_to_transfer[outgoing] = units
156
157 def _report_error(self, message: str) -> None:
158 self._errors.append(TransferAccountError(self._entry.meta, message, self._entry))
159
160
161def split_entries_via_transfer_accounts(
162 entries: Entries,
163 options_map: Dict[str, Any],
164 config_str: Optional[str] = None) -> Tuple[Entries, List[TransferAccountError]]:
165 default_converted: Dict[str, bool] = {}
166 errors: List[TransferAccountError] = []
167 for entry in entries:
168 if isinstance(entry, Open):
169 if not entry.meta:
170 continue
171 transfer_converted = entry.meta.get(TRANSFER_CONVERTED_META, None)
172 if transfer_converted is None:
173 continue
174 if not isinstance(transfer_converted, bool):
175 errors.append(TransferAccountError(
176 entry.meta,
177 f'{TRANSFER_CONVERTED_META} must be a Boolean,' +
178 f' got {transfer_converted} instead',
179 entry))
180 default_converted[entry.account] = transfer_converted
181 processed_entries: Entries = []
182 for entry in entries:
183 if isinstance(entry, Transaction):
184 splitter = _Splitter(entry, processed_entries, errors, default_converted)
185 splitter.split()
186 else:
187 processed_entries.append(entry)
188 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 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8
9
10class TestTransferAccounts(cmptest.TestCase):
11
12 @loader.load_doc()
13 def test_same_currency(self, entries, _, __):
14 '''
15 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
16
17 2020-01-01 open Assets:Checking
18 2020-01-01 open Liabilities:CreditCard
19 2020-01-01 open Expenses:Taxi
20
21 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
22 Assets:Checking -20 USD
23 Expenses:Taxi
24 transfer-account: Liabilities:CreditCard
25 transfer-date: 2020-03-10
26 '''
27 self.assertEqualEntries('''
28 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
29
30 2020-01-01 open Assets:Checking
31 2020-01-01 open Liabilities:CreditCard
32 2020-01-01 open Expenses:Taxi
33
34 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
35 Assets:Checking -20 USD
36 Liabilities:CreditCard 20 USD
37
38 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
39 Expenses:Taxi 20 USD
40 Liabilities:CreditCard -20 USD
41 ''', entries)
42
43 @loader.load_doc()
44 def test_missing_date(self, entries, _, __):
45 '''
46 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
47
48 2020-01-01 open Assets:Checking
49 2020-01-01 open Liabilities:CreditCard
50 2020-01-01 open Expenses:Taxi
51
52 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
53 Assets:Checking -20 USD
54 Expenses:Taxi
55 transfer-account: Liabilities:CreditCard
56 '''
57 self.assertEqualEntries('''
58 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
59
60 2020-01-01 open Assets:Checking
61 2020-01-01 open Liabilities:CreditCard
62 2020-01-01 open Expenses:Taxi
63
64 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
65 Assets:Checking -20 USD
66 Liabilities:CreditCard 20 USD
67
68 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
69 Expenses:Taxi 20 USD
70 Liabilities:CreditCard -20 USD
71 ''', entries)
72
73 @loader.load_doc(expect_errors=True)
74 def test_missing_account_with_date(self, _, errors, __):
75 '''
76 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
77
78 2020-01-01 open Assets:Checking
79 2020-01-01 open Liabilities:CreditCard
80 2020-01-01 open Expenses:Taxi
81
82 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
83 Assets:Checking -20 USD
84 Expenses:Taxi
85 transfer-date: 2020-03-10
86 '''
87 self.assertRegex(errors[0].message, 'transfer-date was set but transfer-account was not')
88
89 @loader.load_doc(expect_errors=True)
90 def test_missing_account_with_conversion(self, _, errors, __):
91 '''
92 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
93
94 2020-01-01 open Assets:Checking
95 2020-01-01 open Liabilities:CreditCard
96 2020-01-01 open Expenses:Taxi
97
98 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
99 Assets:Checking -25.60 CAD
100 Expenses:Taxi 20 USD @ 1.28 CAD
101 transfer-converted: FALSE
102 '''
103 self.assertRegex(
104 errors[0].message, 'transfer-converted was set but transfer-account was not')
105
106 @loader.load_doc(expect_errors=True)
107 def test_invalid_account(self, _, errors, __):
108 '''
109 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
110
111 2020-01-01 open Assets:Checking
112 2020-01-01 open Liabilities:CreditCard
113 2020-01-01 open Expenses:Taxi
114
115 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
116 Assets:Checking -20 USD
117 Expenses:Taxi
118 transfer-account: 2020-03-10
119 '''
120 self.assertRegex(errors[0].message, 'transfer-account must be a string.*')
121
122 @loader.load_doc(expect_errors=True)
123 def test_invalid_date(self, _, errors, __):
124 '''
125 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
126
127 2020-01-01 open Assets:Checking
128 2020-01-01 open Liabilities:CreditCard
129 2020-01-01 open Expenses:Taxi
130
131 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
132 Assets:Checking -20 USD
133 Expenses:Taxi
134 transfer-account: Liabilities:CreditCard
135 transfer-date: "Ides of March"
136 '''
137 self.assertRegex(errors[0].message, 'transfer-date must be a date.*')
138
139 @loader.load_doc(expect_errors=True)
140 def test_invalid_conversion(self, _, errors, __):
141 '''
142 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
143
144 2020-01-01 open Assets:Checking
145 2020-01-01 open Liabilities:CreditCard
146 2020-01-01 open Expenses:Taxi
147
148 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
149 Assets:Checking -25.60 CAD
150 Expenses:Taxi 20 USD @ 1.28 CAD
151 transfer-account: Liabilities:CreditCard
152 transfer-converted: "indeed"
153 '''
154 self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*')
155
156 @loader.load_doc(expect_errors=True)
157 def test_invalid_account_conversion(self, _, errors, __):
158 '''
159 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
160
161 2020-01-01 open Liabilities:CreditCard
162 transfer-converted: "Indeed"
163 '''
164 self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*')
165
166 @loader.load_doc(expect_errors=True)
167 def test_redundant_conversion(self, _, errors, __):
168 '''
169 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
170
171 2020-01-01 open Assets:Checking
172 2020-01-01 open Liabilities:CreditCard
173 2020-01-01 open Expenses:Taxi
174
175 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
176 Assets:Checking -20 USD
177 Expenses:Taxi
178 transfer-account: Liabilities:CreditCard
179 transfer-converted: TRUE
180 '''
181 self.assertRegex(
182 errors[0].message, 'transfer-converted was set, but there is no conversion.*')
183
184 @loader.load_doc()
185 def test_converted_price_false(self, entries, _, __):
186 '''
187 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
188
189 2020-01-01 open Assets:Checking
190 2020-01-01 open Liabilities:CreditCard
191 2020-01-01 open Expenses:Taxi
192
193 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
194 Assets:Checking -25.60 CAD
195 Expenses:Taxi 20 USD @ 1.28 CAD
196 transfer-account: Liabilities:CreditCard
197 transfer-date: 2020-03-10
198 transfer-converted: FALSE
199 '''
200 self.assertEqualEntries('''
201 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
202
203 2020-01-01 open Assets:Checking
204 2020-01-01 open Liabilities:CreditCard
205 2020-01-01 open Expenses:Taxi
206
207 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
208 Assets:Checking -25.60 CAD
209 Liabilities:CreditCard 25.60 CAD
210
211 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
212 Expenses:Taxi 20 USD @ 1.28 CAD
213 Liabilities:CreditCard -25.60 CAD
214 ''', entries)
215
216 @loader.load_doc()
217 def test_converted_price_true(self, entries, _, __):
218 '''
219 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
220
221 2020-01-01 open Assets:Checking
222 2020-01-01 open Liabilities:CreditCard
223 2020-01-01 open Expenses:Taxi
224
225 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
226 Assets:Checking -25.60 CAD
227 Expenses:Taxi 20 USD @ 1.28 CAD
228 transfer-account: Liabilities:CreditCard
229 transfer-date: 2020-03-10
230 transfer-converted: TRUE
231 '''
232 self.assertEqualEntries('''
233 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
234
235 2020-01-01 open Assets:Checking
236 2020-01-01 open Liabilities:CreditCard
237 2020-01-01 open Expenses:Taxi
238
239 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
240 Assets:Checking -25.60 CAD
241 Liabilities:CreditCard 20 USD @ 1.28 CAD
242
243 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
244 Expenses:Taxi 20 USD
245 Liabilities:CreditCard -20 USD
246 ''', entries)
247
248 @loader.load_doc()
249 def test_converted_price_default(self, entries, _, __):
250 '''
251 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
252
253 2020-01-01 open Assets:Checking
254 2020-01-01 open Liabilities:CreditCard
255 2020-01-01 open Expenses:Taxi
256
257 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
258 Assets:Checking -25.60 CAD
259 Expenses:Taxi 20 USD @ 1.28 CAD
260 transfer-account: Liabilities:CreditCard
261 transfer-date: 2020-03-10
262 '''
263 self.assertEqualEntries('''
264 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
265
266 2020-01-01 open Assets:Checking
267 2020-01-01 open Liabilities:CreditCard
268 2020-01-01 open Expenses:Taxi
269
270 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
271 Assets:Checking -25.60 CAD
272 Liabilities:CreditCard 20 USD @ 1.28 CAD
273
274 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
275 Expenses:Taxi 20 USD
276 Liabilities:CreditCard -20 USD
277 ''', entries)
278
279 @loader.load_doc()
280 def test_converted_price_account_false(self, entries, _, __):
281 '''
282 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
283
284 2020-01-01 open Assets:Checking
285 2020-01-01 open Liabilities:CreditCard
286 transfer-converted: FALSE
287 2020-01-01 open Expenses:Taxi
288
289 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
290 Assets:Checking -25.60 CAD
291 Expenses:Taxi 20 USD @ 1.28 CAD
292 transfer-account: Liabilities:CreditCard
293 transfer-date: 2020-03-10
294 '''
295 self.assertEqualEntries('''
296 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
297
298 2020-01-01 open Assets:Checking
299 2020-01-01 open Liabilities:CreditCard
300 2020-01-01 open Expenses:Taxi
301
302 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
303 Assets:Checking -25.60 CAD
304 Liabilities:CreditCard 25.60 CAD
305
306 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
307 Expenses:Taxi 20 USD @ 1.28 CAD
308 Liabilities:CreditCard -25.60 CAD
309 ''', entries)
310
311 @loader.load_doc()
312 def test_converted_price_account_true(self, entries, _, __):
313 '''
314 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
315
316 2020-01-01 open Assets:Checking
317 2020-01-01 open Liabilities:CreditCard
318 transfer-converted: TRUE
319 2020-01-01 open Expenses:Taxi
320
321 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
322 Assets:Checking -25.60 CAD
323 Expenses:Taxi 20 USD @ 1.28 CAD
324 transfer-account: Liabilities:CreditCard
325 transfer-date: 2020-03-10
326 '''
327 self.assertEqualEntries('''
328 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
329
330 2020-01-01 open Assets:Checking
331 2020-01-01 open Liabilities:CreditCard
332 2020-01-01 open Expenses:Taxi
333
334 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
335 Assets:Checking -25.60 CAD
336 Liabilities:CreditCard 20 USD @ 1.28 CAD
337
338 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
339 Expenses:Taxi 20 USD
340 Liabilities:CreditCard -20 USD
341 ''', entries)
342
343 @loader.load_doc()
344 def test_converted_cost_false(self, entries, _, __):
345 '''
346 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
347
348 2020-01-01 open Assets:Checking
349 2020-01-01 open Liabilities:CreditCard
350 2020-01-01 open Expenses:Taxi
351
352 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
353 Assets:Checking -25.60 CAD
354 Expenses:Taxi 20 USD {1.28 CAD}
355 transfer-account: Liabilities:CreditCard
356 transfer-date: 2020-03-10
357 transfer-converted: FALSE
358 '''
359 self.assertEqualEntries('''
360 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
361
362 2020-01-01 open Assets:Checking
363 2020-01-01 open Liabilities:CreditCard
364 2020-01-01 open Expenses:Taxi
365
366 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
367 Assets:Checking -25.60 CAD
368 Liabilities:CreditCard 25.60 CAD
369
370 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
371 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15}
372 Liabilities:CreditCard -25.60 CAD
373 ''', entries)
374
375 @loader.load_doc()
376 def test_converted_cost_true(self, entries, _, __):
377 '''
378 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
379
380 2020-01-01 open Assets:Checking
381 2020-01-01 open Liabilities:CreditCard
382 2020-01-01 open Expenses:Taxi
383
384 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
385 Assets:Checking -25.60 CAD
386 Expenses:Taxi 20 USD {1.28 CAD}
387 transfer-account: Liabilities:CreditCard
388 transfer-date: 2020-03-10
389 transfer-converted: TRUE
390 '''
391 self.assertEqualEntries('''
392 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
393
394 2020-01-01 open Assets:Checking
395 2020-01-01 open Liabilities:CreditCard
396 2020-01-01 open Expenses:Taxi
397
398 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
399 Assets:Checking -25.60 CAD
400 Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15}
401
402 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
403 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15}
404 Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15}
405 ''', entries)
406
407 @loader.load_doc()
408 def test_converted_cost_and_price_false(self, entries, _, __):
409 '''
410 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
411
412 2020-01-01 open Assets:Checking
413 2020-01-01 open Liabilities:CreditCard
414 2020-01-01 open Expenses:Taxi
415
416 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
417 Assets:Checking -25.60 CAD
418 Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD
419 transfer-account: Liabilities:CreditCard
420 transfer-date: 2020-03-10
421 transfer-converted: FALSE
422 '''
423 self.assertEqualEntries('''
424 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
425
426 2020-01-01 open Assets:Checking
427 2020-01-01 open Liabilities:CreditCard
428 2020-01-01 open Expenses:Taxi
429
430 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
431 Assets:Checking -25.60 CAD
432 Liabilities:CreditCard 25.60 CAD
433
434 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
435 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD
436 Liabilities:CreditCard -25.60 CAD
437 ''', entries)
438
439 @loader.load_doc()
440 def test_converted_cost_and_price_true(self, entries, _, __):
441 '''
442 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
443
444 2020-01-01 open Assets:Checking
445 2020-01-01 open Liabilities:CreditCard
446 2020-01-01 open Expenses:Taxi
447
448 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
449 Assets:Checking -25.60 CAD
450 Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD
451 transfer-account: Liabilities:CreditCard
452 transfer-date: 2020-03-10
453 transfer-converted: TRUE
454 '''
455 self.assertEqualEntries('''
456 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
457
458 2020-01-01 open Assets:Checking
459 2020-01-01 open Liabilities:CreditCard
460 2020-01-01 open Expenses:Taxi
461
462 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
463 Assets:Checking -25.60 CAD
464 Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD
465
466 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
467 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15}
468 Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15}
469 ''', entries)
470
471 @loader.load_doc()
472 def test_multiple_separate(self, entries, _, __):
473 '''
474 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
475
476 2020-01-01 open Assets:Checking
477 2020-01-01 open Liabilities:CreditCard
478 2020-01-01 open Expenses:Taxi
479 2020-01-01 open Expenses:Food
480
481 2020-03-15 * "Night out in Brooklyn" ^taxi
482 Assets:Checking -45 USD
483 Expenses:Taxi 20 USD
484 transfer-account: Liabilities:CreditCard
485 transfer-date: 2020-03-10
486 Expenses:Food 25 USD
487 transfer-account: Liabilities:CreditCard
488 transfer-date: 2020-03-12
489 '''
490 self.assertEqualEntries('''
491 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
492
493 2020-01-01 open Assets:Checking
494 2020-01-01 open Liabilities:CreditCard
495 2020-01-01 open Expenses:Taxi
496 2020-01-01 open Expenses:Food
497
498 2020-03-15 * "Night out in Brooklyn" ^taxi
499 Assets:Checking -45 USD
500 Liabilities:CreditCard 45 USD
501
502 2020-03-10 * "Night out in Brooklyn" ^taxi
503 Expenses:Taxi 20 USD
504 Liabilities:CreditCard -20 USD
505
506 2020-03-12 * "Night out in Brooklyn" ^taxi
507 Expenses:Food 25 USD
508 Liabilities:CreditCard -25 USD
509 ''', entries)
510
511 @loader.load_doc()
512 def test_multiple_merge(self, entries, _, __):
513 '''
514 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
515
516 2020-01-01 open Assets:Checking
517 2020-01-01 open Liabilities:CreditCard
518 2020-01-01 open Expenses:Taxi
519 2020-01-01 open Expenses:Food
520
521 2020-03-15 * "Night out in Brooklyn" ^taxi
522 Assets:Checking -45 USD
523 Expenses:Taxi 20 USD
524 transfer-account: Liabilities:CreditCard
525 transfer-date: 2020-03-10
526 Expenses:Food 25 USD
527 transfer-account: Liabilities:CreditCard
528 transfer-date: 2020-03-10
529 '''
530 self.assertEqualEntries('''
531 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
532
533 2020-01-01 open Assets:Checking
534 2020-01-01 open Liabilities:CreditCard
535 2020-01-01 open Expenses:Taxi
536 2020-01-01 open Expenses:Food
537
538 2020-03-15 * "Night out in Brooklyn" ^taxi
539 Assets:Checking -45 USD
540 Liabilities:CreditCard 45 USD
541
542 2020-03-10 * "Night out in Brooklyn" ^taxi
543 Expenses:Taxi 20 USD
544 Expenses:Food 25 USD
545 Liabilities:CreditCard -45 USD
546 ''', entries)
547
548 @loader.load_doc()
549 def test_multiple_currencies_merge_converted_false(self, entries, _, __):
550 '''
551 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
552
553 2020-01-01 open Assets:Checking
554 2020-01-01 open Liabilities:CreditCard
555 2020-01-01 open Expenses:Taxi
556 2020-01-01 open Expenses:Food
557
558 2020-03-15 * "Night out in Brooklyn" ^taxi
559 Assets:Checking -50.60 CAD
560 Expenses:Taxi 20 USD @ 1.28 CAD
561 transfer-account: Liabilities:CreditCard
562 transfer-date: 2020-03-10
563 transfer-converted: FALSE
564 Expenses:Food 25 CAD
565 transfer-account: Liabilities:CreditCard
566 transfer-date: 2020-03-10
567 '''
568 self.assertEqualEntries('''
569 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
570
571 2020-01-01 open Assets:Checking
572 2020-01-01 open Liabilities:CreditCard
573 2020-01-01 open Expenses:Taxi
574 2020-01-01 open Expenses:Food
575
576 2020-03-15 * "Night out in Brooklyn" ^taxi
577 Assets:Checking -50.60 CAD
578 Liabilities:CreditCard 50.60 CAD
579
580 2020-03-10 * "Night out in Brooklyn" ^taxi
581 Expenses:Taxi 20 USD @ 1.28 CAD
582 Expenses:Food 25 CAD
583 Liabilities:CreditCard -50.60 CAD
584 ''', entries)
585
586 @loader.load_doc()
587 def test_multiple_currencies_merge_converted_true(self, entries, _, __):
588 '''
589 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
590
591 2020-01-01 open Assets:Checking
592 2020-01-01 open Liabilities:CreditCard
593 2020-01-01 open Expenses:Taxi
594 2020-01-01 open Expenses:Food
595
596 2020-03-15 * "Night out in Brooklyn" ^taxi
597 Assets:Checking -50.60 CAD
598 Expenses:Taxi 20 USD @ 1.28 CAD
599 transfer-account: Liabilities:CreditCard
600 transfer-date: 2020-03-10
601 transfer-converted: TRUE
602 Expenses:Food 25 CAD
603 transfer-account: Liabilities:CreditCard
604 transfer-date: 2020-03-10
605 '''
606 self.assertEqualEntries('''
607 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
608
609 2020-01-01 open Assets:Checking
610 2020-01-01 open Liabilities:CreditCard
611 2020-01-01 open Expenses:Taxi
612 2020-01-01 open Expenses:Food
613
614 2020-03-15 * "Night out in Brooklyn" ^taxi
615 Assets:Checking -50.60 CAD
616 Liabilities:CreditCard 20 USD @ 1.28 CAD
617 Liabilities:CreditCard 25 CAD
618
619 2020-03-10 * "Night out in Brooklyn" ^taxi
620 Expenses:Taxi 20 USD
621 Expenses:Food 25 CAD
622 Liabilities:CreditCard -20 USD
623 Liabilities:CreditCard -25 CAD
624 ''', entries)
625
626
627if __name__ == '__main__':
628 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 @@
1[mypy]
2check_untyped_defs = True
3ignore_errors = False
4ignore_missing_imports = True
5strict_optional = True
6warn_unused_ignores = True
7warn_redundant_casts = True
8warn_unused_configs = True
9
10[mypy-beancount.*]
11ignore_missing_imports = True
12
13[mypy-pdfminer.*]
14ignore_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."
5category = "main" 5category = "main"
6optional = false 6optional = false
7python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 7python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
8marker = "sys_platform == \"win32\""
9 8
10[[package]] 9[[package]]
11name = "attrs" 10name = "attrs"
@@ -16,10 +15,10 @@ optional = false
16python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 15python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
17 16
18[package.extras] 17[package.extras]
19dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 18dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope-interface", "furo", "sphinx", "pre-commit"]
20docs = ["furo", "sphinx", "zope.interface"] 19docs = ["furo", "sphinx", "zope-interface"]
21tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 20tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope-interface"]
22tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 21tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
23 22
24[[package]] 23[[package]]
25name = "beancount" 24name = "beancount"
@@ -40,9 +39,8 @@ python-dateutil = "*"
40python-magic = "*" 39python-magic = "*"
41 40
42[package.source] 41[package.source]
43url = "beancount"
44reference = ""
45type = "directory" 42type = "directory"
43url = "beancount"
46 44
47[[package]] 45[[package]]
48name = "beautifulsoup4" 46name = "beautifulsoup4"
@@ -52,18 +50,16 @@ category = "main"
52optional = false 50optional = false
53python-versions = "*" 51python-versions = "*"
54 52
53[package.dependencies]
54soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""}
55
55[package.extras] 56[package.extras]
56html5lib = ["html5lib"] 57html5lib = ["html5lib"]
57lxml = ["lxml"] 58lxml = ["lxml"]
58 59
59[package.dependencies]
60[package.dependencies.soupsieve]
61version = ">1.2"
62python = ">=3.0"
63
64[[package]] 60[[package]]
65name = "cachetools" 61name = "cachetools"
66version = "4.2.0" 62version = "4.2.1"
67description = "Extensible memoizing collections and decorators" 63description = "Extensible memoizing collections and decorators"
68category = "main" 64category = "main"
69optional = false 65optional = false
@@ -78,6 +74,17 @@ optional = false
78python-versions = "*" 74python-versions = "*"
79 75
80[[package]] 76[[package]]
77name = "cffi"
78version = "1.14.4"
79description = "Foreign Function Interface for Python calling C code."
80category = "main"
81optional = false
82python-versions = "*"
83
84[package.dependencies]
85pycparser = "*"
86
87[[package]]
81name = "chardet" 88name = "chardet"
82version = "4.0.0" 89version = "4.0.0"
83description = "Universal encoding detector for Python 2 and 3" 90description = "Universal encoding detector for Python 2 and 3"
@@ -92,7 +99,38 @@ description = "Cross-platform colored terminal text."
92category = "main" 99category = "main"
93optional = false 100optional = false
94python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 101python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
95marker = "sys_platform == \"win32\"" 102
103[[package]]
104name = "cryptography"
105version = "3.3.1"
106description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
107category = "main"
108optional = false
109python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
110
111[package.dependencies]
112cffi = ">=1.12"
113six = ">=1.4.1"
114
115[package.extras]
116docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
117docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
118pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
119ssh = ["bcrypt (>=3.1.5)"]
120test = ["pytest (>=3.6.0,!=3.9.0,!=3.9.1,!=3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
121
122[[package]]
123name = "flake8"
124version = "3.8.4"
125description = "the modular source code checker: pep8 pyflakes and co"
126category = "dev"
127optional = false
128python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
129
130[package.dependencies]
131mccabe = ">=0.6.0,<0.7.0"
132pycodestyle = ">=2.6.0a1,<2.7.0"
133pyflakes = ">=2.2.0,<2.3.0"
96 134
97[[package]] 135[[package]]
98name = "google-api-core" 136name = "google-api-core"
@@ -102,20 +140,19 @@ category = "main"
102optional = false 140optional = false
103python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" 141python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
104 142
105[package.extras]
106grpc = ["grpcio (>=1.29.0,<2.0dev)"]
107grpcgcp = ["grpcio-gcp (>=0.2.2)"]
108grpcio-gcp = ["grpcio-gcp (>=0.2.2)"]
109
110[package.dependencies] 143[package.dependencies]
111google-auth = ">=1.21.1,<2.0dev" 144google-auth = ">=1.21.1,<2.0dev"
112googleapis-common-protos = ">=1.6.0,<2.0dev" 145googleapis-common-protos = ">=1.6.0,<2.0dev"
113protobuf = ">=3.12.0" 146protobuf = ">=3.12.0"
114pytz = "*" 147pytz = "*"
115requests = ">=2.18.0,<3.0.0dev" 148requests = ">=2.18.0,<3.0.0dev"
116setuptools = ">=40.3.0"
117six = ">=1.13.0" 149six = ">=1.13.0"
118 150
151[package.extras]
152grpc = ["grpcio (>=1.29.0,<2.0dev)"]
153grpcgcp = ["grpcio-gcp (>=0.2.2)"]
154grpcio-gcp = ["grpcio-gcp (>=0.2.2)"]
155
119[[package]] 156[[package]]
120name = "google-api-python-client" 157name = "google-api-python-client"
121version = "1.12.8" 158version = "1.12.8"
@@ -140,18 +177,14 @@ category = "main"
140optional = false 177optional = false
141python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" 178python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
142 179
143[package.extras]
144aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"]
145
146[package.dependencies] 180[package.dependencies]
147cachetools = ">=2.0.0,<5.0" 181cachetools = ">=2.0.0,<5.0"
148pyasn1-modules = ">=0.2.1" 182pyasn1-modules = ">=0.2.1"
149setuptools = ">=40.3.0" 183rsa = {version = ">=3.1.4,<5", markers = "python_version >= \"3.6\""}
150six = ">=1.9.0" 184six = ">=1.9.0"
151 185
152[package.dependencies.rsa] 186[package.extras]
153version = ">=3.1.4,<5" 187aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"]
154python = ">=3.6"
155 188
156[[package]] 189[[package]]
157name = "google-auth-httplib2" 190name = "google-auth-httplib2"
@@ -174,12 +207,12 @@ category = "main"
174optional = false 207optional = false
175python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 208python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
176 209
177[package.extras]
178grpc = ["grpcio (>=1.0.0)"]
179
180[package.dependencies] 210[package.dependencies]
181protobuf = ">=3.6.0" 211protobuf = ">=3.6.0"
182 212
213[package.extras]
214grpc = ["grpcio (>=1.0.0)"]
215
183[[package]] 216[[package]]
184name = "httplib2" 217name = "httplib2"
185version = "0.18.1" 218version = "0.18.1"
@@ -205,6 +238,49 @@ optional = false
205python-versions = "*" 238python-versions = "*"
206 239
207[[package]] 240[[package]]
241name = "jeepney"
242version = "0.6.0"
243description = "Low-level, pure Python DBus protocol wrapper."
244category = "main"
245optional = true
246python-versions = ">=3.6"
247
248[package.extras]
249test = ["pytest", "pytest-trio", "pytest-asyncio", "testpath", "trio"]
250
251[[package]]
252name = "mccabe"
253version = "0.6.1"
254description = "McCabe checker, plugin for flake8"
255category = "dev"
256optional = false
257python-versions = "*"
258
259[[package]]
260name = "mypy"
261version = "0.800"
262description = "Optional static typing for Python"
263category = "dev"
264optional = false
265python-versions = ">=3.5"
266
267[package.dependencies]
268mypy-extensions = ">=0.4.3,<0.5.0"
269typed-ast = ">=1.4.0,<1.5.0"
270typing-extensions = ">=3.7.4"
271
272[package.extras]
273dmypy = ["psutil (>=4.0)"]
274
275[[package]]
276name = "mypy-extensions"
277version = "0.4.3"
278description = "Experimental type system extensions for programs checked with the mypy typechecker."
279category = "dev"
280optional = false
281python-versions = "*"
282
283[[package]]
208name = "packaging" 284name = "packaging"
209version = "20.8" 285version = "20.8"
210description = "Core utilities for Python packages" 286description = "Core utilities for Python packages"
@@ -216,6 +292,23 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
216pyparsing = ">=2.0.2" 292pyparsing = ">=2.0.2"
217 293
218[[package]] 294[[package]]
295name = "pdfminer-six"
296version = "20201018"
297description = "PDF parser and analyzer"
298category = "main"
299optional = false
300python-versions = ">=3.4"
301
302[package.dependencies]
303chardet = {version = "*", markers = "python_version > \"3.0\""}
304cryptography = "*"
305sortedcontainers = "*"
306
307[package.extras]
308dev = ["nose", "tox"]
309docs = ["sphinx", "sphinx-argparse"]
310
311[[package]]
219name = "pluggy" 312name = "pluggy"
220version = "0.13.1" 313version = "0.13.1"
221description = "plugin and hook calling mechanisms for python" 314description = "plugin and hook calling mechanisms for python"
@@ -273,6 +366,30 @@ python-versions = "*"
273pyasn1 = ">=0.4.6,<0.5.0" 366pyasn1 = ">=0.4.6,<0.5.0"
274 367
275[[package]] 368[[package]]
369name = "pycodestyle"
370version = "2.6.0"
371description = "Python style guide checker"
372category = "dev"
373optional = false
374python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
375
376[[package]]
377name = "pycparser"
378version = "2.20"
379description = "C parser in Python"
380category = "main"
381optional = false
382python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
383
384[[package]]
385name = "pyflakes"
386version = "2.2.0"
387description = "passive checker of Python programs"
388category = "dev"
389optional = false
390python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
391
392[[package]]
276name = "pyparsing" 393name = "pyparsing"
277version = "2.4.7" 394version = "2.4.7"
278description = "Python parsing module" 395description = "Python parsing module"
@@ -288,19 +405,19 @@ category = "main"
288optional = false 405optional = false
289python-versions = ">=3.6" 406python-versions = ">=3.6"
290 407
291[package.extras]
292testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
293
294[package.dependencies] 408[package.dependencies]
295atomicwrites = ">=1.0" 409atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
296attrs = ">=19.2.0" 410attrs = ">=19.2.0"
297colorama = "*" 411colorama = {version = "*", markers = "sys_platform == \"win32\""}
298iniconfig = "*" 412iniconfig = "*"
299packaging = "*" 413packaging = "*"
300pluggy = ">=0.12,<1.0.0a1" 414pluggy = ">=0.12,<1.0.0a1"
301py = ">=1.8.2" 415py = ">=1.8.2"
302toml = "*" 416toml = "*"
303 417
418[package.extras]
419testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
420
304[[package]] 421[[package]]
305name = "python-dateutil" 422name = "python-dateutil"
306version = "2.8.1" 423version = "2.8.1"
@@ -336,16 +453,16 @@ category = "main"
336optional = false 453optional = false
337python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 454python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
338 455
339[package.extras]
340security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
341socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
342
343[package.dependencies] 456[package.dependencies]
344certifi = ">=2017.4.17" 457certifi = ">=2017.4.17"
345chardet = ">=3.0.2,<5" 458chardet = ">=3.0.2,<5"
346idna = ">=2.5,<3" 459idna = ">=2.5,<3"
347urllib3 = ">=1.21.1,<1.27" 460urllib3 = ">=1.21.1,<1.27"
348 461
462[package.extras]
463security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
464socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
465
349[[package]] 466[[package]]
350name = "rsa" 467name = "rsa"
351version = "4.7" 468version = "4.7"
@@ -353,12 +470,23 @@ description = "Pure-Python RSA implementation"
353category = "main" 470category = "main"
354optional = false 471optional = false
355python-versions = ">=3.5, <4" 472python-versions = ">=3.5, <4"
356marker = "python_version >= \"3.6\""
357 473
358[package.dependencies] 474[package.dependencies]
359pyasn1 = ">=0.1.3" 475pyasn1 = ">=0.1.3"
360 476
361[[package]] 477[[package]]
478name = "secretstorage"
479version = "3.3.0"
480description = "Python bindings to FreeDesktop.org Secret Service API"
481category = "main"
482optional = true
483python-versions = ">=3.6"
484
485[package.dependencies]
486cryptography = ">=2.0"
487jeepney = ">=0.6"
488
489[[package]]
362name = "six" 490name = "six"
363version = "1.15.0" 491version = "1.15.0"
364description = "Python 2 and 3 compatibility utilities" 492description = "Python 2 and 3 compatibility utilities"
@@ -367,13 +495,20 @@ optional = false
367python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 495python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
368 496
369[[package]] 497[[package]]
498name = "sortedcontainers"
499version = "2.3.0"
500description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
501category = "main"
502optional = false
503python-versions = "*"
504
505[[package]]
370name = "soupsieve" 506name = "soupsieve"
371version = "2.1" 507version = "2.1"
372description = "A modern CSS selector implementation for Beautiful Soup." 508description = "A modern CSS selector implementation for Beautiful Soup."
373category = "main" 509category = "main"
374optional = false 510optional = false
375python-versions = ">=3.5" 511python-versions = ">=3.5"
376marker = "python_version >= \"3.0\""
377 512
378[[package]] 513[[package]]
379name = "toml" 514name = "toml"
@@ -384,6 +519,22 @@ optional = false
384python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 519python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
385 520
386[[package]] 521[[package]]
522name = "typed-ast"
523version = "1.4.2"
524description = "a fork of Python 2 and 3 ast modules with type comment support"
525category = "dev"
526optional = false
527python-versions = "*"
528
529[[package]]
530name = "typing-extensions"
531version = "3.7.4.3"
532description = "Backported and Experimental Type Hints for Python 3.5+"
533category = "dev"
534optional = false
535python-versions = "*"
536
537[[package]]
387name = "uritemplate" 538name = "uritemplate"
388version = "3.0.1" 539version = "3.0.1"
389description = "URI templates" 540description = "URI templates"
@@ -402,12 +553,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
402[package.extras] 553[package.extras]
403brotli = ["brotlipy (>=0.6.0)"] 554brotli = ["brotlipy (>=0.6.0)"]
404secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 555secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
405socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] 556socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
557
558[extras]
559transferwise = ["requests", "SecretStorage"]
406 560
407[metadata] 561[metadata]
408lock-version = "1.0" 562lock-version = "1.1"
409python-versions = "^3.9" 563python-versions = "^3.9"
410content-hash = "cf1dc1359aafb7aaf42ede043b1d28b1bcb215cff5851d7d9f5fe2012bb796d1" 564content-hash = "605e14048928ef9c444b6e0b8d4722909cf65ba42094bffa743bb1881c61d898"
411 565
412[metadata.files] 566[metadata.files]
413atomicwrites = [ 567atomicwrites = [
@@ -425,13 +579,51 @@ beautifulsoup4 = [
425 {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, 579 {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
426] 580]
427cachetools = [ 581cachetools = [
428 {file = "cachetools-4.2.0-py3-none-any.whl", hash = "sha256:c6b07a6ded8c78bf36730b3dc452dfff7d95f2a12a2fed856b1a0cb13ca78c61"}, 582 {file = "cachetools-4.2.1-py3-none-any.whl", hash = "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2"},
429 {file = "cachetools-4.2.0.tar.gz", hash = "sha256:3796e1de094f0eaca982441c92ce96c68c89cced4cd97721ab297ea4b16db90e"}, 583 {file = "cachetools-4.2.1.tar.gz", hash = "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"},
430] 584]
431certifi = [ 585certifi = [
432 {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, 586 {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
433 {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, 587 {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
434] 588]
589cffi = [
590 {file = "cffi-1.14.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775"},
591 {file = "cffi-1.14.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06"},
592 {file = "cffi-1.14.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26"},
593 {file = "cffi-1.14.4-cp27-cp27m-win32.whl", hash = "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c"},
594 {file = "cffi-1.14.4-cp27-cp27m-win_amd64.whl", hash = "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b"},
595 {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d"},
596 {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca"},
597 {file = "cffi-1.14.4-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698"},
598 {file = "cffi-1.14.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b"},
599 {file = "cffi-1.14.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293"},
600 {file = "cffi-1.14.4-cp35-cp35m-win32.whl", hash = "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2"},
601 {file = "cffi-1.14.4-cp35-cp35m-win_amd64.whl", hash = "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7"},
602 {file = "cffi-1.14.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"},
603 {file = "cffi-1.14.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362"},
604 {file = "cffi-1.14.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec"},
605 {file = "cffi-1.14.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b"},
606 {file = "cffi-1.14.4-cp36-cp36m-win32.whl", hash = "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668"},
607 {file = "cffi-1.14.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009"},
608 {file = "cffi-1.14.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb"},
609 {file = "cffi-1.14.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d"},
610 {file = "cffi-1.14.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03"},
611 {file = "cffi-1.14.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01"},
612 {file = "cffi-1.14.4-cp37-cp37m-win32.whl", hash = "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e"},
613 {file = "cffi-1.14.4-cp37-cp37m-win_amd64.whl", hash = "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35"},
614 {file = "cffi-1.14.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d"},
615 {file = "cffi-1.14.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b"},
616 {file = "cffi-1.14.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53"},
617 {file = "cffi-1.14.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e"},
618 {file = "cffi-1.14.4-cp38-cp38-win32.whl", hash = "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d"},
619 {file = "cffi-1.14.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375"},
620 {file = "cffi-1.14.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909"},
621 {file = "cffi-1.14.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd"},
622 {file = "cffi-1.14.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a"},
623 {file = "cffi-1.14.4-cp39-cp39-win32.whl", hash = "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3"},
624 {file = "cffi-1.14.4-cp39-cp39-win_amd64.whl", hash = "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b"},
625 {file = "cffi-1.14.4.tar.gz", hash = "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c"},
626]
435chardet = [ 627chardet = [
436 {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, 628 {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
437 {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, 629 {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
@@ -440,6 +632,26 @@ colorama = [
440 {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 632 {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
441 {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 633 {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
442] 634]
635cryptography = [
636 {file = "cryptography-3.3.1-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030"},
637 {file = "cryptography-3.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0"},
638 {file = "cryptography-3.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812"},
639 {file = "cryptography-3.3.1-cp27-cp27m-win32.whl", hash = "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e"},
640 {file = "cryptography-3.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901"},
641 {file = "cryptography-3.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d"},
642 {file = "cryptography-3.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5"},
643 {file = "cryptography-3.3.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302"},
644 {file = "cryptography-3.3.1-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244"},
645 {file = "cryptography-3.3.1-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c"},
646 {file = "cryptography-3.3.1-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c"},
647 {file = "cryptography-3.3.1-cp36-abi3-win32.whl", hash = "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a"},
648 {file = "cryptography-3.3.1-cp36-abi3-win_amd64.whl", hash = "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7"},
649 {file = "cryptography-3.3.1.tar.gz", hash = "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6"},
650]
651flake8 = [
652 {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"},
653 {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},
654]
443google-api-core = [ 655google-api-core = [
444 {file = "google-api-core-1.25.0.tar.gz", hash = "sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae"}, 656 {file = "google-api-core-1.25.0.tar.gz", hash = "sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae"},
445 {file = "google_api_core-1.25.0-py2.py3-none-any.whl", hash = "sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb"}, 657 {file = "google_api_core-1.25.0-py2.py3-none-any.whl", hash = "sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb"},
@@ -472,10 +684,50 @@ iniconfig = [
472 {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 684 {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
473 {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 685 {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
474] 686]
687jeepney = [
688 {file = "jeepney-0.6.0-py3-none-any.whl", hash = "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae"},
689 {file = "jeepney-0.6.0.tar.gz", hash = "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657"},
690]
691mccabe = [
692 {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
693 {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
694]
695mypy = [
696 {file = "mypy-0.800-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a"},
697 {file = "mypy-0.800-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641"},
698 {file = "mypy-0.800-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496"},
699 {file = "mypy-0.800-cp35-cp35m-win_amd64.whl", hash = "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876"},
700 {file = "mypy-0.800-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9"},
701 {file = "mypy-0.800-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf"},
702 {file = "mypy-0.800-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363"},
703 {file = "mypy-0.800-cp36-cp36m-win_amd64.whl", hash = "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b"},
704 {file = "mypy-0.800-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f"},
705 {file = "mypy-0.800-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19"},
706 {file = "mypy-0.800-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa"},
707 {file = "mypy-0.800-cp37-cp37m-win_amd64.whl", hash = "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86"},
708 {file = "mypy-0.800-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf"},
709 {file = "mypy-0.800-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0"},
710 {file = "mypy-0.800-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b"},
711 {file = "mypy-0.800-cp38-cp38-win_amd64.whl", hash = "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738"},
712 {file = "mypy-0.800-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25"},
713 {file = "mypy-0.800-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb"},
714 {file = "mypy-0.800-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488"},
715 {file = "mypy-0.800-cp39-cp39-win_amd64.whl", hash = "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07"},
716 {file = "mypy-0.800-py3-none-any.whl", hash = "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653"},
717 {file = "mypy-0.800.tar.gz", hash = "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a"},
718]
719mypy-extensions = [
720 {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
721 {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
722]
475packaging = [ 723packaging = [
476 {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, 724 {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"},
477 {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, 725 {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"},
478] 726]
727pdfminer-six = [
728 {file = "pdfminer.six-20201018-py3-none-any.whl", hash = "sha256:d78877ba8d8bf957f3bb636c4f73f4f6f30f56c461993877ac22c39c20837509"},
729 {file = "pdfminer.six-20201018.tar.gz", hash = "sha256:b9aac0ebeafb21c08bf65f2039f4b2c5f78a3449d0a41df711d72445649e952a"},
730]
479pluggy = [ 731pluggy = [
480 {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 732 {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
481 {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 733 {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
@@ -538,6 +790,18 @@ pyasn1-modules = [
538 {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, 790 {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"},
539 {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, 791 {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"},
540] 792]
793pycodestyle = [
794 {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"},
795 {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"},
796]
797pycparser = [
798 {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
799 {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
800]
801pyflakes = [
802 {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"},
803 {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"},
804]
541pyparsing = [ 805pyparsing = [
542 {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 806 {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
543 {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 807 {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
@@ -566,10 +830,18 @@ rsa = [
566 {file = "rsa-4.7-py3-none-any.whl", hash = "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"}, 830 {file = "rsa-4.7-py3-none-any.whl", hash = "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"},
567 {file = "rsa-4.7.tar.gz", hash = "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4"}, 831 {file = "rsa-4.7.tar.gz", hash = "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4"},
568] 832]
833secretstorage = [
834 {file = "SecretStorage-3.3.0-py3-none-any.whl", hash = "sha256:5c36f6537a523ec5f969ef9fad61c98eb9e017bc601d811e53aa25bece64892f"},
835 {file = "SecretStorage-3.3.0.tar.gz", hash = "sha256:30cfdef28829dad64d6ea1ed08f8eff6aa115a77068926bcc9f5225d5a3246aa"},
836]
569six = [ 837six = [
570 {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 838 {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
571 {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 839 {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
572] 840]
841sortedcontainers = [
842 {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"},
843 {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"},
844]
573soupsieve = [ 845soupsieve = [
574 {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"}, 846 {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"},
575 {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"}, 847 {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"},
@@ -578,6 +850,43 @@ toml = [
578 {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 850 {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
579 {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 851 {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
580] 852]
853typed-ast = [
854 {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"},
855 {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"},
856 {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"},
857 {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"},
858 {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"},
859 {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"},
860 {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"},
861 {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"},
862 {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"},
863 {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"},
864 {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"},
865 {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"},
866 {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"},
867 {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"},
868 {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"},
869 {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"},
870 {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"},
871 {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"},
872 {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"},
873 {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"},
874 {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"},
875 {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"},
876 {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"},
877 {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"},
878 {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"},
879 {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"},
880 {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"},
881 {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"},
882 {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"},
883 {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"},
884]
885typing-extensions = [
886 {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
887 {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
888 {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
889]
581uritemplate = [ 890uritemplate = [
582 {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, 891 {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"},
583 {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, 892 {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 @@
1[tool.poetry] 1[tool.poetry]
2name = "beancount-extras-kris7t" 2name = "beancount-extras-kris7t"
3version = "0.1.0" 3version = "0.1.0"
4description = "Miscellaneous plugins, importers and price sources for Beancount" 4description = "Miscellaneous plugins and importers for Beancount"
5authors = ["Kristóf Marussy <kristof@marussy.com>"] 5authors = ["Kristóf Marussy <kristof@marussy.com>"]
6license = "GPL-2.0-only" 6license = "GPL-2.0-only"
7 7
8[tool.poetry.dependencies] 8[tool.poetry.dependencies]
9python = "^3.9" 9python = "^3.9"
10beancount = { path = "beancount", develop = true } 10beancount = { path = "beancount", develop = true }
11"pdfminer.six" = "^20201018"
12requests = { version = "^2.25.1", optional = true }
13SecretStorage = { version = "^3.3.0", optional = true }
11 14
12[tool.poetry.dev-dependencies] 15[tool.poetry.dev-dependencies]
16flake8 = "^3.8.4"
17mypy = "^0.800"
18
19[tool.poetry.extras]
20transferwise = ["requests", "SecretStorage"]
13 21
14[build-system] 22[build-system]
15requires = ["poetry>=0.12"] 23requires = ["poetry>=0.12"]