diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py
new file mode 100644
index 0000000000000000000000000000000000000000..2bb7b47af34c929efe9dda753317d94c30c29628
--- /dev/null
+++ b/conservancy_beancount/data.py
@@ -0,0 +1,70 @@
+"""Enhanced Beancount data structures for Conservancy"""
+# Copyright © 2020 Brett Smith
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import collections.abc
+
+from typing import (
+ Iterable,
+ Iterator,
+ Optional,
+)
+
+from .beancount_types import (
+ MetaKey,
+ MetaValue,
+ Posting,
+ Transaction,
+)
+
+class PostingMeta(collections.abc.MutableMapping):
+ def __init__(self, txn: Transaction, index: int, post: Optional[Posting]=None) -> None:
+ if post is None:
+ post = txn.postings[index]
+ self.txn = txn
+ self.index = index
+ self.post = post
+
+ def __iter__(self) -> Iterator[MetaKey]:
+ keys: Iterable[MetaKey]
+ if self.post.meta is None:
+ keys = self.txn.meta.keys()
+ else:
+ keys = frozenset(self.post.meta.keys()).union(self.txn.meta.keys())
+ return iter(keys)
+
+ def __len__(self) -> int:
+ return sum(1 for _ in self)
+
+ def __getitem__(self, key: MetaKey) -> MetaValue:
+ if self.post.meta:
+ try:
+ return self.post.meta[key]
+ except KeyError:
+ pass
+ return self.txn.meta[key]
+
+ def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
+ if self.post.meta is None:
+ self.post = self.post._replace(meta={key: value})
+ self.txn.postings[self.index] = self.post
+ else:
+ self.post.meta[key] = value
+
+ def __delitem__(self, key: MetaKey) -> None:
+ if self.post.meta is None:
+ raise KeyError(key)
+ else:
+ del self.post.meta[key]
diff --git a/tests/test_data_posting_meta.py b/tests/test_data_posting_meta.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f091afd2385fdc1b3e13d6639f24b1845ef5f65
--- /dev/null
+++ b/tests/test_data_posting_meta.py
@@ -0,0 +1,95 @@
+"""Test PostingMeta class"""
+# Copyright © 2020 Brett Smith
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import pytest
+
+from . import testutil
+
+from conservancy_beancount import data
+
+@pytest.fixture
+def simple_txn(index=None, key=None):
+ return testutil.Transaction(note='txn note', postings=[
+ ('Assets:Cash', 5),
+ ('Income:Donations', -5, {'note': 'donation love', 'extra': 'Extra'}),
+ ])
+SIMPLE_TXN_METAKEYS = frozenset(['filename', 'lineno', 'note'])
+
+def test_getitem_transaction(simple_txn):
+ assert data.PostingMeta(simple_txn, 0)['note'] == 'txn note'
+
+def test_getitem_posting(simple_txn):
+ assert data.PostingMeta(simple_txn, 1)['note'] == 'donation love'
+
+def test_getitem_keyerror(simple_txn):
+ with pytest.raises(KeyError):
+ data.PostingMeta(simple_txn, 1)['InvalidMetadata']
+
+def test_setitem_overwrite(simple_txn):
+ meta = data.PostingMeta(simple_txn, 1)
+ meta['note'] = 'overwritten'
+ assert meta['note'] == 'overwritten'
+ assert data.PostingMeta(simple_txn, 0)['note'] == 'txn note'
+
+def test_setitem_over_txn(simple_txn):
+ meta = data.PostingMeta(simple_txn, 0)
+ meta['note'] = 'overwritten'
+ assert meta['note'] == 'overwritten'
+ assert simple_txn.meta['note'] == 'txn note'
+ assert data.PostingMeta(simple_txn, 1)['note'] == 'donation love'
+
+def test_setitem_new_meta(simple_txn):
+ meta = data.PostingMeta(simple_txn, 0)
+ meta['newkey'] = 'testvalue'
+ assert meta['newkey'] == 'testvalue'
+ assert 'newkey' not in simple_txn.meta
+ assert 'newkey' not in simple_txn.postings[1].meta
+
+def test_delitem(simple_txn):
+ meta = data.PostingMeta(simple_txn, 1)
+ del meta['note']
+ assert 'note' not in simple_txn.postings[1].meta
+
+def test_delitem_fails_on_txn_meta(simple_txn):
+ meta = data.PostingMeta(simple_txn, 0)
+ with pytest.raises(KeyError):
+ del meta['note']
+
+def test_len_with_empty_post_meta(simple_txn):
+ assert len(data.PostingMeta(simple_txn, 0)) == len(SIMPLE_TXN_METAKEYS)
+
+def test_len_with_post_meta_over_txn(simple_txn):
+ assert len(data.PostingMeta(simple_txn, 1)) == len(SIMPLE_TXN_METAKEYS) + 1
+
+def test_iter_with_empty_post_meta(simple_txn):
+ assert set(data.PostingMeta(simple_txn, 0)) == SIMPLE_TXN_METAKEYS
+
+def test_iter_with_post_meta_over_txn(simple_txn):
+ assert set(data.PostingMeta(simple_txn, 1)) == SIMPLE_TXN_METAKEYS.union(['extra'])
+
+# The .get() tests are arguably testing the stdlib, but they're short and
+# they confirm that we're using the stdlib as we intend.
+def test_get_with_meta_value(simple_txn):
+ assert data.PostingMeta(simple_txn, 1).get('note') == 'donation love'
+
+def test_get_with_txn_value(simple_txn):
+ assert data.PostingMeta(simple_txn, 0).get('note') == 'txn note'
+
+def test_get_with_no_value(simple_txn):
+ assert data.PostingMeta(simple_txn, 0).get('extra') is None
+
+def test_get_with_specified_default(simple_txn):
+ assert data.PostingMeta(simple_txn, 0).get('extra', 'blank') == 'blank'