Skip to content

Commit

Permalink
feat: Add match metadata to matched postings
Browse files Browse the repository at this point in the history
I'm unsure of the status of this decade old issue
(https://groups.google.com/g/beancount/c/MU6KozsmqGQ/m/TspjVxx-ZYIJ).
This commit adds a config that adds metadata to matched postings such
that they can be "linked" to one another.

Alternatives would be using beancount's native links; however, I know in
my use case at least I will have transactions with multiple matches
(e.g., pay stubs with balances going to: multiple bank accounts, HSA,
401k, etc.). Thus, placing the match metadata at the transaction level
is less clear. There is probably some performance hit when querying for
matches on a posting level; but I feel that is negligible compared to
the improvement in clarity.

The use of random IDs might not be preferred, but I didn't want to try
and format some human readable string. All I really care about is being
able to pair transactions, not describe the match.
  • Loading branch information
John McCann committed Feb 19, 2024
1 parent c1a13c4 commit 3dcf5fd
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 25 deletions.
58 changes: 40 additions & 18 deletions beancount_reds_plugins/zerosum/test_zerosum.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import unittest
import re
import unittest

import beancount_reds_plugins.zerosum.zerosum as zerosum

from beancount.core import data
from beancount.parser import options
from beancount import loader

config = """{
'zerosum_accounts' : {
'Assets:Zero-Sum-Accounts:Returns-and-Temporary' : ('', 90),
'Assets:Zero-Sum-Accounts:Checkings' : ('', 90),
'Assets:Zero-Sum-Accounts:401k' : ('', 90),
},
'account_name_replace' : ('Zero-Sum-Accounts', 'ZSA-Matched'),
'tolerance' : 0.0098,
Expand All @@ -23,22 +26,6 @@ def get_entries_with_acc_regexp(entries, regexp):
any(re.search(regexp, posting.account) for posting in entry.postings))]


def get_entries_with_narration(entries, regexp):
"""Return the entries whose narration matches the regexp.
Args:
entries: A list of directives.
regexp: A regular expression string, to be matched against the
narration field of transactions.
Returns:
A list of directives.
"""
return [entry
for entry in entries
if (isinstance(entry, data.Transaction) and
re.search(regexp, entry.narration))]


class TestUnrealized(unittest.TestCase):

def test_empty_entries(self):
Expand Down Expand Up @@ -89,7 +76,8 @@ def test_single_rename(self, entries, _, options_map):
ref = [(0, 1), (0, 2), (1, 1), (2, 1)]

for (m, p) in ref:
self.assertEqual('Assets:ZSA-Matched:Returns-and-Temporary', matched[m].postings[p].account)
self.assertEqual('Assets:ZSA-Matched:Returns-and-Temporary',
matched[m].postings[p].account)

@loader.load_doc()
def test_above_tolerance(self, entries, _, options_map):
Expand Down Expand Up @@ -212,3 +200,37 @@ def test_two_unmatched_above_tolerance(self, entries, _, options_map):

matched_txns = get_entries_with_acc_regexp(new_entries, ':ZSA-Matched')
self.assertEqual(0, len(matched_txns))

@loader.load_doc()
def test_match_metadata_added(self, entries, _, options_map):
"""
2023-01-01 open Income:Salary
2023-01-01 open Assets:Bank:Checkings
2023-01-01 open Assets:Zero-Sum-Accounts:Checkings
2023-01-01 open Assets:Brokerage:401k
2023-01-01 open Assets:Zero-Sum-Accounts:401k
2024-02-15 * "Pay stub"
Income:Salary -1100.06 USD
Assets:Zero-Sum-Accounts:Checkings 999.47 USD
Assets:Zero-Sum-Accounts:401k 100.59 USD
2024-02-16 * "Bank account"
Assets:Bank:Checkings 999.47 USD
Assets:Zero-Sum-Accounts:Checkings
2024-02-16 * "401k statement"
Assets:Brokerage:401k 100.59 USD
Assets:Zero-Sum-Accounts:401k
"""
new_entries, _ = zerosum.zerosum(entries, options_map, config)

matched = dict(
[(m.narration, m) for m in
get_entries_with_acc_regexp(new_entries, ':ZSA-Matched')])

self.assertEqual(3, len(matched))
self.assertEqual(matched["Pay stub"].postings[1].meta['match_id'],
matched["Bank account"].postings[1].meta['match_id'])
self.assertEqual(matched["Pay stub"].postings[2].meta['match_id'],
matched["401k statement"].postings[1].meta['match_id'])
7 changes: 4 additions & 3 deletions beancount_reds_plugins/zerosum/zerosum-example.beancount
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
option "operating_currency" "USD"
plugin "beancount_reds_plugins.zerosum.zerosum" "{
'zerosum_accounts' : {
'zerosum_accounts' : {
'Assets:Reimbursements:Workplace' : ('Assets:Reimbursements-Received:Workplace', 40),
'Assets:Rebates' : ('Assets:ZeroSum-Matched:Rebates', 180),
'Assets:ZeroSum:Bank-Transfers' : ('', 3),
},
'account_name_replace' : ('ZeroSum', 'ZeroSum-Matched')
'account_name_replace' : ('ZeroSum', 'ZeroSum-Matched'),
'match_metadata': True,
}"

2000-01-01 open Liabilities:Credit-Card USD
Expand All @@ -30,7 +31,7 @@ plugin "beancount_reds_plugins.zerosum.zerosum" "{

2010-02-03 * "Reimbursement"
Assets:Bank:Checking 25 USD
Assets:Reimbursements:Workplace
Assets:Reimbursements:Workplace



Expand Down
27 changes: 23 additions & 4 deletions beancount_reds_plugins/zerosum/zerosum.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,12 @@
"""

import datetime
import random
import string
import time

from ast import literal_eval
import datetime
from collections import defaultdict

from beancount.core import data
Expand All @@ -172,14 +175,22 @@

DEBUG = 0
DEFAULT_TOLERANCE = 0.0099
MATCHING_ID_STRING = "match_id"
random.seed(6) # arbitrary fixed seed

__plugins__ = ('zerosum', 'flag_unmatched',)


# replace the account on a given posting with a new account
def account_replace(txn, posting, new_account):
def account_replace(txn, posting, new_account, match_id):
# create a new posting with the new account, then remove old and add new
# from parent transaction
if match_id:
if posting.meta:
# Will overwrite an existing match (shouldn't exist)
posting.meta.update({MATCHING_ID_STRING: match_id})
else:
posting.meta = {MATCHING_ID_STRING: match_id}
new_posting = posting._replace(account=new_account)
txn.postings.remove(posting)
txn.postings.append(new_posting)
Expand Down Expand Up @@ -208,6 +219,9 @@ def zerosum(entries, options_map, config): # noqa: C901
- 'flag_unmatched': bool to control whether to flag unmatched
transactions as warnings (default off)
- 'match_metadata': bool to control whether matched postings have metadata
linking the matched transactions, allowing manual verification in post.
See example for more info.
Returns:
Expand Down Expand Up @@ -241,6 +255,7 @@ def find_match():
zs_accounts_list = config_obj.pop('zerosum_accounts', {})
(account_name_from, account_name_to) = config_obj.pop('account_name_replace', ('', ''))
tolerance = config_obj.pop('tolerance', DEFAULT_TOLERANCE)
match_metadata = config_obj.pop('match_metadata', False)

new_accounts = set()
zerosum_postings_count = 0
Expand Down Expand Up @@ -275,8 +290,12 @@ def find_match():
# print('Match:', txn.date, match[1].date, match[1].date - txn.date,
# posting.units, posting.meta['lineno'], match[0].meta['lineno'])
match_count += 1
account_replace(txn, posting, target_account)
account_replace(match[1], match[0], target_account)
match_id = ''.join(random.choices(
string.ascii_letters + string.digits, k=20))
account_replace(txn, posting, target_account,
match_id=match_id)
account_replace(match[1], match[0], target_account,
match_id=match_id)
new_accounts.add(target_account)
reprocess = True
break
Expand Down

0 comments on commit 3dcf5fd

Please sign in to comment.