diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-01-25 01:14:28 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-01-25 01:14:28 +0100 |
commit | a1c2a999e449054d6641bbb633954e45fcd63f90 (patch) | |
tree | 47628c10ded721d66e47b5f87f501293cd8af003 /beancount_extras_kris7t/plugins | |
parent | Initialize package (diff) | |
download | beancount-extras-kris7t-a1c2a999e449054d6641bbb633954e45fcd63f90.tar.gz beancount-extras-kris7t-a1c2a999e449054d6641bbb633954e45fcd63f90.tar.zst beancount-extras-kris7t-a1c2a999e449054d6641bbb633954e45fcd63f90.zip |
Add plugins and importers from private config
The importers are missing tests, because not having any specifications
for the import formats means we must use real, private data as test inputs
Diffstat (limited to 'beancount_extras_kris7t/plugins')
-rw-r--r-- | beancount_extras_kris7t/plugins/__init__.py | 0 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/closing_balance.py | 70 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/closing_balance_test.py | 124 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/default_tolerance.py | 47 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/default_tolerance_test.py | 104 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/selective_implicit_prices.py | 157 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/selective_implicit_prices_test.py | 410 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/templates.py | 144 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/templates_test.py | 326 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/transfer_accounts.py | 188 | ||||
-rw-r--r-- | beancount_extras_kris7t/plugins/transfer_accounts_test.py | 628 |
11 files changed, 2198 insertions, 0 deletions
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 | ''' | ||
2 | Plugin 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 | |||
7 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||
8 | |||
9 | from beancount.core.data import Balance, Close, Directive, Entries, Meta, Posting, Transaction | ||
10 | from beancount.core.flags import FLAG_OKAY | ||
11 | from beancount.core.number import ZERO | ||
12 | |||
13 | __plugins__ = ('close_with_balance_assertions',) | ||
14 | |||
15 | CLOSE_TO_META = 'close-to' | ||
16 | CLOSING_META = 'closing' | ||
17 | |||
18 | |||
19 | class ClosingBalanceError(NamedTuple): | ||
20 | source: Optional[Meta] | ||
21 | message: str | ||
22 | entry: Directive | ||
23 | |||
24 | |||
25 | def 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 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | |||
9 | |||
10 | class 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 | |||
123 | if __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 | ''' | ||
2 | Plugin 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 | |||
7 | from decimal import Decimal | ||
8 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||
9 | |||
10 | from beancount.core.data import Open, Balance, Directive, Entries, Meta | ||
11 | |||
12 | __plugins__ = ('set_tolerances_to_default',) | ||
13 | |||
14 | DEFAULT_TOLERANCE_META = 'default-balance-tolerance' | ||
15 | |||
16 | |||
17 | class DefaultToleranceError(NamedTuple): | ||
18 | source: Optional[Meta] | ||
19 | message: str | ||
20 | entry: Directive | ||
21 | |||
22 | |||
23 | def 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 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | |||
9 | |||
10 | class 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 | |||
103 | if __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 | ||
2 | directive or if it is an augmenting posting, has a cost directive. | ||
3 | |||
4 | Price directives will be synthesized only for commodities with the | ||
5 | implicit-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 | |||
11 | import collections | ||
12 | from typing import List, Tuple, Set | ||
13 | |||
14 | from beancount.core.data import Commodity, Entries, Transaction | ||
15 | from beancount.core import data | ||
16 | from beancount.core import amount | ||
17 | from beancount.core import inventory | ||
18 | from beancount.core.position import Cost | ||
19 | |||
20 | __plugins__ = ('add_implicit_prices',) | ||
21 | |||
22 | |||
23 | ImplicitPriceError = collections.namedtuple('ImplicitPriceError', 'source message entry') | ||
24 | |||
25 | |||
26 | METADATA_FIELD = "__implicit_prices__" | ||
27 | IMPLICIT_PRICES_META = "implicit-prices" | ||
28 | |||
29 | |||
30 | def 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 | |||
46 | def 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 | |||
5 | import unittest | ||
6 | |||
7 | from beancount.core.number import D | ||
8 | from beancount.core import data | ||
9 | from beancount.parser import cmptest | ||
10 | from beancount import loader | ||
11 | |||
12 | from beancount_extras_kris7t.plugins import selective_implicit_prices as implicit_prices | ||
13 | |||
14 | |||
15 | class 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 | |||
409 | if __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 | ''' | ||
2 | Plugin 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 | |||
7 | import datetime as dt | ||
8 | from decimal import Decimal | ||
9 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||
10 | |||
11 | from beancount.core import amount | ||
12 | from beancount.core.data import Custom, Directive, Entries, Meta, Transaction, Union | ||
13 | from beancount.core.number import ZERO | ||
14 | |||
15 | __plugins__ = ('apply_templates',) | ||
16 | |||
17 | TEMPLATE_META = 'template' | ||
18 | TEMPLATE_USE_CUSTOM = 'template-use' | ||
19 | TEMPLATE_DELETE_CUSTOM = 'template-delete' | ||
20 | TEMPLATE_TAG_PREFIX = 'template' | ||
21 | |||
22 | |||
23 | Templates = Dict[str, Transaction] | ||
24 | |||
25 | |||
26 | class TemplateError(NamedTuple): | ||
27 | source: Optional[Meta] | ||
28 | message: str | ||
29 | entry: Directive | ||
30 | |||
31 | |||
32 | def _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 | |||
43 | def _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 | |||
81 | def _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 | |||
96 | def _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 | |||
117 | def 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 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | import pytest | ||
9 | |||
10 | |||
11 | class 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 | |||
325 | if __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 | ''' | ||
2 | Plugin 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 | |||
7 | from collections import defaultdict | ||
8 | import datetime as dt | ||
9 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union | ||
10 | |||
11 | from beancount.core import amount, convert | ||
12 | from beancount.core.amount import Amount | ||
13 | from beancount.core.data import Cost, CostSpec, Directive, Entries, Meta, Posting, Open, \ | ||
14 | Transaction | ||
15 | from beancount.core.inventory import Inventory | ||
16 | from beancount.core.number import ZERO | ||
17 | |||
18 | __plugins__ = ('split_entries_via_transfer_accounts',) | ||
19 | |||
20 | TRANSFER_ACCOUNT_META = 'transfer-account' | ||
21 | TRANSFER_DATE_META = 'transfer-date' | ||
22 | TRANSFER_CONVERTED_META = 'transfer-converted' | ||
23 | TRANSFER_CONVERTED_DEFAULT = True | ||
24 | |||
25 | |||
26 | class TransferAccountError(NamedTuple): | ||
27 | source: Optional[Meta] | ||
28 | message: str | ||
29 | entry: Directive | ||
30 | |||
31 | |||
32 | class _OutgoingTransfer(NamedTuple): | ||
33 | accout: str | ||
34 | currency: str | ||
35 | cost: Optional[Union[Cost, CostSpec]] | ||
36 | price: Optional[Amount] | ||
37 | |||
38 | |||
39 | class _IncomingTransfer(NamedTuple): | ||
40 | account: str | ||
41 | date: dt.date | ||
42 | |||
43 | |||
44 | class _IncomingPostings(NamedTuple): | ||
45 | postings: List[Posting] | ||
46 | inventroy: Inventory | ||
47 | |||
48 | |||
49 | class _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 | |||
161 | def 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 | |||
4 | import unittest | ||
5 | |||
6 | from beancount import loader | ||
7 | from beancount.parser import cmptest | ||
8 | |||
9 | |||
10 | class 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 | |||
627 | if __name__ == '__main__': | ||
628 | unittest.main() | ||