diff options
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() | ||