budget-sync is now working after adding code to use a config file
authorJacob Lifshay <programmerjake@gmail.com>
Thu, 10 Sep 2020 00:44:14 +0000 (17:44 -0700)
committerJacob Lifshay <programmerjake@gmail.com>
Thu, 10 Sep 2020 00:44:14 +0000 (17:44 -0700)
budget-sync-config.toml [new file with mode: 0644]
src/budget_sync/budget_graph.py
src/budget_sync/config.py
src/budget_sync/main.py
src/budget_sync/test/test_budget_graph.py

diff --git a/budget-sync-config.toml b/budget-sync-config.toml
new file mode 100644 (file)
index 0000000..60ae440
--- /dev/null
@@ -0,0 +1,32 @@
+bugzilla_url = "https://bugs.libre-soc.org"
+
+[people."Jacob R. Lifshay"]
+email = "programmerjake@gmail.com"
+aliases = ["programmerjake", "jacob", "Jacob", "Jacob Lifshay"]
+
+[people."Luke Kenneth Casson Leighton"]
+email = "lkcl@lkcl.net"
+aliases = ["lkcl", "luke", "Luke", "Luke Leighton"]
+
+[people."Samuel A. Falvo II"]
+email = "kc5tja@arrl.net"
+aliases = ["kc5tja", "samuel", "Samuel", "Samuel Falvo II", "sam.falvo"]
+
+[people."Vivek Pandya"]
+email = "vivekvpandya@gmail.com"
+aliases = ["vivekvpandya", "vivek pandya", "vivek", "Vivek"]
+
+[people."Florent Kermarrec"]
+email = "florent@enjoy-digital.fr"
+aliases = ["florent", "Florent"]
+
+[milestones]
+"NLnet.2019.02" = { canonical_bug_id = 191 }
+"NLnet.2019.10.Cells" = { canonical_bug_id = 153 }
+"NLNet.2019.10.Formal" = { canonical_bug_id = 158 }
+"NLNet.2019.10.Standards" = { canonical_bug_id = 174 }
+"NLNet.2019.10.Wishbone" = { canonical_bug_id = 175 }
+"NLNet.2019.Coriolis2" = { canonical_bug_id = 138 }
+"NLNet.2019.Video" = { canonical_bug_id = 137 }
+"NLNet.2019.Vulkan" = { canonical_bug_id = 140 }
+"Future" = { canonical_bug_id = 487 }
\ No newline at end of file
index dd59fd4..c6b5272 100644 (file)
@@ -3,6 +3,7 @@ from bugzilla import Bugzilla
 from typing import Set, Dict, Iterable, Optional, List, Union, Any
 from budget_sync.util import all_bugs
 from budget_sync.money import Money
+from budget_sync.config import Config, Person, Milestone
 from functools import cached_property
 import toml
 import sys
@@ -26,7 +27,8 @@ class BudgetGraphPayeesParseError(BudgetGraphParseError):
         self.msg = msg
 
     def __str__(self):
-        return f"Failed to parse cf_payees_list field of bug #{self.bug_id}: {self.msg}"
+        return f"Failed to parse cf_payees_list field of " \
+            f"bug #{self.bug_id}: {self.msg}"
 
 
 class BudgetGraphLoopError(BudgetGraphBaseError):
@@ -100,6 +102,16 @@ class Payment:
         self.paid = paid
         self.submitted = submitted
 
+    @cached_property
+    def payee(self) -> Person:
+        try:
+            return self.node.graph.config.all_names[self.payee_key]
+        except KeyError:
+            msg = f"unknown payee name: {self.payee_key!r} is not the name " \
+                f"or an alias of any known person"
+            raise BudgetGraphPayeesParseError(self.node.bug.id, msg) \
+                .with_traceback(sys.exc_info()[2])
+
     @property
     def state(self):
         if self.paid is not None:
@@ -177,7 +189,12 @@ class Payment:
                        paid=paid, submitted=submitted)
 
     def __repr__(self):
+        try:
+            payee = f"Person<{self.payee.identifier!r}>"
+        except BudgetGraphBaseError:
+            payee = "<unknown person>"
         return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
+                f"payee={payee}, "
                 f"payee_key={self.payee_key!r}, "
                 f"amount={self.amount}, "
                 f"state={self.state.name}, "
@@ -185,6 +202,16 @@ class Payment:
                 f"submitted={str(self.submitted)})")
 
 
+class BudgetGraphUnknownMilestone(BudgetGraphParseError):
+    def __init__(self, bug_id: int, milestone_str: str):
+        super().__init__(bug_id)
+        self.milestone_str = milestone_str
+
+    def __str__(self):
+        return f"failed to parse cf_nlnet_milestone field of bug " \
+            f"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
+
+
 class Node:
     graph: "BudgetGraph"
     bug: Bug
@@ -194,7 +221,7 @@ class Node:
     budget_including_subtasks: Money
     fixed_budget_excluding_subtasks: Money
     fixed_budget_including_subtasks: Money
-    nlnet_milestone: Optional[str]
+    milestone_str: Optional[str]
 
     def __init__(self, graph: "BudgetGraph", bug: Bug):
         self.graph = graph
@@ -205,9 +232,20 @@ class Node:
         self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks
         self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
         self.fixed_budget_including_subtasks = self.budget_including_subtasks
-        self.nlnet_milestone = bug.cf_nlnet_milestone
-        if self.nlnet_milestone == "---":
-            self.nlnet_milestone = None
+        self.milestone_str = bug.cf_nlnet_milestone
+        if self.milestone_str == "---":
+            self.milestone_str = None
+
+    @cached_property
+    def milestone(self) -> Optional[Milestone]:
+        try:
+            if self.milestone_str is not None:
+                return self.graph.config.milestones[self.milestone_str]
+            return None
+        except KeyError:
+            new_err = BudgetGraphUnknownMilestone(
+                self.bug.id, self.milestone_str)
+            raise new_err.with_traceback(sys.exc_info()[2])
 
     @cached_property
     def payments(self) -> Dict[str, Payment]:
@@ -283,6 +321,10 @@ class Node:
             root = _NodeSimpleReprWrapper(self.root)
         except BudgetGraphLoopError:
             root = "<loop error>"
+        try:
+            milestone = repr(self.milestone)
+        except BudgetGraphBaseError:
+            milestone = "<unknown milestone>"
         immediate_children = []
         for i in self.immediate_children:
             immediate_children.append(_NodeSimpleReprWrapper(i))
@@ -297,7 +339,8 @@ class Node:
                 f"budget_including_subtasks={self.budget_including_subtasks}, "
                 f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
                 f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
-                f"nlnet_milestone={self.nlnet_milestone!r}, "
+                f"milestone_str={self.milestone_str!r}, "
+                f"milestone={milestone}, "
                 f"immediate_children={immediate_children!r}, "
                 f"payments={payments!r}")
 
@@ -380,11 +423,37 @@ class BudgetGraphNegativePayeeMoney(BudgetGraphError):
                 f"bug #{self.bug_id}, payee {self.payee_key!r}")
 
 
+class BudgetGraphDuplicatePayeesForTask(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int, payee1_key: str, payee2_key: str):
+        super().__init__(bug_id, root_bug_id)
+        self.payee1_key = payee1_key
+        self.payee2_key = payee2_key
+
+    def __str__(self):
+        return (f"Budget assigned to multiple aliases of the same person in "
+                f"a single task: bug #{self.bug_id}, budget assigned to both "
+                f"{self.payee1_key!r} and {self.payee2_key!r}")
+
+
+class BudgetGraphIncorrectRootForMilestone(BudgetGraphError):
+    def __init__(self, bug_id: int, milestone: str, milestone_canonical_bug_id: int):
+        super().__init__(bug_id, bug_id)
+        self.milestone = milestone
+        self.milestone_canonical_bug_id = milestone_canonical_bug_id
+
+    def __str__(self):
+        return (f"Bug #{self.bug_id} is not the canonical root bug for "
+                f"assigned milestone {self.milestone!r} but has no parent "
+                f"bug set: the milestone's canonical root bug is "
+                f"#{self.milestone_canonical_bug_id}")
+
+
 class BudgetGraph:
     nodes: Dict[int, Node]
 
-    def __init__(self, bugs: Iterable[Bug]):
+    def __init__(self, bugs: Iterable[Bug], config: Config):
         self.nodes = {}
+        self.config = config
         for bug in bugs:
             self.nodes[bug.id] = Node(self, bug)
         for node in self.nodes.values():
@@ -403,13 +472,27 @@ class BudgetGraph:
 
     def _get_node_errors(self, root: Node, node: Node,
                          errors: List[BudgetGraphBaseError]):
-        if node.nlnet_milestone is None:
+        if node.milestone_str is None:
             if node.budget_including_subtasks != 0 \
                     or node.budget_excluding_subtasks != 0:
                 errors.append(BudgetGraphMoneyWithNoMilestone(
                     node.bug.id, root.bug.id))
 
-        if node.nlnet_milestone != root.nlnet_milestone:
+        try:
+            # check for milestone errors
+            node.milestone
+            if root == node and node.milestone is not None \
+                    and node.milestone.canonical_bug_id != node.bug.id:
+                if node.budget_including_subtasks != 0 \
+                        or node.budget_excluding_subtasks != 0:
+                    errors.append(BudgetGraphIncorrectRootForMilestone(
+                        node.bug.id, node.milestone.identifier,
+                        node.milestone.canonical_bug_id
+                    ))
+        except BudgetGraphBaseError as e:
+            errors.append(e)
+
+        if node.milestone_str != root.milestone_str:
             errors.append(BudgetGraphMilestoneMismatch(
                 node.bug.id, root.bug.id))
 
@@ -423,11 +506,24 @@ class BudgetGraph:
             subtasks_total += child.fixed_budget_including_subtasks
 
         payees_total = Money(0)
+        payee_payments = {}
         for payment in node.payments.values():
             if payment.amount < 0:
                 errors.append(BudgetGraphNegativePayeeMoney(
                     node.bug.id, root.bug.id, payment.payee_key))
             payees_total += payment.amount
+            try:
+                # check for payee errors
+                payment.payee
+                previous_payment = payee_payments.get(payment.payee)
+                if previous_payment is not None:
+                    errors.append(BudgetGraphDuplicatePayeesForTask(
+                        node.bug.id, root.bug.id,
+                        previous_payment.payee_key, payment.payee_key
+                    ))
+                payee_payments[payment.payee] = payment
+            except BudgetGraphBaseError as e:
+                errors.append(e)
 
         def set_including_from_excluding_and_error():
             node.fixed_budget_including_subtasks = \
@@ -559,9 +655,15 @@ class BudgetGraph:
         return errors
 
     @cached_property
-    def payee_keys(self) -> Set[str]:
-        retval = set()
+    def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
+        retval = {}
+        for person in self.config.people.values():
+            milestone_payments = {}
+            for milestone in self.config.milestones.values():
+                milestone_payments[milestone] = []
+            retval[person] = milestone_payments
         for node in self.nodes.values():
-            for payee_key in node.payments.keys():
-                retval.add(payee_key)
+            if node.milestone is not None:
+                for payment in node.payments.values():
+                    retval[payment.payee][node.milestone].append(payment)
         return retval
index 2f0e0f7..1bebca1 100644 (file)
@@ -46,6 +46,12 @@ class Milestone:
         self.identifier = identifier
         self.canonical_bug_id = canonical_bug_id
 
+    def __eq__(self, other):
+        return self.identifier == other.identifier
+
+    def __hash__(self):
+        return hash(self.identifier)
+
     def __repr__(self):
         return f"Milestone(config=..., " \
             f"identifier={self.identifier!r}, " \
index d2e3bf1..412f21c 100644 (file)
@@ -1,6 +1,8 @@
 from bugzilla import Bugzilla
 import logging
+import argparse
 from budget_sync.util import all_bugs
+from budget_sync.config import Config, ConfigParseError
 from budget_sync.budget_graph import BudgetGraph, BudgetGraphBaseError
 
 
@@ -8,10 +10,24 @@ BUGZILLA_URL = "https://bugs.libre-soc.org"
 
 
 def main():
-    logging.info("Using Bugzilla instance at %s", BUGZILLA_URL)
-    bz = Bugzilla(BUGZILLA_URL)
+    parser = argparse.ArgumentParser(
+        description="Check for errors in "
+        "Libre-SOC's style of budget tracking in Bugzilla.")
+    parser.add_argument(
+        "-c, --config", type=argparse.FileType('r'),
+        required=True, help="The path to the configuration TOML file",
+        dest="config", metavar="<path/to/budget-sync-config.toml>")
+    args = parser.parse_args()
+    try:
+        with args.config as config_file:
+            config = Config.from_file(config_file)
+    except (IOError, ConfigParseError) as e:
+        logging.error("Failed to parse config file: %s", e)
+        return
+    logging.info("Using Bugzilla instance at %s", config.bugzilla_url)
+    bz = Bugzilla(config.bugzilla_url)
     logging.debug("Connected to Bugzilla")
-    budget_graph = BudgetGraph(all_bugs(bz))
+    budget_graph = BudgetGraph(all_bugs(bz), config)
     for error in budget_graph.get_errors():
         logging.error("%s", error)
 
index 885a20e..21786a1 100644 (file)
@@ -1,20 +1,33 @@
 from budget_sync.test.mock_bug import MockBug
-from budget_sync.budget_graph import (BudgetGraphLoopError, BudgetGraph,
-                                      Node, BudgetGraphMoneyWithNoMilestone,
-                                      BudgetGraphBaseError,
-                                      BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
-                                      BudgetGraphMoneyMismatchForBudgetIncludingSubtasks,
-                                      BudgetGraphNegativeMoney,
-                                      BudgetGraphMilestoneMismatch,
-                                      BudgetGraphNegativePayeeMoney,
-                                      BudgetGraphPayeesParseError,
-                                      BudgetGraphPayeesMoneyMismatch)
+from budget_sync.config import Config
+from budget_sync.budget_graph import (
+    BudgetGraphLoopError, BudgetGraph, Node, BudgetGraphMoneyWithNoMilestone,
+    BudgetGraphBaseError, BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks,
+    BudgetGraphNegativeMoney, BudgetGraphMilestoneMismatch,
+    BudgetGraphNegativePayeeMoney, BudgetGraphPayeesParseError,
+    BudgetGraphPayeesMoneyMismatch, BudgetGraphUnknownMilestone,
+    BudgetGraphDuplicatePayeesForTask, BudgetGraphIncorrectRootForMilestone)
 from budget_sync.money import Money
 from typing import List, Type
 import unittest
 
 
 class TestErrorFormatting(unittest.TestCase):
+    def test_budget_graph_incorrect_root_for_milestone(self):
+        self.assertEqual(str(BudgetGraphIncorrectRootForMilestone(
+            2, "milestone 1", 1)),
+            "Bug #2 is not the canonical root bug for assigned milestone "
+            "'milestone 1' but has no parent bug set: the milestone's "
+            "canonical root bug is #1")
+
+    def test_budget_graph_duplicate_payees_for_task(self):
+        self.assertEqual(str(BudgetGraphDuplicatePayeesForTask(
+            2, 1, "alias1", "alias2")),
+            "Budget assigned to multiple aliases of the same person in a "
+            "single task: bug #2, budget assigned to both 'alias1' "
+            "and 'alias2'")
+
     def test_budget_graph_loop_error(self):
         self.assertEqual(str(BudgetGraphLoopError([1, 2, 3, 4, 5])),
                          "Detected Loop in Budget Graph: #5 -> #1 "
@@ -33,6 +46,12 @@ class TestErrorFormatting(unittest.TestCase):
                          "milestone assigned to the root bug: descendant "
                          "bug #1, root bug #5")
 
+    def test_budget_graph_unknown_milestone(self):
+        self.assertEqual(str(BudgetGraphUnknownMilestone(
+            123, "fake milestone")),
+            "failed to parse cf_nlnet_milestone field of bug "
+            "#123: unknown milestone: 'fake milestone'")
+
     def test_budget_graph_money_mismatch(self):
         self.assertEqual(str(
             BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -97,30 +116,57 @@ EXAMPLE_PARENT_BUG1 = MockBug(bug_id=1,
                               cf_budget_parent=None,
                               cf_budget="10",
                               cf_total_budget="20",
-                              cf_nlnet_milestone="abc",
+                              cf_nlnet_milestone="milestone 1",
                               cf_payees_list="")
 EXAMPLE_CHILD_BUG2 = MockBug(bug_id=2,
                              cf_budget_parent=1,
                              cf_budget="10",
                              cf_total_budget="10",
-                             cf_nlnet_milestone="abc",
+                             cf_nlnet_milestone="milestone 1",
                              cf_payees_list="")
 
+EXAMPLE_CONFIG = Config.from_str(
+    """
+    bugzilla_url = ""
+    [people."person1"]
+    aliases = ["person1_alias1", "alias1"]
+    [people."person2"]
+    aliases = ["person1_alias2", "alias2", "person 2"]
+    [people."person3"]
+    [milestones]
+    "milestone 1" = { canonical_bug_id = 1 }
+    "milestone 2" = { canonical_bug_id = 2 }
+    """)
+
 
 class TestBudgetGraph(unittest.TestCase):
+    maxDiff = None
+
     def assertErrorTypesMatches(self, errors: List[BudgetGraphBaseError], template: List[Type]):
+        def wrap_type_list(type_list: List[Type]):
+            class TypeWrapper:
+                def __init__(self, t):
+                    self.t = t
+
+                def __repr__(self):
+                    return self.t.__name__
+
+                def __eq__(self, other):
+                    return self.t == other.t
+            return [TypeWrapper(i) for i in type_list]
         error_types = []
         for error in errors:
             error_types.append(type(error))
-        self.assertEqual(error_types, template)
+        self.assertEqual(wrap_type_list(error_types), wrap_type_list(template))
 
     def test_empty(self):
-        bg = BudgetGraph([])
+        bg = BudgetGraph([], EXAMPLE_CONFIG)
         self.assertEqual(len(bg.nodes), 0)
         self.assertEqual(len(bg.roots), 0)
+        self.assertIs(bg.config, EXAMPLE_CONFIG)
 
     def test_single(self):
-        bg = BudgetGraph([EXAMPLE_BUG1])
+        bg = BudgetGraph([EXAMPLE_BUG1], EXAMPLE_CONFIG)
         self.assertEqual(len(bg.nodes), 1)
         node: Node = bg.nodes[1]
         self.assertEqual(bg.roots, {node})
@@ -132,21 +178,23 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(node.immediate_children, set())
         self.assertEqual(node.budget_excluding_subtasks, Money(cents=0))
         self.assertEqual(node.budget_including_subtasks, Money(cents=0))
-        self.assertIsNone(node.nlnet_milestone)
+        self.assertIsNone(node.milestone)
         self.assertEqual(node.payments, {})
 
     def test_loop1(self):
         with self.assertRaises(BudgetGraphLoopError) as cm:
-            BudgetGraph([EXAMPLE_LOOP1_BUG1]).roots
+            BudgetGraph([EXAMPLE_LOOP1_BUG1], EXAMPLE_CONFIG).roots
         self.assertEqual(cm.exception.bug_ids, [1])
 
     def test_loop2(self):
         with self.assertRaises(BudgetGraphLoopError) as cm:
-            BudgetGraph([EXAMPLE_LOOP2_BUG1, EXAMPLE_LOOP2_BUG2]).roots
+            BudgetGraph([EXAMPLE_LOOP2_BUG1, EXAMPLE_LOOP2_BUG2],
+                        EXAMPLE_CONFIG).roots
         self.assertEqual(cm.exception.bug_ids, [2, 1])
 
     def test_parent_child(self):
-        bg = BudgetGraph([EXAMPLE_PARENT_BUG1, EXAMPLE_CHILD_BUG2])
+        bg = BudgetGraph([EXAMPLE_PARENT_BUG1, EXAMPLE_CHILD_BUG2],
+                         EXAMPLE_CONFIG)
         self.assertEqual(len(bg.nodes), 2)
         node1: Node = bg.nodes[1]
         node2: Node = bg.nodes[2]
@@ -163,7 +211,7 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(node1.immediate_children, {node2})
         self.assertEqual(node1.budget_excluding_subtasks, Money(cents=1000))
         self.assertEqual(node1.budget_including_subtasks, Money(cents=2000))
-        self.assertEqual(node1.nlnet_milestone, "abc")
+        self.assertEqual(node1.milestone_str, "milestone 1")
         self.assertEqual(list(node1.children()), [node2])
         self.assertEqual(list(node1.children_breadth_first()), [node2])
         self.assertEqual(node1.payments, {})
@@ -175,7 +223,7 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(node2.immediate_children, set())
         self.assertEqual(node2.budget_excluding_subtasks, Money(cents=1000))
         self.assertEqual(node2.budget_including_subtasks, Money(cents=1000))
-        self.assertEqual(node2.nlnet_milestone, "abc")
+        self.assertEqual(node2.milestone_str, "milestone 1")
         self.assertEqual(node2.payments, {})
 
     def test_children(self):
@@ -222,7 +270,7 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_total_budget="0",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         self.assertEqual(len(bg.nodes), 7)
         node1: Node = bg.nodes[1]
         node2: Node = bg.nodes[2]
@@ -245,7 +293,7 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_total_budget="10",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [
             BudgetGraphMoneyWithNoMilestone,
@@ -259,7 +307,7 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_total_budget="0",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [
             BudgetGraphMoneyWithNoMilestone,
@@ -273,7 +321,7 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_total_budget="10",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [BudgetGraphMoneyWithNoMilestone])
         self.assertEqual(errors[0].bug_id, 1)
@@ -289,15 +337,15 @@ class TestBudgetGraph(unittest.TestCase):
                         cf_budget_parent=None,
                         cf_budget=budget,
                         cf_total_budget=total_budget,
-                        cf_nlnet_milestone="abc",
+                        cf_nlnet_milestone="milestone 1",
                         cf_payees_list=payees_list),
                 MockBug(bug_id=2,
                         cf_budget_parent=1,
                         cf_budget=child_budget,
                         cf_total_budget=child_budget,
-                        cf_nlnet_milestone="abc",
+                        cf_nlnet_milestone="milestone 1",
                         cf_payees_list=""),
-            ])
+            ], EXAMPLE_CONFIG)
             node1: Node = bg.nodes[1]
             errors = bg.get_errors()
             self.assertErrorTypesMatches(errors,
@@ -310,15 +358,15 @@ class TestBudgetGraph(unittest.TestCase):
                         cf_budget=str(node1.fixed_budget_excluding_subtasks),
                         cf_total_budget=str(
                             node1.fixed_budget_including_subtasks),
-                        cf_nlnet_milestone="abc",
+                        cf_nlnet_milestone="milestone 1",
                         cf_payees_list=payees_list),
                 MockBug(bug_id=2,
                         cf_budget_parent=1,
                         cf_budget=child_budget,
                         cf_total_budget=child_budget,
-                        cf_nlnet_milestone="abc",
+                        cf_nlnet_milestone="milestone 1",
                         cf_payees_list=""),
-            ])
+            ], EXAMPLE_CONFIG)
             errors = bg.get_errors()
             self.assertErrorTypesMatches(errors,
                                          expected_fixed_error_types)
@@ -337,7 +385,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="0",
                total_budget="0",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -347,7 +395,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="0",
                total_budget="0",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -357,7 +405,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="0",
                total_budget="0",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -367,7 +415,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="0",
                total_budget="0",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -393,7 +441,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="0",
                total_budget="100",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -403,7 +451,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="0",
                total_budget="100",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -413,7 +461,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="0",
                total_budget="100",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -423,7 +471,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="0",
                total_budget="100",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -438,7 +486,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_errors=[])
         helper(budget="0",
                total_budget="5",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="5",
                expected_errors=[
                    BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(0)),
@@ -446,7 +494,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="0",
                total_budget="5",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[
                    BudgetGraphPayeesMoneyMismatch(1, 1, Money(10), Money(0)),
@@ -470,7 +518,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="10",
                total_budget="0",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -480,7 +528,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="10",
                total_budget="0",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -490,7 +538,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="10",
                total_budget="0",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -498,7 +546,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="10",
                total_budget="0",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -511,7 +559,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_errors=[])
         helper(budget="10",
                total_budget="10",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="0",
                expected_errors=[
                    BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
@@ -519,7 +567,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="10",
                total_budget="10",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="0",
                expected_errors=[])
         helper(budget="10",
@@ -540,7 +588,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="10",
                total_budget="100",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -550,7 +598,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="10",
                total_budget="100",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -560,7 +608,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="10",
                total_budget="100",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="0",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -568,7 +616,7 @@ class TestBudgetGraph(unittest.TestCase):
                ])
         helper(budget="10",
                total_budget="100",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
@@ -581,7 +629,7 @@ class TestBudgetGraph(unittest.TestCase):
                expected_errors=[])
         helper(budget="10",
                total_budget="15",
-               payees_list="a=1",
+               payees_list="person1=1",
                child_budget="5",
                expected_errors=[
                    BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
@@ -589,13 +637,13 @@ class TestBudgetGraph(unittest.TestCase):
                expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
         helper(budget="10",
                total_budget="15",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[])
 
         helper(budget="1",
                total_budget="15",
-               payees_list="a=10",
+               payees_list="person1=10",
                child_budget="5",
                expected_errors=[
                    BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
@@ -608,9 +656,9 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_budget_parent=None,
                     cf_budget="0",
                     cf_total_budget="-10",
-                    cf_nlnet_milestone="abc",
+                    cf_nlnet_milestone="milestone 1",
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [
             BudgetGraphNegativeMoney,
@@ -625,9 +673,9 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_budget_parent=None,
                     cf_budget="-10",
                     cf_total_budget="0",
-                    cf_nlnet_milestone="abc",
+                    cf_nlnet_milestone="milestone 1",
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [
             BudgetGraphNegativeMoney,
@@ -642,9 +690,9 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_budget_parent=None,
                     cf_budget="-10",
                     cf_total_budget="-10",
-                    cf_nlnet_milestone="abc",
+                    cf_nlnet_milestone="milestone 1",
                     cf_payees_list=""),
-        ])
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
                                      [BudgetGraphNegativeMoney])
@@ -652,147 +700,231 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(errors[0].root_bug_id, 1)
 
     def test_payees_parse(self):
-        def check(cf_payees_list, expected_payments):
+        def check(cf_payees_list, error_types, expected_payments):
             bg = BudgetGraph([MockBug(bug_id=1,
                                       cf_budget_parent=None,
                                       cf_budget="0",
                                       cf_total_budget="0",
-                                      cf_nlnet_milestone="abc",
+                                      cf_nlnet_milestone="milestone 1",
                                       cf_payees_list=cf_payees_list),
-                              ])
+                              ], EXAMPLE_CONFIG)
+            self.assertErrorTypesMatches(bg.get_errors(), error_types)
             self.assertEqual(len(bg.nodes), 1)
             node: Node = bg.nodes[1]
             self.assertEqual([str(i) for i in node.payments.values()],
                              expected_payments)
 
-        check("""
-              abc = 123
-              """,
-              ["Payment(node=#1, payee_key='abc', amount=123, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)"])
-        check("""
-              abc = "123"
-              """,
-              ["Payment(node=#1, payee_key='abc', amount=123, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)"])
-        check("""
-              abc = "123.45"
-              """,
-              ["Payment(node=#1, payee_key='abc', amount=123.45, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)"])
-        check("""
-              abc = "123.45"
-              "d e f" = "21.35"
-              """,
-              ["Payment(node=#1, payee_key='abc', amount=123.45, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)",
-               "Payment(node=#1, payee_key='d e f', amount=21.35, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)"])
-        check("""
-              abc = "123.45"
-              # my comments
-              "AAA" = "-21.35"
-              """,
-              ["Payment(node=#1, payee_key='abc', amount=123.45, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)",
-               "Payment(node=#1, payee_key='AAA', amount=-21.35, "
-               + "state=NotYetSubmitted, paid=None, submitted=None)"])
-        check("""
-              "not-an-email@example.com" = "-2345"
-              """,
-              ["Payment(node=#1, payee_key='not-an-email@example.com', "
-               + "amount=-2345, state=NotYetSubmitted, paid=None, "
-               + "submitted=None)"])
-        check("""
-              payee = { amount = 123 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=NotYetSubmitted, paid=None, "
-               + "submitted=None)"])
-        check("""
-              payee = { amount = 123, submitted = 2020-05-01 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Submitted, paid=None, "
-               + "submitted=2020-05-01)"])
-        check("""
-              payee = { amount = 123, submitted = 2020-05-01T00:00:00 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Submitted, paid=None, "
-               + "submitted=2020-05-01 00:00:00)"])
-        check("""
-              payee = { amount = 123, submitted = 2020-05-01T00:00:00Z }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Submitted, paid=None, "
-               + "submitted=2020-05-01 00:00:00+00:00)"])
-        check("""
-              payee = { amount = 123, submitted = 2020-05-01T00:00:00-07:23 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Submitted, paid=None, "
-               + "submitted=2020-05-01 00:00:00-07:23)"])
-        check("""
-              payee = { amount = 123, paid = 2020-05-01 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01, "
-               + "submitted=None)"])
-        check("""
-              payee = { amount = 123, paid = 2020-05-01T00:00:00 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01 00:00:00, "
-               + "submitted=None)"])
-        check("""
-              payee = { amount = 123, paid = 2020-05-01T00:00:00Z }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01 00:00:00+00:00, "
-               + "submitted=None)"])
-        check("""
-              payee = { amount = 123, paid = 2020-05-01T00:00:00-07:23 }
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01 00:00:00-07:23, "
-               + "submitted=None)"])
-        check("""
-              [payee]
-              amount = 123
-              submitted = 2020-05-23
-              paid = 2020-05-01
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01, "
-               + "submitted=2020-05-23)"])
-        check("""
-              [payee]
-              amount = 123
-              submitted = 2020-05-23
-              paid = 2020-05-01T00:00:00
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01 00:00:00, "
-               + "submitted=2020-05-23)"])
-        check("""
-              [payee]
-              amount = 123
-              submitted = 2020-05-23
-              paid = 2020-05-01T00:00:00Z
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01 00:00:00+00:00, "
-               + "submitted=2020-05-23)"])
-        check("""
-              [payee]
-              amount = 123
-              submitted = 2020-05-23
-              paid = 2020-05-01T00:00:00-07:23
-              """,
-              ["Payment(node=#1, payee_key='payee', "
-               + "amount=123, state=Paid, paid=2020-05-01 00:00:00-07:23, "
-               + "submitted=2020-05-23)"])
+        check(
+            """
+            person1 = 123
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, "
+             "payee_key='person1', amount=123, "
+             "state=NotYetSubmitted, paid=None, submitted=None)"])
+        check(
+            """
+            abc = "123"
+            """,
+            [BudgetGraphPayeesParseError,
+             BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=<unknown person>, payee_key='abc', "
+             "amount=123, state=NotYetSubmitted, paid=None, "
+             "submitted=None)"])
+        check(
+            """
+            person1 = "123.45"
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, "
+             "payee_key='person1', amount=123.45, "
+             "state=NotYetSubmitted, paid=None, submitted=None)"])
+        check(
+            """
+            person1 = "123.45"
+            "person 2" = "21.35"
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             'amount=123.45, state=NotYetSubmitted, paid=None, '
+             'submitted=None)',
+             "Payment(node=#1, payee=Person<'person2'>, payee_key='person 2', "
+             'amount=21.35, state=NotYetSubmitted, paid=None, '
+             'submitted=None)'])
+        check(
+            """
+            person1 = "123.45"
+            "d e f" = "21.35"
+            """,
+            [BudgetGraphPayeesParseError,
+             BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             'amount=123.45, state=NotYetSubmitted, paid=None, '
+             'submitted=None)',
+             "Payment(node=#1, payee=<unknown person>, payee_key='d e f', "
+             'amount=21.35, state=NotYetSubmitted, paid=None, '
+             'submitted=None)'])
+        check(
+            """
+            abc = "123.45"
+            # my comments
+            "AAA" = "-21.35"
+            """,
+            [BudgetGraphPayeesParseError,
+             BudgetGraphNegativePayeeMoney,
+             BudgetGraphPayeesParseError,
+             BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=<unknown person>, payee_key='abc', "
+             'amount=123.45, state=NotYetSubmitted, paid=None, '
+             'submitted=None)',
+             "Payment(node=#1, payee=<unknown person>, payee_key='AAA', "
+             'amount=-21.35, state=NotYetSubmitted, paid=None, '
+             'submitted=None)'])
+        check(
+            """
+            "not-an-email@example.com" = "-2345"
+            """,
+            [BudgetGraphNegativePayeeMoney,
+             BudgetGraphPayeesParseError,
+             BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ['Payment(node=#1, payee=<unknown person>, '
+             "payee_key='not-an-email@example.com', amount=-2345, "
+             "state=NotYetSubmitted, paid=None, submitted=None)"])
+        check(
+            """
+            person1 = { amount = 123 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             "amount=123, state=NotYetSubmitted, paid=None, submitted=None)"])
+        check(
+            """
+            person1 = { amount = 123, submitted = 2020-05-01 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Submitted, paid=None, "
+             + "submitted=2020-05-01)"])
+        check(
+            """
+            person1 = { amount = 123, submitted = 2020-05-01T00:00:00 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Submitted, paid=None, "
+             + "submitted=2020-05-01 00:00:00)"])
+        check(
+            """
+            person1 = { amount = 123, submitted = 2020-05-01T00:00:00Z }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Submitted, paid=None, "
+             + "submitted=2020-05-01 00:00:00+00:00)"])
+        check(
+            """
+            person1 = { amount = 123, submitted = 2020-05-01T00:00:00-07:23 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Submitted, paid=None, "
+             + "submitted=2020-05-01 00:00:00-07:23)"])
+        check(
+            """
+            person1 = { amount = 123, paid = 2020-05-01 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01, "
+             + "submitted=None)"])
+        check(
+            """
+            person1 = { amount = 123, paid = 2020-05-01T00:00:00 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01 00:00:00, "
+             + "submitted=None)"])
+        check(
+            """
+            person1 = { amount = 123, paid = 2020-05-01T00:00:00Z }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01 00:00:00+00:00, "
+             + "submitted=None)"])
+        check(
+            """
+            person1 = { amount = 123, paid = 2020-05-01T00:00:00-07:23 }
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01 00:00:00-07:23, "
+             + "submitted=None)"])
+        check(
+            """
+            [person1]
+            amount = 123
+            submitted = 2020-05-23
+            paid = 2020-05-01
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01, "
+             + "submitted=2020-05-23)"])
+        check(
+            """
+            [person1]
+            amount = 123
+            submitted = 2020-05-23
+            paid = 2020-05-01T00:00:00
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01 00:00:00, "
+             + "submitted=2020-05-23)"])
+        check(
+            """
+            [person1]
+            amount = 123
+            submitted = 2020-05-23
+            paid = 2020-05-01T00:00:00Z
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01 00:00:00+00:00, "
+             + "submitted=2020-05-23)"])
+        check(
+            """
+            [person1]
+            amount = 123
+            submitted = 2020-05-23
+            paid = 2020-05-01T00:00:00-07:23
+            """,
+            [BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+             BudgetGraphMoneyMismatchForBudgetIncludingSubtasks],
+            ["Payment(node=#1, payee=Person<'person1'>, payee_key='person1', "
+             + "amount=123, state=Paid, paid=2020-05-01 00:00:00-07:23, "
+             + "submitted=2020-05-23)"])
 
     def test_payees_money_mismatch(self):
         bg = BudgetGraph([
@@ -800,9 +932,9 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
-                    cf_payees_list="payee = 5\npayee2 = 10"),
-        ])
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="person1 = 5\nperson2 = 10"),
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
                                      [BudgetGraphPayeesMoneyMismatch])
@@ -817,9 +949,9 @@ class TestBudgetGraph(unittest.TestCase):
                         cf_budget_parent=None,
                         cf_budget="0",
                         cf_total_budget="0",
-                        cf_nlnet_milestone="abc",
+                        cf_nlnet_milestone="milestone 1",
                         cf_payees_list=cf_payees_list),
-            ]).get_errors()
+            ], EXAMPLE_CONFIG).get_errors()
             self.assertErrorTypesMatches(errors,
                                          [BudgetGraphPayeesParseError])
             self.assertEqual(errors[0].bug_id, 1)
@@ -912,37 +1044,109 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
-                    cf_payees_list="""payee1 = -10"""),
-        ])
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="""person1 = -10"""),
+        ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
                                      [BudgetGraphNegativePayeeMoney,
                                       BudgetGraphPayeesMoneyMismatch])
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
-        self.assertEqual(errors[0].payee_key, "payee1")
+        self.assertEqual(errors[0].payee_key, "person1")
         self.assertEqual(errors[1].bug_id, 1)
         self.assertEqual(errors[1].root_bug_id, 1)
         self.assertEqual(errors[1].payees_total, -10)
 
-    def test_payee_keys(self):
+    def test_duplicate_payments(self):
+        bg = BudgetGraph([
+            MockBug(bug_id=1,
+                    cf_budget_parent=None,
+                    cf_budget="10",
+                    cf_total_budget="10",
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="""
+                    person1 = 5
+                    alias1 = 5
+                    """),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors,
+                                     [BudgetGraphDuplicatePayeesForTask])
+        self.assertEqual(errors[0].bug_id, 1)
+        self.assertEqual(errors[0].root_bug_id, 1)
+        self.assertEqual(errors[0].payee1_key, "person1")
+        self.assertEqual(errors[0].payee2_key, "alias1")
+
+    def test_incorrect_root_for_milestone(self):
+        bg = BudgetGraph([
+            MockBug(bug_id=1,
+                    cf_budget_parent=None,
+                    cf_budget="10",
+                    cf_total_budget="10",
+                    cf_nlnet_milestone="milestone 2",
+                    cf_payees_list=""),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors,
+                                     [BudgetGraphIncorrectRootForMilestone])
+        self.assertEqual(errors[0].bug_id, 1)
+        self.assertEqual(errors[0].root_bug_id, 1)
+        self.assertEqual(errors[0].milestone, "milestone 2")
+        self.assertEqual(errors[0].milestone_canonical_bug_id, 2)
+        bg = BudgetGraph([
+            MockBug(bug_id=1,
+                    cf_budget_parent=None,
+                    cf_budget="0",
+                    cf_total_budget="0",
+                    cf_nlnet_milestone="milestone 2",
+                    cf_payees_list=""),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors, [])
+
+    def test_payments(self):
         bg = BudgetGraph([
             MockBug(bug_id=1,
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
-                    cf_payees_list="payee2 = 3\npayee1 = 7"),
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="person1 = 3\nperson2 = 7"),
             MockBug(bug_id=2,
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="10",
-                    cf_nlnet_milestone="def",
-                    cf_payees_list="""payee3 = 5\npayee2 = 5"""),
-        ])
+                    cf_nlnet_milestone="milestone 2",
+                    cf_payees_list="person3 = 5\nperson2 = 5"),
+        ], EXAMPLE_CONFIG)
         self.assertErrorTypesMatches(bg.get_errors(), [])
-        self.assertEqual(bg.payee_keys, {"payee1", "payee2", "payee3"})
+        person1 = EXAMPLE_CONFIG.people["person1"]
+        person2 = EXAMPLE_CONFIG.people["person2"]
+        person3 = EXAMPLE_CONFIG.people["person3"]
+        milestone1 = EXAMPLE_CONFIG.milestones["milestone 1"]
+        milestone2 = EXAMPLE_CONFIG.milestones["milestone 2"]
+        node1: Node = bg.nodes[1]
+        node2: Node = bg.nodes[2]
+        node1_payment_person1 = node1.payments["person1"]
+        node1_payment_person2 = node1.payments["person2"]
+        node2_payment_person2 = node2.payments["person2"]
+        node2_payment_person3 = node2.payments["person3"]
+        self.assertEqual(bg.payments,
+                         {
+                             person1: {
+                                 milestone1: [node1_payment_person1],
+                                 milestone2: [],
+                             },
+                             person2: {
+                                 milestone1: [node1_payment_person2],
+                                 milestone2: [node2_payment_person2],
+                             },
+                             person3: {
+                                 milestone1: [],
+                                 milestone2: [node2_payment_person3],
+                             },
+                         })
 
 
 if __name__ == "__main__":