aboutsummaryrefslogtreecommitdiffstats
path: root/beancount_extras_kris7t/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'beancount_extras_kris7t/plugins')
-rw-r--r--beancount_extras_kris7t/plugins/__init__.py0
-rw-r--r--beancount_extras_kris7t/plugins/closing_balance.py70
-rw-r--r--beancount_extras_kris7t/plugins/closing_balance_test.py124
-rw-r--r--beancount_extras_kris7t/plugins/default_tolerance.py47
-rw-r--r--beancount_extras_kris7t/plugins/default_tolerance_test.py104
-rw-r--r--beancount_extras_kris7t/plugins/selective_implicit_prices.py157
-rw-r--r--beancount_extras_kris7t/plugins/selective_implicit_prices_test.py410
-rw-r--r--beancount_extras_kris7t/plugins/templates.py144
-rw-r--r--beancount_extras_kris7t/plugins/templates_test.py326
-rw-r--r--beancount_extras_kris7t/plugins/transfer_accounts.py188
-rw-r--r--beancount_extras_kris7t/plugins/transfer_accounts_test.py628
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'''
2Plugin that closes an account by transferring its whole balance to another account.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from typing import Any, Dict, List, NamedTuple, Optional, Tuple
8
9from beancount.core.data import Balance, Close, Directive, Entries, Meta, Posting, Transaction
10from beancount.core.flags import FLAG_OKAY
11from beancount.core.number import ZERO
12
13__plugins__ = ('close_with_balance_assertions',)
14
15CLOSE_TO_META = 'close-to'
16CLOSING_META = 'closing'
17
18
19class ClosingBalanceError(NamedTuple):
20 source: Optional[Meta]
21 message: str
22 entry: Directive
23
24
25def close_with_balance_assertions(entries: Entries,
26 options_map: Dict[str, Any],
27 config_str: Optional[str] = None) -> \
28 Tuple[Entries, List[ClosingBalanceError]]:
29 new_entries: Entries = []
30 errors: List[ClosingBalanceError] = []
31 for entry in entries:
32 new_entries.append(entry)
33 if isinstance(entry, Balance) and CLOSE_TO_META in entry.meta:
34 close_to_account = entry.meta[CLOSE_TO_META]
35 if not isinstance(close_to_account, str):
36 errors.append(ClosingBalanceError(
37 entry.meta,
38 f'{CLOSE_TO_META} must be a string, got {close_to_account} instead',
39 entry))
40 continue
41 if entry.tolerance is not None and entry.tolerance != ZERO:
42 errors.append(ClosingBalanceError(
43 entry.meta,
44 f'Closing an account requires {ZERO} tolerance, got {entry.tolerance} instead',
45 entry))
46 continue
47 if entry.diff_amount is not None:
48 errors.append(ClosingBalanceError(
49 entry.meta,
50 f'Not closing {entry.account} with {entry.diff_amount} failed balance check',
51 entry))
52 continue
53 new_meta = dict(entry.meta)
54 del new_meta[CLOSE_TO_META]
55 if entry.amount.number != ZERO:
56 new_entries.append(Transaction(
57 new_meta,
58 entry.date,
59 FLAG_OKAY,
60 None,
61 f'Closing {entry.account}',
62 set(),
63 set(),
64 [
65 Posting(entry.account, -entry.amount, None, None, None,
66 {CLOSING_META: True}),
67 Posting(close_to_account, entry.amount, None, None, None, None)
68 ]))
69 new_entries.append(Close(new_meta, entry.date, entry.account))
70 return new_entries, errors
diff --git a/beancount_extras_kris7t/plugins/closing_balance_test.py b/beancount_extras_kris7t/plugins/closing_balance_test.py
new file mode 100644
index 0000000..40115bf
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/closing_balance_test.py
@@ -0,0 +1,124 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8
9
10class TestClosingBalance(cmptest.TestCase):
11
12 @loader.load_doc()
13 def test_close_account_correct(self, entries, errors, options_map):
14 '''
15 plugin "beancount_extras_kris7t.plugins.closing_balance"
16
17 2020-01-01 open Assets:Checking
18 2020-01-01 open Equity:Opening-Balances
19 2020-01-01 open Equity:Closing-Balances
20
21 2020-01-01 * "Opening balances"
22 Assets:Checking 100 USD
23 Equity:Opening-Balances -100 USD
24
25 2020-03-15 balance Assets:Checking 100 USD
26 close-to: Equity:Closing-Balances
27 '''
28 self.assertEqualEntries('''
29 plugin "beancount_extras_kris7t.plugins.closing_balance"
30
31 2020-01-01 open Assets:Checking
32 2020-01-01 open Equity:Opening-Balances
33 2020-01-01 open Equity:Closing-Balances
34
35 2020-01-01 * "Opening balances"
36 Assets:Checking 100 USD
37 Equity:Opening-Balances -100 USD
38
39 2020-03-15 balance Assets:Checking 100 USD
40 close-to: Equity:Closing-Balances
41
42 2020-03-15 * "Closing Assets:Checking"
43 Assets:Checking -100 USD
44 closing: TRUE
45 Equity:Closing-Balances 100 USD
46
47 2020-03-15 close Assets:Checking
48 ''', entries)
49
50 @loader.load_doc(expect_errors=True)
51 def test_close_account_incorrect(self, entries, errors, options_map):
52 '''
53 plugin "beancount_extras_kris7t.plugins.closing_balance"
54
55 2020-01-01 open Assets:Checking
56 2020-01-01 open Equity:Opening-Balances
57 2020-01-01 open Equity:Closing-Balances
58
59 2020-01-01 * "Opening balances"
60 Assets:Checking 100 USD
61 Equity:Opening-Balances -100 USD
62
63 2020-03-15 balance Assets:Checking 80 USD
64 close-to: Equity:Closing-Balances
65 '''
66 self.assertEqualEntries('''
67 plugin "beancount_extras_kris7t.plugins.closing_balance"
68
69 2020-01-01 open Assets:Checking
70 2020-01-01 open Equity:Opening-Balances
71 2020-01-01 open Equity:Closing-Balances
72
73 2020-01-01 * "Opening balances"
74 Assets:Checking 100 USD
75 Equity:Opening-Balances -100 USD
76
77 2020-03-15 balance Assets:Checking 80 USD
78 close-to: Equity:Closing-Balances
79
80 2020-03-15 * "Closing Assets:Checking"
81 Assets:Checking -80 USD
82 closing: TRUE
83 Equity:Closing-Balances 80 USD
84
85 2020-03-15 close Assets:Checking
86 ''', entries)
87 self.assertRegex(errors[0].message, '^Balance failed for \'Assets:Checking\'')
88
89 @loader.load_doc()
90 def test_close_account_zero(self, entries, errors, options_map):
91 '''
92 plugin "beancount_extras_kris7t.plugins.closing_balance"
93
94 2020-01-01 open Assets:Checking
95
96 2020-03-15 balance Assets:Checking 0 USD
97 close-to: Equity:Closing-Balances
98 '''
99 self.assertEqualEntries('''
100 plugin "beancount_extras_kris7t.plugins.closing_balance"
101
102 2020-01-01 open Assets:Checking
103
104 2020-03-15 balance Assets:Checking 0 USD
105 close-to: Equity:Closing-Balances
106
107 2020-03-15 close Assets:Checking
108 ''', entries)
109
110 @loader.load_doc(expect_errors=True)
111 def test_invalid_close_to(self, entries, errors, options_map):
112 '''
113 plugin "beancount_extras_kris7t.plugins.closing_balance"
114
115 2020-01-01 open Assets:Checking
116
117 2020-03-15 balance Assets:Checking 100 USD
118 close-to: TRUE
119 '''
120 self.assertRegex(errors[0].message, '^close-to must be a string')
121
122
123if __name__ == '__main__':
124 unittest.main()
diff --git a/beancount_extras_kris7t/plugins/default_tolerance.py b/beancount_extras_kris7t/plugins/default_tolerance.py
new file mode 100644
index 0000000..52fa956
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/default_tolerance.py
@@ -0,0 +1,47 @@
1'''
2Plugin that sets the tolerance values of balance assertions to an amount determined by the account.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from decimal import Decimal
8from typing import Any, Dict, List, NamedTuple, Optional, Tuple
9
10from beancount.core.data import Open, Balance, Directive, Entries, Meta
11
12__plugins__ = ('set_tolerances_to_default',)
13
14DEFAULT_TOLERANCE_META = 'default-balance-tolerance'
15
16
17class DefaultToleranceError(NamedTuple):
18 source: Optional[Meta]
19 message: str
20 entry: Directive
21
22
23def set_tolerances_to_default(entries: Entries,
24 options_map: Dict[str, Any],
25 config_str: Optional[str] = None) -> \
26 Tuple[Entries, List[DefaultToleranceError]]:
27 errors: List[DefaultToleranceError] = []
28 accounts: Dict[str, Optional[Decimal]] = {}
29 for entry in entries:
30 if not isinstance(entry, Open):
31 continue
32 if tolerance := entry.meta.get(DEFAULT_TOLERANCE_META, None):
33 if isinstance(tolerance, Decimal):
34 accounts[entry.account] = tolerance
35 else:
36 errors.append(DefaultToleranceError(
37 entry.meta,
38 f'{DEFAULT_TOLERANCE_META} must be decimal, got {tolerance} instead',
39 entry))
40 new_entries: Entries = []
41 for entry in entries:
42 if isinstance(entry, Balance) and entry.tolerance is None and entry.account in accounts:
43 account_tolerance = accounts[entry.account]
44 new_entries.append(entry._replace(tolerance=account_tolerance))
45 else:
46 new_entries.append(entry)
47 return new_entries, errors
diff --git a/beancount_extras_kris7t/plugins/default_tolerance_test.py b/beancount_extras_kris7t/plugins/default_tolerance_test.py
new file mode 100644
index 0000000..2e5c629
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/default_tolerance_test.py
@@ -0,0 +1,104 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8
9
10class DefaultToleranceTest(cmptest.TestCase):
11
12 @loader.load_doc()
13 def test_account_with_tolerance(self, entries, errors, options_map):
14 '''
15 plugin "beancount_extras_kris7t.plugins.default_tolerance"
16
17 2020-01-01 open Assets:Checking
18 default-balance-tolerance: 10
19
20 2020-01-01 balance Assets:Checking 0 USD
21 '''
22 self.assertEqualEntries('''
23 plugin "beancount_extras_kris7t.plugins.default_tolerance"
24
25 2020-01-01 open Assets:Checking
26 default-balance-tolerance: 10
27
28 2020-01-01 balance Assets:Checking 0 ~ 10 USD
29 ''', entries)
30
31 @loader.load_doc()
32 def test_account_with_tolerance_override(self, entries, errors, options_map):
33 '''
34 plugin "beancount_extras_kris7t.plugins.default_tolerance"
35
36 2020-01-01 open Assets:Checking
37 default-balance-tolerance: 10
38
39 2020-01-01 balance Assets:Checking 10 ~ 20 USD
40 '''
41 self.assertEqualEntries('''
42 plugin "beancount_extras_kris7t.plugins.default_tolerance"
43
44 2020-01-01 open Assets:Checking
45 default-balance-tolerance: 10
46
47 2020-01-01 balance Assets:Checking 10 ~ 20 USD
48 ''', entries)
49
50 @loader.load_doc()
51 def test_account_with_tolerance_override_zero(self, entries, errors, options_map):
52 '''
53 plugin "beancount_extras_kris7t.plugins.default_tolerance"
54
55 2020-01-01 open Assets:Checking
56 default-balance-tolerance: 10
57
58 2020-01-01 balance Assets:Checking 0 ~ 0 USD
59 '''
60 self.assertEqualEntries('''
61 plugin "beancount_extras_kris7t.plugins.default_tolerance"
62
63 2020-01-01 open Assets:Checking
64 default-balance-tolerance: 10
65
66 2020-01-01 balance Assets:Checking 0 ~ 0 USD
67 ''', entries)
68
69 @loader.load_doc()
70 def test_account_without_tolerance(self, entries, errors, options_map):
71 '''
72 plugin "beancount_extras_kris7t.plugins.default_tolerance"
73
74 2020-01-01 open Assets:Checking
75
76 2020-01-01 balance Assets:Checking 0 USD
77
78 2020-01-02 balance Assets:Checking 10 ~ 20 USD
79 '''
80 self.assertEqualEntries('''
81 plugin "beancount_extras_kris7t.plugins.default_tolerance"
82
83 2020-01-01 open Assets:Checking
84
85 2020-01-01 balance Assets:Checking 0 USD
86
87 2020-01-02 balance Assets:Checking 10 ~ 20 USD
88 ''', entries)
89
90 @loader.load_doc(expect_errors=True)
91 def test_account_with_invalid_tolerance(self, entries, errors, options_map):
92 '''
93 plugin "beancount_extras_kris7t.plugins.default_tolerance"
94
95 2020-01-01 open Assets:Checking
96 default-balance-tolerance: TRUE
97
98 2020-01-01 balance Assets:Checking 0 USD
99 '''
100 self.assertRegex(errors[0].message, '^default-balance-tolerance must be decimal')
101
102
103if __name__ == '__main__':
104 unittest.main()
diff --git a/beancount_extras_kris7t/plugins/selective_implicit_prices.py b/beancount_extras_kris7t/plugins/selective_implicit_prices.py
new file mode 100644
index 0000000..07dc893
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/selective_implicit_prices.py
@@ -0,0 +1,157 @@
1"""This plugin synthesizes Price directives for all Postings with a price or
2directive or if it is an augmenting posting, has a cost directive.
3
4Price directives will be synthesized only for commodities with the
5implicit-prices: TRUE metadata set.
6"""
7__copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \
8 "2020 Kristóf Marussy <kristof@marussy.com>"
9__license__ = "GNU GPLv2"
10
11import collections
12from typing import List, Tuple, Set
13
14from beancount.core.data import Commodity, Entries, Transaction
15from beancount.core import data
16from beancount.core import amount
17from beancount.core import inventory
18from beancount.core.position import Cost
19
20__plugins__ = ('add_implicit_prices',)
21
22
23ImplicitPriceError = collections.namedtuple('ImplicitPriceError', 'source message entry')
24
25
26METADATA_FIELD = "__implicit_prices__"
27IMPLICIT_PRICES_META = "implicit-prices"
28
29
30def fetch_commodities(entries: Entries) -> Tuple[Set[str], List[ImplicitPriceError]]:
31 commodities: Set[str] = set()
32 errors: List[ImplicitPriceError] = []
33 for entry in entries:
34 if isinstance(entry, Commodity):
35 implicit_prices = entry.meta.get(IMPLICIT_PRICES_META, False)
36 if not isinstance(implicit_prices, bool):
37 errors.append(ImplicitPriceError(
38 entry.meta,
39 f'{IMPLICIT_PRICES_META} must be Boolean, got {implicit_prices} instead',
40 entry))
41 if implicit_prices:
42 commodities.add(entry.currency)
43 return commodities, errors
44
45
46def add_implicit_prices(entries: Entries,
47 unused_options_map) -> Tuple[Entries, List[ImplicitPriceError]]:
48 """Insert implicitly defined prices from Transactions.
49
50 Explicit price entries are simply maintained in the output list. Prices from
51 postings with costs or with prices from Transaction entries are synthesized
52 as new Price entries in the list of entries output.
53
54 Args:
55 entries: A list of directives. We're interested only in the Transaction instances.
56 unused_options_map: A parser options dict.
57 Returns:
58 A list of entries, possibly with more Price entries than before, and a
59 list of errors.
60 """
61 new_entries: Entries = []
62 errors: List[ImplicitPriceError] = []
63
64 commodities, fetch_errors = fetch_commodities(entries)
65 errors.extend(fetch_errors)
66
67 # A dict of (date, currency, cost-currency) to price entry.
68 new_price_entry_map = {}
69
70 balances = collections.defaultdict(inventory.Inventory)
71 for entry in entries:
72 # Always replicate the existing entries.
73 new_entries.append(entry)
74
75 if isinstance(entry, Transaction):
76 # Inspect all the postings in the transaction.
77 for posting in entry.postings:
78 units = posting.units
79 if units.currency not in commodities:
80 continue
81
82 cost = posting.cost
83
84 # Check if the position is matching against an existing
85 # position.
86 _, booking = balances[posting.account].add_position(posting)
87
88 # Add prices when they're explicitly specified on a posting. An
89 # explicitly specified price may occur in a conversion, e.g.
90 # Assets:Account 100 USD @ 1.10 CAD
91 # or, if a cost is also specified, as the current price of the
92 # underlying instrument, e.g.
93 # Assets:Account 100 HOOL {564.20} @ {581.97} USD
94 if posting.price is not None:
95 meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"])
96 meta[METADATA_FIELD] = "from_price"
97 price_entry = data.Price(meta, entry.date,
98 units.currency,
99 posting.price)
100
101 # Add costs, when we're not matching against an existing
102 # position. This happens when we're just specifying the cost,
103 # e.g.
104 # Assets:Account 100 HOOL {564.20}
105 elif (cost is not None and
106 booking != inventory.MatchResult.REDUCED):
107 # TODO(blais): What happens here if the account has no
108 # booking strategy? Do we end up inserting a price for the
109 # reducing leg? Check.
110 meta = data.new_metadata(entry.meta["filename"], entry.meta["lineno"])
111 meta[METADATA_FIELD] = "from_cost"
112 if isinstance(cost, Cost):
113 price_entry = data.Price(meta, entry.date,
114 units.currency,
115 amount.Amount(cost.number, cost.currency))
116 else:
117 errors.append(
118 ImplicitPriceError(
119 entry.meta,
120 f'Expected {entry} to have a Cost, got {cost} instead',
121 entry))
122 price_entry = None
123 else:
124 price_entry = None
125
126 if price_entry is not None:
127 key = (price_entry.date,
128 price_entry.currency,
129 price_entry.amount.number, # Ideally should be removed.
130 price_entry.amount.currency)
131 try:
132 new_price_entry_map[key]
133
134 # Do not fail for now. We still have many valid use
135 # cases of duplicate prices on the same date, for
136 # example, stock splits, or trades on two dates with
137 # two separate reported prices. We need to figure out a
138 # more elegant solution for this in the long term.
139 # Keeping both for now. We should ideally not use the
140 # number in the de-dup key above.
141 #
142 # dup_entry = new_price_entry_map[key]
143 # if price_entry.amount.number == dup_entry.amount.number:
144 # # Skip duplicates.
145 # continue
146 # else:
147 # errors.append(
148 # ImplicitPriceError(
149 # entry.meta,
150 # "Duplicate prices for {} on {}".format(entry,
151 # dup_entry),
152 # entry))
153 except KeyError:
154 new_price_entry_map[key] = price_entry
155 new_entries.append(price_entry)
156
157 return new_entries, errors
diff --git a/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py b/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py
new file mode 100644
index 0000000..6ead45d
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/selective_implicit_prices_test.py
@@ -0,0 +1,410 @@
1__copyright__ = "Copyright (C) 2015-2017 Martin Blais, " + \
2 "2020 Kristóf Marussy <kristof@marussy.com>"
3__license__ = "GNU GPLv2"
4
5import unittest
6
7from beancount.core.number import D
8from beancount.core import data
9from beancount.parser import cmptest
10from beancount import loader
11
12from beancount_extras_kris7t.plugins import selective_implicit_prices as implicit_prices
13
14
15class TestImplicitPrices(cmptest.TestCase):
16
17 @loader.load_doc()
18 def test_add_implicit_prices__all_cases(self, entries, _, options_map):
19 """
20 1702-04-02 commodity USD
21 implicit-prices: TRUE
22
23 2013-01-01 commodity HOOL
24 implicit-prices: TRUE
25
26 2013-01-01 open Assets:Account1
27 2013-01-01 open Assets:Account2
28 2013-01-01 open Assets:Other
29
30 ;; An explicit price directive.
31 2013-02-01 price USD 1.10 CAD
32
33 2013-04-01 * "A transaction with a price conversion."
34 Assets:Account1 150 USD @ 1.12 CAD
35 Assets:Other
36
37 ;; This should book at price at the cost.
38 2013-04-02 * "A transaction with a cost."
39 Assets:Account1 1500 HOOL {520 USD}
40 Assets:Other
41
42 ;; This one should be IGNORED because it books against the above.
43 2013-04-03 * "A transaction with a cost that reduces an existing position"
44 Assets:Account1 -500 HOOL {520 USD}
45 Assets:Other
46
47 ;; This one should generate the price, even if it is reducing.
48 2013-04-04 * "A transaction with a cost that reduces existing position, with price"
49 Assets:Account1 -100 HOOL {520 USD} @ 530 USD
50 Assets:Other
51
52 ;; This is not reducing and should also book a price at cost.
53 2013-04-05 * "A transaction with another cost that is not reducing."
54 Assets:Account1 500 HOOL {540 USD}
55 Assets:Other
56
57 ;; The price here overrides the cost and should create an entry.
58 2013-04-06 * "A transaction with a cost and a price."
59 Assets:Account1 500 HOOL {540 USD} @ 560 USD
60 Assets:Other
61 """
62 self.assertEqual(12, len(entries))
63 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
64 price_entries = [entry for entry in new_entries if isinstance(entry, data.Price)]
65
66 self.assertEqualEntries("""
67 1702-04-02 commodity USD
68 implicit-prices: TRUE
69
70 2013-01-01 commodity HOOL
71 implicit-prices: TRUE
72
73 2013-01-01 open Assets:Account1
74 2013-01-01 open Assets:Account2
75 2013-01-01 open Assets:Other
76
77 2013-02-01 price USD 1.10 CAD
78
79 2013-04-01 * "A transaction with a price conversion."
80 Assets:Account1 150 USD @ 1.12 CAD
81 Assets:Other -168.00 CAD
82
83 2013-04-01 price USD 1.12 CAD
84
85 2013-04-02 * "A transaction with a cost."
86 Assets:Account1 1500 HOOL {520 USD}
87 Assets:Other -780000 USD
88
89 2013-04-02 price HOOL 520 USD
90
91 2013-04-03 * "A transaction with a cost that reduces an existing position"
92 Assets:Account1 -500 HOOL {520 USD}
93 Assets:Other 260000 USD
94
95 2013-04-04 * "A transaction with a cost that reduces existing position, with price"
96 Assets:Account1 -100 HOOL {520 USD} @ 530 USD
97 Assets:Other 52000 USD
98
99 2013-04-04 price HOOL 530 USD
100
101 2013-04-05 * "A transaction with another cost that is not reducing."
102 Assets:Account1 500 HOOL {540 USD}
103 Assets:Other -270000 USD
104
105 2013-04-05 price HOOL 540 USD
106
107 2013-04-06 * "A transaction with a cost and a price."
108 Assets:Account1 500 HOOL {540 USD} @ 560 USD
109 Assets:Other -270000 USD
110
111 2013-04-06 price HOOL 560 USD
112 """, new_entries)
113
114 self.assertEqual(6, len(price_entries))
115 expected_values = [(x[0], x[1], D(x[2])) for x in [
116 ('USD', 'CAD', '1.10'),
117 ('USD', 'CAD', '1.12'),
118 ('HOOL', 'USD', '520.00'),
119 ('HOOL', 'USD', '530.00'),
120 ('HOOL', 'USD', '540.00'),
121 ('HOOL', 'USD', '560.00')
122 ]]
123 for expected, price in zip(expected_values, price_entries):
124 actual = (price.currency, price.amount.currency, price.amount.number)
125 self.assertEqual(expected, actual)
126
127 @loader.load_doc()
128 def test_add_implicit_prices__other_account(self, entries, errors, options_map):
129 """
130 2013-01-01 commodity HOOL
131 implicit-prices: TRUE
132
133 2013-01-01 open Assets:Account1
134 2013-01-01 open Assets:Account2 "NONE"
135 2013-01-01 open Assets:Other
136
137 2013-04-01 *
138 Assets:Account1 1500 HOOL {520 USD}
139 Assets:Other
140
141 2013-04-02 *
142 Assets:Account2 1500 HOOL {530 USD}
143 Assets:Other
144
145 2013-04-10 * "Reduces existing position in account 1"
146 Assets:Account1 -100 HOOL {520 USD}
147 Assets:Other 52000 USD
148
149 2013-04-11 * "Does not find an existing position in account 2"
150 Assets:Account2 -200 HOOL {531 USD}
151 Assets:Other 106200 USD
152
153 """
154 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
155 self.assertEqualEntries("""
156 2013-01-01 commodity HOOL
157 implicit-prices: TRUE
158
159 2013-01-01 open Assets:Account1
160 2013-01-01 open Assets:Account2 "NONE"
161 2013-01-01 open Assets:Other
162
163 2013-04-01 *
164 Assets:Account1 1500 HOOL {520 USD}
165 Assets:Other -780000 USD
166
167 2013-04-02 *
168 Assets:Account2 1500 HOOL {530 USD}
169 Assets:Other -795000 USD
170
171 2013-04-01 price HOOL 520 USD
172
173 2013-04-02 price HOOL 530 USD
174
175 2013-04-10 * "Reduces existing position in account 1"
176 Assets:Account1 -100 HOOL {520 USD}
177 Assets:Other 52000 USD
178
179 2013-04-11 * "Does not find an existing position in account 2"
180 Assets:Account2 -200 HOOL {531 USD}
181 Assets:Other 106200 USD
182
183 ;; Because a match was not found against the inventory, a price will be added.
184 2013-04-11 price HOOL 531 USD
185
186 """, new_entries)
187
188 @loader.load_doc()
189 def test_add_implicit_prices__duplicates_on_same_transaction(self,
190 entries, _, options_map):
191 """
192 2013-01-01 commodity HOOL
193 implicit-prices: TRUE
194
195 2013-01-01 open Assets:Account1
196 2013-01-01 open Assets:Account2
197 2013-01-01 open Assets:Other
198
199 2013-04-01 * "Allowed because of same price"
200 Assets:Account1 1500 HOOL {520 USD}
201 Assets:Account2 1500 HOOL {520 USD}
202 Assets:Other
203
204 2013-04-02 * "Second one is disallowed because of different price"
205 Assets:Account1 1500 HOOL {520 USD}
206 Assets:Account2 1500 HOOL {530 USD}
207 Assets:Other
208
209 """
210 new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map)
211 self.assertEqual([], [type(error) for error in errors])
212 self.assertEqualEntries("""
213 2013-01-01 commodity HOOL
214 implicit-prices: TRUE
215
216 2013-01-01 open Assets:Account1
217 2013-01-01 open Assets:Account2
218 2013-01-01 open Assets:Other
219
220 2013-04-01 * "Allowed because of same price"
221 Assets:Account1 1500 HOOL {520 USD}
222 Assets:Account2 1500 HOOL {520 USD}
223 Assets:Other -1560000 USD
224
225 2013-04-01 price HOOL 520 USD
226
227 2013-04-02 * "Second one is disallowed because of different price"
228 Assets:Account1 1500 HOOL {520 USD}
229 Assets:Account2 1500 HOOL {530 USD}
230 Assets:Other -1575000 USD
231
232 2013-04-02 price HOOL 520 USD
233 2013-04-02 price HOOL 530 USD ;; Allowed for now.
234
235 """, new_entries)
236
237 @loader.load_doc()
238 def test_add_implicit_prices__duplicates_on_different_transactions(self,
239 entries, _,
240 options_map):
241 """
242 2013-01-01 commodity HOOL
243 implicit-prices: TRUE
244
245 2013-01-01 open Assets:Account1
246 2013-01-01 open Assets:Account2
247 2013-01-01 open Assets:Other
248
249 2013-04-01 * "Allowed because of same price #1"
250 Assets:Account1 1500 HOOL {520 USD}
251 Assets:Other
252
253 2013-04-01 * "Allowed because of same price #2"
254 Assets:Account2 1500 HOOL {520 USD}
255 Assets:Other
256
257 2013-04-02 * "Second one is disallowed because of different price #1"
258 Assets:Account1 1500 HOOL {520 USD}
259 Assets:Other
260
261 2013-04-02 * "Second one is disallowed because of different price #2"
262 Assets:Account2 1500 HOOL {530 USD}
263 Assets:Other
264
265 """
266 new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map)
267 self.assertEqual([], [type(error) for error in errors])
268 self.assertEqualEntries("""
269 2013-01-01 commodity HOOL
270 implicit-prices: TRUE
271
272 2013-01-01 open Assets:Account1
273 2013-01-01 open Assets:Account2
274 2013-01-01 open Assets:Other
275
276 2013-04-01 * "Allowed because of same price #1"
277 Assets:Account1 1500 HOOL {520 USD}
278 Assets:Other -780000 USD
279
280 2013-04-01 * "Allowed because of same price #2"
281 Assets:Account2 1500 HOOL {520 USD}
282 Assets:Other -780000 USD
283
284 2013-04-01 price HOOL 520 USD
285
286 2013-04-02 * "Second one is disallowed because of different price #1"
287 Assets:Account1 1500 HOOL {520 USD}
288 Assets:Other -780000 USD
289
290 2013-04-02 * "Second one is disallowed because of different price #2"
291 Assets:Account2 1500 HOOL {530 USD}
292 Assets:Other -795000 USD
293
294 2013-04-02 price HOOL 520 USD
295 2013-04-02 price HOOL 530 USD ;; Allowed for now.
296
297 """, new_entries)
298
299 @loader.load_doc()
300 def test_add_implicit_prices__duplicates_overloaded(self, entries, _, options_map):
301 """
302 2013-01-01 commodity HOOL
303 implicit-prices: TRUE
304
305 2013-01-01 open Assets:Account1
306 2013-01-01 open Assets:Other
307
308 2013-04-01 * "Allowed, sets the price for that day"
309 Assets:Account1 1500 HOOL {520 USD}
310 Assets:Other
311
312 2013-04-01 * "Will be ignored, price for the day already set"
313 Assets:Account1 1500 HOOL {530 USD}
314 Assets:Other
315
316 2013-04-01 * "Should be ignored too, price for the day already set"
317 Assets:Account1 1500 HOOL {530 USD}
318 Assets:Other
319 """
320 new_entries, errors = implicit_prices.add_implicit_prices(entries, options_map)
321 self.assertEqual([], [type(error) for error in errors])
322 self.assertEqualEntries("""
323 2013-01-01 commodity HOOL
324 implicit-prices: TRUE
325
326 2013-01-01 open Assets:Account1
327 2013-01-01 open Assets:Other
328
329 2013-04-01 * "Allowed, sets the price for that day"
330 Assets:Account1 1500 HOOL {520 USD}
331 Assets:Other -780000 USD
332
333 2013-04-01 * "Will be ignored, price for the day already set"
334 Assets:Account1 1500 HOOL {530 USD}
335 Assets:Other -795000 USD
336
337 2013-04-01 * "Should be ignored too, price for the day already set"
338 Assets:Account1 1500 HOOL {530 USD}
339 Assets:Other -795000 USD
340
341 2013-04-01 price HOOL 520 USD
342 2013-04-01 price HOOL 530 USD
343
344 """, new_entries)
345
346 @loader.load_doc()
347 def test_add_implicit_prices__not_enabled(self, entries, errors, options_map):
348 """
349 2013-01-01 open Assets:Account1
350 2013-01-01 open Assets:Other
351
352 2013-04-01 *
353 Assets:Account1 1500 HOOL {520 USD}
354 Assets:Other
355 """
356 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
357 self.assertEqualEntries("""
358 2013-01-01 open Assets:Account1
359 2013-01-01 open Assets:Other
360
361 2013-04-01 *
362 Assets:Account1 1500 HOOL {520 USD}
363 Assets:Other -780000 USD
364 """, new_entries)
365
366 @loader.load_doc()
367 def test_add_implicit_prices__disabled(self, entries, errors, options_map):
368 """
369 2013-01-01 commodity HOOL
370 implicit-prices: FALSE
371
372 2013-01-01 open Assets:Account1
373 2013-01-01 open Assets:Other
374
375 2013-04-01 *
376 Assets:Account1 1500 HOOL {520 USD}
377 Assets:Other
378 """
379 new_entries, _ = implicit_prices.add_implicit_prices(entries, options_map)
380 self.assertEqualEntries("""
381 2013-01-01 commodity HOOL
382 implicit-prices: FALSE
383
384 2013-01-01 open Assets:Account1
385 2013-01-01 open Assets:Other
386
387 2013-04-01 *
388 Assets:Account1 1500 HOOL {520 USD}
389 Assets:Other -780000 USD
390 """, new_entries)
391
392 @loader.load_doc()
393 def test_add_implicit_prices__invalid(self, entries, errors, options_map):
394 """
395 2013-01-01 commodity HOOL
396 implicit-prices: "yes"
397
398 2013-01-01 open Assets:Account1
399 2013-01-01 open Assets:Other
400
401 2013-04-01 *
402 Assets:Account1 1500 HOOL {520 USD}
403 Assets:Other
404 """
405 _, new_errors = implicit_prices.add_implicit_prices(entries, options_map)
406 self.assertRegex(new_errors[0].message, '^implicit-prices must be Boolean')
407
408
409if __name__ == '__main__':
410 unittest.main()
diff --git a/beancount_extras_kris7t/plugins/templates.py b/beancount_extras_kris7t/plugins/templates.py
new file mode 100644
index 0000000..7d2d2c1
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/templates.py
@@ -0,0 +1,144 @@
1'''
2Plugin that closes an account by transferring its whole balance to another account.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7import datetime as dt
8from decimal import Decimal
9from typing import Any, Dict, List, NamedTuple, Optional, Tuple
10
11from beancount.core import amount
12from beancount.core.data import Custom, Directive, Entries, Meta, Transaction, Union
13from beancount.core.number import ZERO
14
15__plugins__ = ('apply_templates',)
16
17TEMPLATE_META = 'template'
18TEMPLATE_USE_CUSTOM = 'template-use'
19TEMPLATE_DELETE_CUSTOM = 'template-delete'
20TEMPLATE_TAG_PREFIX = 'template'
21
22
23Templates = Dict[str, Transaction]
24
25
26class TemplateError(NamedTuple):
27 source: Optional[Meta]
28 message: str
29 entry: Directive
30
31
32def _create_transaction(date: dt.date,
33 meta: Meta,
34 template: Transaction,
35 scale_factor: Decimal) -> Transaction:
36 return template._replace(
37 date=date,
38 meta={**template.meta, **meta},
39 postings=[posting._replace(units=amount.mul(posting.units, scale_factor))
40 for posting in template.postings])
41
42
43def _use_template(entry: Custom,
44 templates: Templates) -> Union[Transaction, TemplateError]:
45 if len(entry.values) == 0:
46 return TemplateError(entry.meta, 'Template name missing', entry)
47 if len(entry.values) > 2:
48 return TemplateError(
49 entry.meta,
50 f'Too many {TEMPLATE_USE_CUSTOM} arguments',
51 entry)
52 template_name = entry.values[0].value
53 if not isinstance(template_name, str):
54 return TemplateError(
55 entry.meta,
56 f'Template name must be a string, got {template_name} instead',
57 entry)
58 template = templates.get(template_name, None)
59 if template is None:
60 return TemplateError(
61 entry.meta,
62 f'Unknown template: {template_name}',
63 entry)
64 if len(entry.values) == 2:
65 scale_factor = entry.values[1].value
66 if not isinstance(scale_factor, Decimal):
67 return TemplateError(
68 entry.meta,
69 f'Invalid scale factor {scale_factor}',
70 entry)
71 if scale_factor == ZERO:
72 return TemplateError(
73 entry.meta,
74 f'Scale factor must not be {ZERO}',
75 entry)
76 else:
77 scale_factor = Decimal(1.0)
78 return _create_transaction(entry.date, entry.meta, template, scale_factor)
79
80
81def _add_template(entry: Transaction, templates: Templates) -> Optional[TemplateError]:
82 template_name = entry.meta[TEMPLATE_META]
83 if not isinstance(template_name, str):
84 return TemplateError(
85 entry.meta,
86 f'{TEMPLATE_META} must be a string, got {template_name} instead',
87 entry)
88 new_meta = dict(entry.meta)
89 del new_meta[TEMPLATE_META]
90 templates[template_name] = entry._replace(
91 meta=new_meta,
92 links={*entry.links, f'{TEMPLATE_TAG_PREFIX}_{template_name}'})
93 return None
94
95
96def _delete_template(entry: Custom, templates: Templates) -> Optional[TemplateError]:
97 if len(entry.values) != 1:
98 return TemplateError(
99 entry.meta,
100 f'{TEMPLATE_DELETE_CUSTOM} takes a single argument',
101 entry)
102 template_name = entry.values[0].value
103 if not isinstance(template_name, str):
104 return TemplateError(
105 entry.meta,
106 f'Template name must be a string, got {template_name} instead',
107 entry)
108 if template_name not in templates:
109 return TemplateError(
110 entry.meta,
111 f'Unknown template: {template_name}',
112 entry)
113 del templates[template_name]
114 return None
115
116
117def apply_templates(entries: Entries,
118 options_map: Dict[str, Any],
119 config_str: Optional[str] = None) -> \
120 Tuple[Entries, List[TemplateError]]:
121 new_entries: Entries = []
122 errors: List[TemplateError] = []
123 templates: Templates = {}
124 for entry in entries:
125 if isinstance(entry, Transaction) and TEMPLATE_META in entry.meta:
126 result = _add_template(entry, templates)
127 if result:
128 errors.append(result)
129 elif isinstance(entry, Custom):
130 if entry.type == TEMPLATE_USE_CUSTOM:
131 result = _use_template(entry, templates)
132 if isinstance(result, TemplateError):
133 errors.append(result)
134 else:
135 new_entries.append(result)
136 elif entry.type == TEMPLATE_DELETE_CUSTOM:
137 result = _delete_template(entry, templates)
138 if result:
139 errors.append(result)
140 else:
141 new_entries.append(entry)
142 else:
143 new_entries.append(entry)
144 return new_entries, errors
diff --git a/beancount_extras_kris7t/plugins/templates_test.py b/beancount_extras_kris7t/plugins/templates_test.py
new file mode 100644
index 0000000..5f63e6c
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/templates_test.py
@@ -0,0 +1,326 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8import pytest
9
10
11class TestClosingBalance(cmptest.TestCase):
12
13 @loader.load_doc()
14 def test_use_template_simple(self, entries, errors, options_map):
15 '''
16 plugin "beancount_extras_kris7t.plugins.templates"
17
18 2020-01-01 open Assets:Checking
19 2020-01-01 open Expenses:Food
20
21 2020-01-01 * "Eating out" #tag ^link
22 template: "eating-out"
23 Assets:Checking 25 USD
24 Expenses:Food -25 USD
25
26 2020-03-15 custom "template-use" "eating-out"
27
28 2020-04-12 custom "template-use" "eating-out"
29 '''
30 self.assertEqualEntries('''
31 plugin "beancount_extras_kris7t.plugins.closing_balance"
32
33 2020-01-01 open Assets:Checking
34 2020-01-01 open Expenses:Food
35
36 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
37 template: "eating-out"
38 Assets:Checking 25 USD
39 Expenses:Food -25 USD
40
41 2020-04-12 * "Eating out" #tag ^link ^template_eating-out
42 template: "eating-out"
43 Assets:Checking 25 USD
44 Expenses:Food -25 USD
45 ''', entries)
46
47 @loader.load_doc()
48 def test_use_template_metadata(self, entries, errors, options_map):
49 '''
50 plugin "beancount_extras_kris7t.plugins.templates"
51
52 2020-01-01 open Assets:Checking
53 2020-01-01 open Expenses:Food
54
55 2020-01-01 * "Eating out" #tag ^link
56 template: "eating-out"
57 meta1: "data"
58 meta2: TRUE
59 Assets:Checking 25 USD
60 Expenses:Food -25 USD
61
62 2020-03-15 custom "template-use" "eating-out"
63 meta1: "foo"
64 meta3: 3.14
65 '''
66 self.assertEqualEntries('''
67 plugin "beancount_extras_kris7t.plugins.closing_balance"
68
69 2020-01-01 open Assets:Checking
70 2020-01-01 open Expenses:Food
71
72 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
73 template: "eating-out"
74 meta1: "foo"
75 meta2: TRUE
76 meta3: 2.14
77 Assets:Checking 25 USD
78 Expenses:Food -25 USD
79 ''', entries)
80
81 @loader.load_doc()
82 def test_use_template_scaled(self, entries, errors, options_map):
83 '''
84 plugin "beancount_extras_kris7t.plugins.templates"
85
86 2020-01-01 open Assets:Checking
87 2020-01-01 open Expenses:Food
88
89 2020-01-01 * "Eating out" #tag ^link
90 template: "eating-out"
91 Assets:Checking 1 USD
92 Expenses:Food -1 USD
93
94 2020-03-15 custom "template-use" "eating-out" 25
95
96 2020-04-12 custom "template-use" "eating-out" 27
97 '''
98 self.assertEqualEntries('''
99 plugin "beancount_extras_kris7t.plugins.closing_balance"
100
101 2020-01-01 open Assets:Checking
102 2020-01-01 open Expenses:Food
103
104 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
105 template: "eating-out"
106 Assets:Checking 25 USD
107 Expenses:Food -25 USD
108
109 2020-04-12 * "Eating out" #tag ^link ^template_eating-out
110 template: "eating-out"
111 Assets:Checking 27 USD
112 Expenses:Food -27 USD
113 ''', entries)
114
115 @loader.load_doc()
116 def test_use_template_overwritten(self, entries, errors, options_map):
117 '''
118 plugin "beancount_extras_kris7t.plugins.templates"
119
120 2020-01-01 open Assets:Checking
121 2020-01-01 open Expenses:Food
122 2020-01-01 open Expenses:Tax
123
124 2020-04-01 * "Eating out" #tag ^link
125 template: "eating-out"
126 Assets:Checking 1.10 USD
127 Expenses:Food -1 USD
128 Expenses:Tax -0.10 USD
129
130 2020-01-01 * "Eating out" #tag ^link
131 template: "eating-out"
132 Assets:Checking 1 USD
133 Expenses:Food -1 USD
134
135 2020-03-15 custom "template-use" "eating-out" 25
136
137 2020-04-12 custom "template-use" "eating-out" 27
138 '''
139 self.assertEqualEntries('''
140 plugin "beancount_extras_kris7t.plugins.closing_balance"
141
142 2020-01-01 open Assets:Checking
143 2020-01-01 open Expenses:Food
144 2020-01-01 open Expenses:Tax
145
146 2020-03-15 * "Eating out" #tag ^link ^template_eating-out
147 template: "eating-out"
148 Assets:Checking 25 USD
149 Expenses:Food -25 USD
150
151 2020-04-12 * "Eating out" #tag ^link ^template_eating-out
152 template: "eating-out"
153 Assets:Checking 29.70 USD
154 Expenses:Food -27 USD
155 Expenses:Tax -2.70 USD
156 ''', entries)
157
158 @loader.load_doc(expect_errors=True)
159 def test_invalid_name(self, entries, errors, options_map):
160 '''
161 plugin "beancount_extras_kris7t.plugins.templates"
162
163 2020-01-01 open Assets:Checking
164 2020-01-01 open Expenses:Food
165
166 2020-01-01 * "Eating out"
167 template: TRUE
168 Assets:Checking 25 USD
169 Expenses:Food -25 USD
170 '''
171 self.assertRegex(errors[0].message, "^template must be a string")
172
173 @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad")
174 @loader.load_doc(expect_errors=True)
175 def test_use_missing_name(self, entries, errors, options_map):
176 '''
177 plugin "beancount_extras_kris7t.plugins.templates"
178
179 2020-01-01 open Assets:Checking
180 2020-01-01 open Expenses:Food
181
182 2020-01-01 * "Eating out"
183 template: "eating-out"
184 Assets:Checking 25 USD
185 Expenses:Food -25 USD
186
187 2020-03-15 custom "template-use"
188 '''
189 self.assertRegex(errors[0].message, "^Template name missing")
190
191 @loader.load_doc(expect_errors=True)
192 def test_use_too_many_arguments(self, entries, errors, options_map):
193 '''
194 plugin "beancount_extras_kris7t.plugins.templates"
195
196 2020-01-01 open Assets:Checking
197 2020-01-01 open Expenses:Food
198
199 2020-01-01 * "Eating out"
200 template: "eating-out"
201 Assets:Checking 25 USD
202 Expenses:Food -25 USD
203
204 2020-03-15 custom "template-use" "eating-out" 2 "another"
205 '''
206 self.assertRegex(errors[0].message, "^Too many template-use arguments")
207
208 @loader.load_doc(expect_errors=True)
209 def test_use_invalid_name(self, entries, errors, options_map):
210 '''
211 plugin "beancount_extras_kris7t.plugins.templates"
212
213 2020-03-15 custom "template-use" TRUE
214 '''
215 self.assertRegex(errors[0].message, "^Template name must be a string")
216
217 @loader.load_doc(expect_errors=True)
218 def test_use_unknown(self, entries, errors, options_map):
219 '''
220 plugin "beancount_extras_kris7t.plugins.templates"
221
222 2020-03-15 custom "template-use" "taxi"
223 '''
224 self.assertRegex(errors[0].message, "^Unknown template")
225
226 @loader.load_doc(expect_errors=True)
227 def test_use_invalid_scale_factor(self, entries, errors, options_map):
228 '''
229 plugin "beancount_extras_kris7t.plugins.templates"
230
231 2020-01-01 open Assets:Checking
232 2020-01-01 open Expenses:Food
233
234 2020-01-01 * "Eating out"
235 template: "eating-out"
236 Assets:Checking 25 USD
237 Expenses:Food -25 USD
238
239 2020-03-15 custom "template-use" "eating-out" TRUE
240 '''
241 self.assertRegex(errors[0].message, "^Invalid scale factor")
242
243 @loader.load_doc(expect_errors=True)
244 def test_use_zero_scale_factor(self, entries, errors, options_map):
245 '''
246 plugin "beancount_extras_kris7t.plugins.templates"
247
248 2020-01-01 open Assets:Checking
249 2020-01-01 open Expenses:Food
250
251 2020-01-01 * "Eating out"
252 template: "eating-out"
253 Assets:Checking 25 USD
254 Expenses:Food -25 USD
255
256 2020-03-15 custom "template-use" "eating-out" 0.0
257 '''
258 self.assertRegex(errors[0].message, "^Scale factor must not be 0")
259
260 @loader.load_doc(expect_errors=True)
261 def test_template_delete(self, entries, errors, options_map):
262 '''
263 plugin "beancount_extras_kris7t.plugins.templates"
264
265 2020-01-01 open Assets:Checking
266 2020-01-01 open Expenses:Food
267
268 2020-01-01 * "Eating out"
269 template: "eating-out"
270 Assets:Checking 25 USD
271 Expenses:Food -25 USD
272
273 2020-03-01 custom "template-delete" "eating-out"
274
275 2020-03-15 custom "template-use" "eating-out"
276 '''
277 self.assertRegex(errors[0].message, "^Unknown template")
278
279 @pytest.mark.xfail(reason="Empty custom directive fails in beancount.ops.pad")
280 @loader.load_doc(expect_errors=True)
281 def test_template_delete_too_few_arguments(self, entries, errors, options_map):
282 '''
283 plugin "beancount_extras_kris7t.plugins.templates"
284
285 2020-03-01 custom "template-delete"
286 '''
287 self.assertRegex(errors[0].message, "^template-delete takes a single argument")
288
289 @loader.load_doc(expect_errors=True)
290 def test_template_delete_too_many_arguments(self, entries, errors, options_map):
291 '''
292 plugin "beancount_extras_kris7t.plugins.templates"
293
294 2020-01-01 open Assets:Checking
295 2020-01-01 open Expenses:Food
296
297 2020-01-01 * "Eating out"
298 template: "eating-out"
299 Assets:Checking 25 USD
300 Expenses:Food -25 USD
301
302 2020-03-01 custom "template-delete" "eating-out" TRUE
303 '''
304 self.assertRegex(errors[0].message, "^template-delete takes a single argument")
305
306 @loader.load_doc(expect_errors=True)
307 def test_template_delete_invalid_argument(self, entries, errors, options_map):
308 '''
309 plugin "beancount_extras_kris7t.plugins.templates"
310
311 2020-03-01 custom "template-delete" TRUE
312 '''
313 self.assertRegex(errors[0].message, "^Template name must be a string")
314
315 @loader.load_doc(expect_errors=True)
316 def test_template_delete_unknown(self, entries, errors, options_map):
317 '''
318 plugin "beancount_extras_kris7t.plugins.templates"
319
320 2020-03-01 custom "template-delete" "taxi"
321 '''
322 self.assertRegex(errors[0].message, "^Unknown template")
323
324
325if __name__ == '__main__':
326 unittest.main()
diff --git a/beancount_extras_kris7t/plugins/transfer_accounts.py b/beancount_extras_kris7t/plugins/transfer_accounts.py
new file mode 100644
index 0000000..653a54f
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/transfer_accounts.py
@@ -0,0 +1,188 @@
1'''
2Plugin that splits off postings into a new transaction to simulate settlement dates.
3'''
4__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
5__license__ = 'GNU GPLv2'
6
7from collections import defaultdict
8import datetime as dt
9from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union
10
11from beancount.core import amount, convert
12from beancount.core.amount import Amount
13from beancount.core.data import Cost, CostSpec, Directive, Entries, Meta, Posting, Open, \
14 Transaction
15from beancount.core.inventory import Inventory
16from beancount.core.number import ZERO
17
18__plugins__ = ('split_entries_via_transfer_accounts',)
19
20TRANSFER_ACCOUNT_META = 'transfer-account'
21TRANSFER_DATE_META = 'transfer-date'
22TRANSFER_CONVERTED_META = 'transfer-converted'
23TRANSFER_CONVERTED_DEFAULT = True
24
25
26class TransferAccountError(NamedTuple):
27 source: Optional[Meta]
28 message: str
29 entry: Directive
30
31
32class _OutgoingTransfer(NamedTuple):
33 accout: str
34 currency: str
35 cost: Optional[Union[Cost, CostSpec]]
36 price: Optional[Amount]
37
38
39class _IncomingTransfer(NamedTuple):
40 account: str
41 date: dt.date
42
43
44class _IncomingPostings(NamedTuple):
45 postings: List[Posting]
46 inventroy: Inventory
47
48
49class _Splitter:
50 _entry: Transaction
51 _processed_entries: Entries
52 _errors: List[TransferAccountError]
53 _default_converted: Dict[str, bool]
54 _processed_postings: List[Posting]
55 _amounts_to_transfer: Dict[_OutgoingTransfer, Amount]
56 _new_transactions: Dict[_IncomingTransfer, _IncomingPostings]
57
58 def __init__(
59 self,
60 entry: Transaction,
61 processed_entries: Entries,
62 errors: List[TransferAccountError],
63 default_converted: Dict[str, bool]):
64 self._entry = entry
65 self._processed_entries = processed_entries
66 self._errors = errors
67 self._default_converted = default_converted
68 self._processed_postings = []
69 self._amounts_to_transfer = {}
70 self._new_transactions = defaultdict(lambda: _IncomingPostings([], Inventory()))
71
72 def split(self) -> None:
73 for posting in self._entry.postings:
74 self._split_posting(posting)
75 if not self._amounts_to_transfer:
76 self._processed_entries.append(self._entry)
77 return
78 for (account, _, cost, price), units in self._amounts_to_transfer.items():
79 if units.number != ZERO:
80 self._processed_postings.append(Posting(account, units, cost, price, None, None))
81 self._processed_entries.append(self._entry._replace(postings=self._processed_postings))
82 for (account, date), (postings, inv) in self._new_transactions.items():
83 for (units, cost) in inv:
84 postings.append(Posting(account, -units, cost, None, None, None))
85 self._processed_entries.append(self._entry._replace(date=date, postings=postings))
86
87 def _split_posting(self, posting: Posting) -> None:
88 if not posting.meta:
89 self._processed_postings.append(posting)
90 return
91 transfer_account = posting.meta.pop(TRANSFER_ACCOUNT_META, None)
92 transfer_date = posting.meta.pop(TRANSFER_DATE_META, None)
93 transfer_converted = posting.meta.pop(TRANSFER_CONVERTED_META, None)
94 if transfer_account is None:
95 if transfer_date is not None:
96 self._report_error(
97 f'{TRANSFER_DATE_META} was set but {TRANSFER_ACCOUNT_META} was not')
98 if transfer_converted is not None:
99 self._report_error(
100 f'{TRANSFER_CONVERTED_META} was set but {TRANSFER_ACCOUNT_META} was not')
101 self._processed_postings.append(posting)
102 return
103 if not isinstance(transfer_account, str):
104 self._report_error(
105 f'{TRANSFER_ACCOUNT_META} must be a string, got {transfer_account} instead')
106 self._processed_postings.append(posting)
107 return
108 if transfer_date is None:
109 transfer_date = self._entry.date
110 elif not isinstance(transfer_date, dt.date):
111 self._report_error(
112 f'{TRANSFER_DATE_META} must be a date, got {transfer_date} instead')
113 transfer_date = self._entry.date
114 transfer_converted_default = self._default_converted.get(
115 transfer_account, TRANSFER_CONVERTED_DEFAULT)
116 if transfer_converted is None:
117 transfer_converted = transfer_converted_default
118 elif not isinstance(transfer_converted, bool):
119 self._report_error(
120 f'{TRANSFER_CONVERTED_META} must be a Boolean, got {transfer_converted} instead')
121 transfer_converted = transfer_converted_default
122 elif posting.price is None and posting.cost is None:
123 self._report_error(
124 f'{TRANSFER_CONVERTED_META} was set, but there is no conversion')
125 assert posting.units
126 self._split_posting_with_options(
127 posting, transfer_account, transfer_date, transfer_converted)
128
129 def _split_posting_with_options(
130 self,
131 posting: Posting,
132 transfer_account: str,
133 transfer_date: dt.date,
134 transfer_converted: bool) -> None:
135 incoming = _IncomingTransfer(transfer_account, transfer_date)
136 incoming_postings, inv = self._new_transactions[incoming]
137 converted_amount = convert.get_weight(posting)
138 if transfer_converted:
139 outgoing = _OutgoingTransfer(
140 transfer_account, posting.units.currency, posting.cost, posting.price)
141 self._accumulate_outgoing(outgoing, posting.units)
142 incoming_postings.append(posting._replace(price=None))
143 inv.add_amount(posting.units, posting.cost)
144 else:
145 outgoing = _OutgoingTransfer(transfer_account, converted_amount.currency, None, None)
146 self._accumulate_outgoing(outgoing, converted_amount)
147 incoming_postings.append(posting)
148 inv.add_amount(converted_amount)
149
150 def _accumulate_outgoing(self, outgoing: _OutgoingTransfer, units: Amount) -> None:
151 current_amount = self._amounts_to_transfer.get(outgoing, None)
152 if current_amount:
153 self._amounts_to_transfer[outgoing] = amount.add(current_amount, units)
154 else:
155 self._amounts_to_transfer[outgoing] = units
156
157 def _report_error(self, message: str) -> None:
158 self._errors.append(TransferAccountError(self._entry.meta, message, self._entry))
159
160
161def split_entries_via_transfer_accounts(
162 entries: Entries,
163 options_map: Dict[str, Any],
164 config_str: Optional[str] = None) -> Tuple[Entries, List[TransferAccountError]]:
165 default_converted: Dict[str, bool] = {}
166 errors: List[TransferAccountError] = []
167 for entry in entries:
168 if isinstance(entry, Open):
169 if not entry.meta:
170 continue
171 transfer_converted = entry.meta.get(TRANSFER_CONVERTED_META, None)
172 if transfer_converted is None:
173 continue
174 if not isinstance(transfer_converted, bool):
175 errors.append(TransferAccountError(
176 entry.meta,
177 f'{TRANSFER_CONVERTED_META} must be a Boolean,' +
178 f' got {transfer_converted} instead',
179 entry))
180 default_converted[entry.account] = transfer_converted
181 processed_entries: Entries = []
182 for entry in entries:
183 if isinstance(entry, Transaction):
184 splitter = _Splitter(entry, processed_entries, errors, default_converted)
185 splitter.split()
186 else:
187 processed_entries.append(entry)
188 return processed_entries, errors
diff --git a/beancount_extras_kris7t/plugins/transfer_accounts_test.py b/beancount_extras_kris7t/plugins/transfer_accounts_test.py
new file mode 100644
index 0000000..0883522
--- /dev/null
+++ b/beancount_extras_kris7t/plugins/transfer_accounts_test.py
@@ -0,0 +1,628 @@
1__copyright__ = 'Copyright (c) 2020 Kristóf Marussy <kristof@marussy.com>'
2__license__ = 'GNU GPLv2'
3
4import unittest
5
6from beancount import loader
7from beancount.parser import cmptest
8
9
10class TestTransferAccounts(cmptest.TestCase):
11
12 @loader.load_doc()
13 def test_same_currency(self, entries, _, __):
14 '''
15 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
16
17 2020-01-01 open Assets:Checking
18 2020-01-01 open Liabilities:CreditCard
19 2020-01-01 open Expenses:Taxi
20
21 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
22 Assets:Checking -20 USD
23 Expenses:Taxi
24 transfer-account: Liabilities:CreditCard
25 transfer-date: 2020-03-10
26 '''
27 self.assertEqualEntries('''
28 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
29
30 2020-01-01 open Assets:Checking
31 2020-01-01 open Liabilities:CreditCard
32 2020-01-01 open Expenses:Taxi
33
34 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
35 Assets:Checking -20 USD
36 Liabilities:CreditCard 20 USD
37
38 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
39 Expenses:Taxi 20 USD
40 Liabilities:CreditCard -20 USD
41 ''', entries)
42
43 @loader.load_doc()
44 def test_missing_date(self, entries, _, __):
45 '''
46 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
47
48 2020-01-01 open Assets:Checking
49 2020-01-01 open Liabilities:CreditCard
50 2020-01-01 open Expenses:Taxi
51
52 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
53 Assets:Checking -20 USD
54 Expenses:Taxi
55 transfer-account: Liabilities:CreditCard
56 '''
57 self.assertEqualEntries('''
58 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
59
60 2020-01-01 open Assets:Checking
61 2020-01-01 open Liabilities:CreditCard
62 2020-01-01 open Expenses:Taxi
63
64 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
65 Assets:Checking -20 USD
66 Liabilities:CreditCard 20 USD
67
68 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
69 Expenses:Taxi 20 USD
70 Liabilities:CreditCard -20 USD
71 ''', entries)
72
73 @loader.load_doc(expect_errors=True)
74 def test_missing_account_with_date(self, _, errors, __):
75 '''
76 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
77
78 2020-01-01 open Assets:Checking
79 2020-01-01 open Liabilities:CreditCard
80 2020-01-01 open Expenses:Taxi
81
82 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
83 Assets:Checking -20 USD
84 Expenses:Taxi
85 transfer-date: 2020-03-10
86 '''
87 self.assertRegex(errors[0].message, 'transfer-date was set but transfer-account was not')
88
89 @loader.load_doc(expect_errors=True)
90 def test_missing_account_with_conversion(self, _, errors, __):
91 '''
92 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
93
94 2020-01-01 open Assets:Checking
95 2020-01-01 open Liabilities:CreditCard
96 2020-01-01 open Expenses:Taxi
97
98 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
99 Assets:Checking -25.60 CAD
100 Expenses:Taxi 20 USD @ 1.28 CAD
101 transfer-converted: FALSE
102 '''
103 self.assertRegex(
104 errors[0].message, 'transfer-converted was set but transfer-account was not')
105
106 @loader.load_doc(expect_errors=True)
107 def test_invalid_account(self, _, errors, __):
108 '''
109 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
110
111 2020-01-01 open Assets:Checking
112 2020-01-01 open Liabilities:CreditCard
113 2020-01-01 open Expenses:Taxi
114
115 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
116 Assets:Checking -20 USD
117 Expenses:Taxi
118 transfer-account: 2020-03-10
119 '''
120 self.assertRegex(errors[0].message, 'transfer-account must be a string.*')
121
122 @loader.load_doc(expect_errors=True)
123 def test_invalid_date(self, _, errors, __):
124 '''
125 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
126
127 2020-01-01 open Assets:Checking
128 2020-01-01 open Liabilities:CreditCard
129 2020-01-01 open Expenses:Taxi
130
131 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
132 Assets:Checking -20 USD
133 Expenses:Taxi
134 transfer-account: Liabilities:CreditCard
135 transfer-date: "Ides of March"
136 '''
137 self.assertRegex(errors[0].message, 'transfer-date must be a date.*')
138
139 @loader.load_doc(expect_errors=True)
140 def test_invalid_conversion(self, _, errors, __):
141 '''
142 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
143
144 2020-01-01 open Assets:Checking
145 2020-01-01 open Liabilities:CreditCard
146 2020-01-01 open Expenses:Taxi
147
148 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
149 Assets:Checking -25.60 CAD
150 Expenses:Taxi 20 USD @ 1.28 CAD
151 transfer-account: Liabilities:CreditCard
152 transfer-converted: "indeed"
153 '''
154 self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*')
155
156 @loader.load_doc(expect_errors=True)
157 def test_invalid_account_conversion(self, _, errors, __):
158 '''
159 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
160
161 2020-01-01 open Liabilities:CreditCard
162 transfer-converted: "Indeed"
163 '''
164 self.assertRegex(errors[0].message, 'transfer-converted must be a Boolean.*')
165
166 @loader.load_doc(expect_errors=True)
167 def test_redundant_conversion(self, _, errors, __):
168 '''
169 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
170
171 2020-01-01 open Assets:Checking
172 2020-01-01 open Liabilities:CreditCard
173 2020-01-01 open Expenses:Taxi
174
175 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
176 Assets:Checking -20 USD
177 Expenses:Taxi
178 transfer-account: Liabilities:CreditCard
179 transfer-converted: TRUE
180 '''
181 self.assertRegex(
182 errors[0].message, 'transfer-converted was set, but there is no conversion.*')
183
184 @loader.load_doc()
185 def test_converted_price_false(self, entries, _, __):
186 '''
187 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
188
189 2020-01-01 open Assets:Checking
190 2020-01-01 open Liabilities:CreditCard
191 2020-01-01 open Expenses:Taxi
192
193 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
194 Assets:Checking -25.60 CAD
195 Expenses:Taxi 20 USD @ 1.28 CAD
196 transfer-account: Liabilities:CreditCard
197 transfer-date: 2020-03-10
198 transfer-converted: FALSE
199 '''
200 self.assertEqualEntries('''
201 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
202
203 2020-01-01 open Assets:Checking
204 2020-01-01 open Liabilities:CreditCard
205 2020-01-01 open Expenses:Taxi
206
207 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
208 Assets:Checking -25.60 CAD
209 Liabilities:CreditCard 25.60 CAD
210
211 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
212 Expenses:Taxi 20 USD @ 1.28 CAD
213 Liabilities:CreditCard -25.60 CAD
214 ''', entries)
215
216 @loader.load_doc()
217 def test_converted_price_true(self, entries, _, __):
218 '''
219 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
220
221 2020-01-01 open Assets:Checking
222 2020-01-01 open Liabilities:CreditCard
223 2020-01-01 open Expenses:Taxi
224
225 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
226 Assets:Checking -25.60 CAD
227 Expenses:Taxi 20 USD @ 1.28 CAD
228 transfer-account: Liabilities:CreditCard
229 transfer-date: 2020-03-10
230 transfer-converted: TRUE
231 '''
232 self.assertEqualEntries('''
233 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
234
235 2020-01-01 open Assets:Checking
236 2020-01-01 open Liabilities:CreditCard
237 2020-01-01 open Expenses:Taxi
238
239 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
240 Assets:Checking -25.60 CAD
241 Liabilities:CreditCard 20 USD @ 1.28 CAD
242
243 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
244 Expenses:Taxi 20 USD
245 Liabilities:CreditCard -20 USD
246 ''', entries)
247
248 @loader.load_doc()
249 def test_converted_price_default(self, entries, _, __):
250 '''
251 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
252
253 2020-01-01 open Assets:Checking
254 2020-01-01 open Liabilities:CreditCard
255 2020-01-01 open Expenses:Taxi
256
257 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
258 Assets:Checking -25.60 CAD
259 Expenses:Taxi 20 USD @ 1.28 CAD
260 transfer-account: Liabilities:CreditCard
261 transfer-date: 2020-03-10
262 '''
263 self.assertEqualEntries('''
264 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
265
266 2020-01-01 open Assets:Checking
267 2020-01-01 open Liabilities:CreditCard
268 2020-01-01 open Expenses:Taxi
269
270 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
271 Assets:Checking -25.60 CAD
272 Liabilities:CreditCard 20 USD @ 1.28 CAD
273
274 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
275 Expenses:Taxi 20 USD
276 Liabilities:CreditCard -20 USD
277 ''', entries)
278
279 @loader.load_doc()
280 def test_converted_price_account_false(self, entries, _, __):
281 '''
282 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
283
284 2020-01-01 open Assets:Checking
285 2020-01-01 open Liabilities:CreditCard
286 transfer-converted: FALSE
287 2020-01-01 open Expenses:Taxi
288
289 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
290 Assets:Checking -25.60 CAD
291 Expenses:Taxi 20 USD @ 1.28 CAD
292 transfer-account: Liabilities:CreditCard
293 transfer-date: 2020-03-10
294 '''
295 self.assertEqualEntries('''
296 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
297
298 2020-01-01 open Assets:Checking
299 2020-01-01 open Liabilities:CreditCard
300 2020-01-01 open Expenses:Taxi
301
302 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
303 Assets:Checking -25.60 CAD
304 Liabilities:CreditCard 25.60 CAD
305
306 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
307 Expenses:Taxi 20 USD @ 1.28 CAD
308 Liabilities:CreditCard -25.60 CAD
309 ''', entries)
310
311 @loader.load_doc()
312 def test_converted_price_account_true(self, entries, _, __):
313 '''
314 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
315
316 2020-01-01 open Assets:Checking
317 2020-01-01 open Liabilities:CreditCard
318 transfer-converted: TRUE
319 2020-01-01 open Expenses:Taxi
320
321 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
322 Assets:Checking -25.60 CAD
323 Expenses:Taxi 20 USD @ 1.28 CAD
324 transfer-account: Liabilities:CreditCard
325 transfer-date: 2020-03-10
326 '''
327 self.assertEqualEntries('''
328 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
329
330 2020-01-01 open Assets:Checking
331 2020-01-01 open Liabilities:CreditCard
332 2020-01-01 open Expenses:Taxi
333
334 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
335 Assets:Checking -25.60 CAD
336 Liabilities:CreditCard 20 USD @ 1.28 CAD
337
338 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
339 Expenses:Taxi 20 USD
340 Liabilities:CreditCard -20 USD
341 ''', entries)
342
343 @loader.load_doc()
344 def test_converted_cost_false(self, entries, _, __):
345 '''
346 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
347
348 2020-01-01 open Assets:Checking
349 2020-01-01 open Liabilities:CreditCard
350 2020-01-01 open Expenses:Taxi
351
352 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
353 Assets:Checking -25.60 CAD
354 Expenses:Taxi 20 USD {1.28 CAD}
355 transfer-account: Liabilities:CreditCard
356 transfer-date: 2020-03-10
357 transfer-converted: FALSE
358 '''
359 self.assertEqualEntries('''
360 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
361
362 2020-01-01 open Assets:Checking
363 2020-01-01 open Liabilities:CreditCard
364 2020-01-01 open Expenses:Taxi
365
366 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
367 Assets:Checking -25.60 CAD
368 Liabilities:CreditCard 25.60 CAD
369
370 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
371 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15}
372 Liabilities:CreditCard -25.60 CAD
373 ''', entries)
374
375 @loader.load_doc()
376 def test_converted_cost_true(self, entries, _, __):
377 '''
378 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
379
380 2020-01-01 open Assets:Checking
381 2020-01-01 open Liabilities:CreditCard
382 2020-01-01 open Expenses:Taxi
383
384 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
385 Assets:Checking -25.60 CAD
386 Expenses:Taxi 20 USD {1.28 CAD}
387 transfer-account: Liabilities:CreditCard
388 transfer-date: 2020-03-10
389 transfer-converted: TRUE
390 '''
391 self.assertEqualEntries('''
392 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
393
394 2020-01-01 open Assets:Checking
395 2020-01-01 open Liabilities:CreditCard
396 2020-01-01 open Expenses:Taxi
397
398 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
399 Assets:Checking -25.60 CAD
400 Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15}
401
402 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
403 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15}
404 Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15}
405 ''', entries)
406
407 @loader.load_doc()
408 def test_converted_cost_and_price_false(self, entries, _, __):
409 '''
410 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
411
412 2020-01-01 open Assets:Checking
413 2020-01-01 open Liabilities:CreditCard
414 2020-01-01 open Expenses:Taxi
415
416 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
417 Assets:Checking -25.60 CAD
418 Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD
419 transfer-account: Liabilities:CreditCard
420 transfer-date: 2020-03-10
421 transfer-converted: FALSE
422 '''
423 self.assertEqualEntries('''
424 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
425
426 2020-01-01 open Assets:Checking
427 2020-01-01 open Liabilities:CreditCard
428 2020-01-01 open Expenses:Taxi
429
430 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
431 Assets:Checking -25.60 CAD
432 Liabilities:CreditCard 25.60 CAD
433
434 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
435 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD
436 Liabilities:CreditCard -25.60 CAD
437 ''', entries)
438
439 @loader.load_doc()
440 def test_converted_cost_and_price_true(self, entries, _, __):
441 '''
442 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
443
444 2020-01-01 open Assets:Checking
445 2020-01-01 open Liabilities:CreditCard
446 2020-01-01 open Expenses:Taxi
447
448 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
449 Assets:Checking -25.60 CAD
450 Expenses:Taxi 20 USD {1.28 CAD} @ 1.30 CAD
451 transfer-account: Liabilities:CreditCard
452 transfer-date: 2020-03-10
453 transfer-converted: TRUE
454 '''
455 self.assertEqualEntries('''
456 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
457
458 2020-01-01 open Assets:Checking
459 2020-01-01 open Liabilities:CreditCard
460 2020-01-01 open Expenses:Taxi
461
462 2020-03-15 * "Taxi home from concert in Brooklyn" ^taxi
463 Assets:Checking -25.60 CAD
464 Liabilities:CreditCard 20 USD {1.28 CAD, 2020-03-15} @ 1.30 CAD
465
466 2020-03-10 * "Taxi home from concert in Brooklyn" ^taxi
467 Expenses:Taxi 20 USD {1.28 CAD, 2020-03-15}
468 Liabilities:CreditCard -20 USD {1.28 CAD, 2020-03-15}
469 ''', entries)
470
471 @loader.load_doc()
472 def test_multiple_separate(self, entries, _, __):
473 '''
474 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
475
476 2020-01-01 open Assets:Checking
477 2020-01-01 open Liabilities:CreditCard
478 2020-01-01 open Expenses:Taxi
479 2020-01-01 open Expenses:Food
480
481 2020-03-15 * "Night out in Brooklyn" ^taxi
482 Assets:Checking -45 USD
483 Expenses:Taxi 20 USD
484 transfer-account: Liabilities:CreditCard
485 transfer-date: 2020-03-10
486 Expenses:Food 25 USD
487 transfer-account: Liabilities:CreditCard
488 transfer-date: 2020-03-12
489 '''
490 self.assertEqualEntries('''
491 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
492
493 2020-01-01 open Assets:Checking
494 2020-01-01 open Liabilities:CreditCard
495 2020-01-01 open Expenses:Taxi
496 2020-01-01 open Expenses:Food
497
498 2020-03-15 * "Night out in Brooklyn" ^taxi
499 Assets:Checking -45 USD
500 Liabilities:CreditCard 45 USD
501
502 2020-03-10 * "Night out in Brooklyn" ^taxi
503 Expenses:Taxi 20 USD
504 Liabilities:CreditCard -20 USD
505
506 2020-03-12 * "Night out in Brooklyn" ^taxi
507 Expenses:Food 25 USD
508 Liabilities:CreditCard -25 USD
509 ''', entries)
510
511 @loader.load_doc()
512 def test_multiple_merge(self, entries, _, __):
513 '''
514 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
515
516 2020-01-01 open Assets:Checking
517 2020-01-01 open Liabilities:CreditCard
518 2020-01-01 open Expenses:Taxi
519 2020-01-01 open Expenses:Food
520
521 2020-03-15 * "Night out in Brooklyn" ^taxi
522 Assets:Checking -45 USD
523 Expenses:Taxi 20 USD
524 transfer-account: Liabilities:CreditCard
525 transfer-date: 2020-03-10
526 Expenses:Food 25 USD
527 transfer-account: Liabilities:CreditCard
528 transfer-date: 2020-03-10
529 '''
530 self.assertEqualEntries('''
531 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
532
533 2020-01-01 open Assets:Checking
534 2020-01-01 open Liabilities:CreditCard
535 2020-01-01 open Expenses:Taxi
536 2020-01-01 open Expenses:Food
537
538 2020-03-15 * "Night out in Brooklyn" ^taxi
539 Assets:Checking -45 USD
540 Liabilities:CreditCard 45 USD
541
542 2020-03-10 * "Night out in Brooklyn" ^taxi
543 Expenses:Taxi 20 USD
544 Expenses:Food 25 USD
545 Liabilities:CreditCard -45 USD
546 ''', entries)
547
548 @loader.load_doc()
549 def test_multiple_currencies_merge_converted_false(self, entries, _, __):
550 '''
551 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
552
553 2020-01-01 open Assets:Checking
554 2020-01-01 open Liabilities:CreditCard
555 2020-01-01 open Expenses:Taxi
556 2020-01-01 open Expenses:Food
557
558 2020-03-15 * "Night out in Brooklyn" ^taxi
559 Assets:Checking -50.60 CAD
560 Expenses:Taxi 20 USD @ 1.28 CAD
561 transfer-account: Liabilities:CreditCard
562 transfer-date: 2020-03-10
563 transfer-converted: FALSE
564 Expenses:Food 25 CAD
565 transfer-account: Liabilities:CreditCard
566 transfer-date: 2020-03-10
567 '''
568 self.assertEqualEntries('''
569 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
570
571 2020-01-01 open Assets:Checking
572 2020-01-01 open Liabilities:CreditCard
573 2020-01-01 open Expenses:Taxi
574 2020-01-01 open Expenses:Food
575
576 2020-03-15 * "Night out in Brooklyn" ^taxi
577 Assets:Checking -50.60 CAD
578 Liabilities:CreditCard 50.60 CAD
579
580 2020-03-10 * "Night out in Brooklyn" ^taxi
581 Expenses:Taxi 20 USD @ 1.28 CAD
582 Expenses:Food 25 CAD
583 Liabilities:CreditCard -50.60 CAD
584 ''', entries)
585
586 @loader.load_doc()
587 def test_multiple_currencies_merge_converted_true(self, entries, _, __):
588 '''
589 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
590
591 2020-01-01 open Assets:Checking
592 2020-01-01 open Liabilities:CreditCard
593 2020-01-01 open Expenses:Taxi
594 2020-01-01 open Expenses:Food
595
596 2020-03-15 * "Night out in Brooklyn" ^taxi
597 Assets:Checking -50.60 CAD
598 Expenses:Taxi 20 USD @ 1.28 CAD
599 transfer-account: Liabilities:CreditCard
600 transfer-date: 2020-03-10
601 transfer-converted: TRUE
602 Expenses:Food 25 CAD
603 transfer-account: Liabilities:CreditCard
604 transfer-date: 2020-03-10
605 '''
606 self.assertEqualEntries('''
607 plugin "beancount_extras_kris7t.plugins.transfer_accounts"
608
609 2020-01-01 open Assets:Checking
610 2020-01-01 open Liabilities:CreditCard
611 2020-01-01 open Expenses:Taxi
612 2020-01-01 open Expenses:Food
613
614 2020-03-15 * "Night out in Brooklyn" ^taxi
615 Assets:Checking -50.60 CAD
616 Liabilities:CreditCard 20 USD @ 1.28 CAD
617 Liabilities:CreditCard 25 CAD
618
619 2020-03-10 * "Night out in Brooklyn" ^taxi
620 Expenses:Taxi 20 USD
621 Expenses:Food 25 CAD
622 Liabilities:CreditCard -20 USD
623 Liabilities:CreditCard -25 CAD
624 ''', entries)
625
626
627if __name__ == '__main__':
628 unittest.main()