a quick hack to display total payments per milestone
[utils.git] / src / budget_sync / budget_graph.py
index 3711385992574bac2b1fdc332ba604e3a1068353..5a9542b3edeb1d3b7026fa40075643a7609d9040 100644 (file)
 from bugzilla.bug import Bug
-from bugzilla import Bugzilla
-from typing import Set, Dict, Iterable, Optional
-from budget_sync.util import all_bugs
+from typing import Set, Dict, Iterable, Optional, List, Union, Any
+from budget_sync.util import BugStatus
 from budget_sync.money import Money
+from budget_sync.config import Config, Person, Milestone
+from functools import cached_property
+import toml
+import sys
+import enum
+from collections import deque
+from datetime import date, time, datetime
+from collections import OrderedDict
+
+class BudgetGraphBaseError(Exception):
+    pass
+
+
+class BudgetGraphParseError(BudgetGraphBaseError):
+    def __init__(self, bug_id: int):
+        self.bug_id = bug_id
+
+
+class BudgetGraphPayeesParseError(BudgetGraphParseError):
+    def __init__(self, bug_id: int, msg: str):
+        super().__init__(bug_id)
+        self.msg = msg
+
+    def __str__(self):
+        return f"Failed to parse cf_payees_list field of " \
+            f"bug #{self.bug_id}: {self.msg}"
+
+
+class BudgetGraphUnknownAssignee(BudgetGraphParseError):
+    def __init__(self, bug_id: int, assignee: str):
+        super().__init__(bug_id)
+        self.assignee = assignee
+
+    def __str__(self):
+        return f"Bug #{self.bug_id} is assigned to an unknown person: " \
+            f"{self.assignee!r}"
+
+
+class BudgetGraphLoopError(BudgetGraphBaseError):
+    def __init__(self, bug_ids: List[int]):
+        self.bug_ids = bug_ids
+
+    def __str__(self):
+        retval = f"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
+        retval += " -> ".join((f"#{i}" for i in self.bug_ids))
+        return retval
+
+
+class _NodeSimpleReprWrapper:
+    def __init__(self, node: "Node"):
+        self.node = node
+
+    def __repr__(self):
+        return f"#{self.node.bug.id}"
+
+    def __lt__(self, other):
+        # for list.sort()
+        return self.node.bug.id < other.node.bug.id
+
+
+class PayeeState(enum.Enum):
+    NotYetSubmitted = "not yet submitted"
+    Submitted = "submitted"
+    Paid = "paid"
+
+
+_Date = Union[date, datetime]
+
+
+def _parse_money_from_toml(value: Any) -> Money:
+    if not isinstance(value, (int, str)):
+        msg = f"monetary amount is not a string or integer " \
+            f"(to use fractional amounts such as 123.45, write " \
+            f"\"123.45\"): {value!r}"
+        raise ValueError(msg)
+    return Money(value)
+
+
+def _parse_date_time_or_none_from_toml(value: Any) -> Optional[_Date]:
+    if value is None or isinstance(value, (date, datetime)):
+        return value
+    elif isinstance(value, time):
+        msg = f"just a time of day by itself is not enough," \
+            f" a date must be included: {str(value)}"
+        raise ValueError(msg)
+    elif isinstance(value, bool):
+        msg = f"invalid date: {str(value).lower()}"
+        raise ValueError(msg)
+    elif isinstance(value, (str, int, float)):
+        msg = f"invalid date: {value!r}"
+        raise ValueError(msg)
+    else:
+        msg = f"invalid date"
+        raise ValueError(msg)
+
+
+class Payment:
+    def __init__(self,
+                 node: "Node",
+                 payee_key: str,
+                 amount: Money,
+                 paid: Optional[_Date],
+                 submitted: Optional[_Date]):
+        self.node = node
+        self.payee_key = payee_key
+        self.amount = amount
+        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:
+            return PayeeState.Paid
+        if self.submitted is not None:
+            return PayeeState.Submitted
+        return PayeeState.NotYetSubmitted
+
+    @staticmethod
+    def _from_toml(node: "Node", payee_key: str, toml_value: Any) -> "Payment":
+        paid = None
+        submitted = None
+        known_keys = ("paid", "submitted", "amount")
+        if isinstance(toml_value, dict):
+            try:
+                amount = toml_value['amount']
+            except KeyError:
+                msg = f"value for key {payee_key!r} is missing the " \
+                    f"`amount` field which is required"
+                raise BudgetGraphPayeesParseError(node.bug.id, msg) \
+                    .with_traceback(sys.exc_info()[2])
+            for k, v in toml_value.items():
+                if k in ("paid", "submitted"):
+                    try:
+                        parsed_value = _parse_date_time_or_none_from_toml(v)
+                    except ValueError as e:
+                        msg = f"failed to parse `{k}` field for" \
+                            f" key {payee_key!r}: {e}"
+                        raise BudgetGraphPayeesParseError(
+                            node.bug.id, msg) \
+                            .with_traceback(sys.exc_info()[2])
+                    if k == "paid":
+                        paid = parsed_value
+                    else:
+                        assert k == "submitted"
+                        submitted = parsed_value
+                if k not in known_keys:
+                    msg = f"value for key {payee_key!r} has an unknown" \
+                        f" field: `{k}`"
+                    raise BudgetGraphPayeesParseError(node.bug.id, msg) \
+                        .with_traceback(sys.exc_info()[2])
+            try:
+                paid = _parse_date_time_or_none_from_toml(
+                    toml_value.get('paid'))
+            except ValueError as e:
+                msg = f"failed to parse `paid` field for" \
+                    f" key {payee_key!r}: {e}"
+                raise BudgetGraphPayeesParseError(
+                    node.bug.id, msg) \
+                    .with_traceback(sys.exc_info()[2])
+            try:
+                submitted = _parse_date_time_or_none_from_toml(
+                    toml_value.get('submitted'))
+            except ValueError as e:
+                msg = f"failed to parse `submitted` field for" \
+                    f" key {payee_key!r}: {e}"
+                raise BudgetGraphPayeesParseError(
+                    node.bug.id, msg) \
+                    .with_traceback(sys.exc_info()[2])
+        elif isinstance(toml_value, (int, str, float)):
+            # float included for better error messages
+            amount = toml_value
+        else:
+            msg = f"value for key {payee_key!r} is invalid -- it should " \
+                f"either be a monetary value or a table"
+            raise BudgetGraphPayeesParseError(node.bug.id, msg)
+        try:
+            amount = _parse_money_from_toml(amount)
+        except ValueError as e:
+            msg = f"failed to parse monetary amount for key {payee_key!r}: {e}"
+            raise BudgetGraphPayeesParseError(
+                node.bug.id, msg) \
+                .with_traceback(sys.exc_info()[2])
+        return Payment(node=node, payee_key=payee_key, amount=amount,
+                       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}, "
+                f"paid={str(self.paid)}, "
+                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 BudgetGraphUnknownStatus(BudgetGraphParseError):
+    def __init__(self, bug_id: int, status_str: str):
+        super().__init__(bug_id)
+        self.status_str = status_str
+
+    def __str__(self):
+        return f"failed to parse status field of bug " \
+            f"#{self.bug_id}: unknown status: {self.status_str!r}"
 
 
 class Node:
-    parent: Optional["Node"]
+    graph: "BudgetGraph"
+    bug: Bug
     parent_id: Optional[int]
+    immediate_children: Set["Node"]
     budget_excluding_subtasks: Money
     budget_including_subtasks: Money
-    nlnet_milestone: Optional[str]
+    fixed_budget_excluding_subtasks: Money
+    fixed_budget_including_subtasks: Money
+    milestone_str: Optional[str]
 
-    def __init__(self, bug: Bug, bug_set: Set[Bug] = None):
+    def __init__(self, graph: "BudgetGraph", bug: Bug):
+        self.graph = graph
         self.bug = bug
-        if bug_set is None:
-            bug_set = {bug}
-        self.bug_set = bug_set
-        self.parent = None
         self.parent_id = getattr(bug, "cf_budget_parent", None)
+        self.immediate_children = set()
         self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
+        self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks
         self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
-        self.nlnet_milestone = bug.cf_nlnet_milestone
-        if self.nlnet_milestone == "---":
-            self.nlnet_milestone = None
+        self.fixed_budget_including_subtasks = self.budget_including_subtasks
+        self.milestone_str = bug.cf_nlnet_milestone
+        if self.milestone_str == "---":
+            self.milestone_str = None
+
+    @property
+    def status(self) -> BugStatus:
+        try:
+            return BugStatus.cast(self.bug.status)
+        except ValueError:
+            new_err = BudgetGraphUnknownStatus(self.bug.id, self.bug.status)
+            raise new_err.with_traceback(sys.exc_info()[2])
+
+    @cached_property
+    def assignee(self) -> Person:
+        try:
+            return self.graph.config.all_names[self.bug.assigned_to]
+        except KeyError:
+            raise BudgetGraphUnknownAssignee(self.bug.id,
+                                             self.bug.assigned_to) \
+                .with_traceback(sys.exc_info()[2])
+
+    @cached_property
+    def bug_url(self) -> str:
+        return f"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
+            f"id={self.bug.id}"
+
+    @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]:
+        try:
+            parsed = toml.loads(self.bug.cf_payees_list)
+        except toml.TomlDecodeError as e:
+            new_err = BudgetGraphPayeesParseError(
+                self.bug.id, f"TOML parse error: {e}")
+            raise new_err.with_traceback(sys.exc_info()[2])
+        retval = OrderedDict()
+        for key, value in parsed.items():
+            if not isinstance(key, str):
+                raise BudgetGraphPayeesParseError(
+                    self.bug.id, f"key is not a string: {key!r}")
+            retval[key] = Payment._from_toml(self, key, value)
+        return retval
+
+    @property
+    def parent(self) -> Optional["Node"]:
+        if self.parent_id is not None:
+            return self.graph.nodes[self.parent_id]
+        return None
+
+    def parents(self) -> Iterable["Node"]:
+        parent = self.parent
+        while parent is not None:
+            yield parent
+            parent = parent.parent
+
+    def _raise_loop_error(self):
+        bug_ids = []
+        for parent in self.parents():
+            bug_ids.append(parent.bug.id)
+            if parent == self:
+                break
+        raise BudgetGraphLoopError(bug_ids)
+
+    @cached_property
+    def root(self) -> "Node":
+        # also checks for loop errors
+        retval = self
+        for parent in self.parents():
+            retval = parent
+            if parent == self:
+                self._raise_loop_error()
+        return retval
+
+    def children(self) -> Iterable["Node"]:
+        def visitor(node: Node) -> Iterable[Node]:
+            for i in node.immediate_children:
+                yield i
+                yield from visitor(i)
+        return visitor(self)
+
+    def children_breadth_first(self) -> Iterable["Node"]:
+        q = deque(self.immediate_children)
+        while True:
+            try:
+                node = q.popleft()
+            except IndexError:
+                return
+            q.extend(node.immediate_children)
+            yield node
+
+    def __eq__(self, other):
+        return self.bug.id == other.bug.id
+
+    def __hash__(self):
+        return self.bug.id
+
+    def __repr__(self):
+        try:
+            root = _NodeSimpleReprWrapper(self.root)
+        except BudgetGraphLoopError:
+            root = "<loop error>"
+        try:
+            milestone = repr(self.milestone)
+        except BudgetGraphBaseError:
+            milestone = "<unknown milestone>"
+        try:
+            status = repr(self.status)
+        except BudgetGraphBaseError:
+            status = f"<unknown status: {self.bug.status!r}>"
+        try:
+            assignee = f"Person<{self.assignee.identifier!r}>"
+        except BudgetGraphBaseError:
+            assignee = f"<unknown assignee: {self.bug.assigned_to!r}>"
+        immediate_children = []
+        for i in self.immediate_children:
+            immediate_children.append(_NodeSimpleReprWrapper(i))
+        immediate_children.sort()
+        parent = f"#{self.parent_id}" if self.parent_id is not None else None
+        payments = list(self.payments.values())
+        return (f"Node(graph=..., "
+                f"id={_NodeSimpleReprWrapper(self)}, "
+                f"root={root}, "
+                f"parent={parent}, "
+                f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
+                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"milestone_str={self.milestone_str!r}, "
+                f"milestone={milestone}, "
+                f"immediate_children={immediate_children!r}, "
+                f"payments={payments!r}, "
+                f"status={status}, "
+                f"assignee={assignee})")
+
+
+class BudgetGraphError(BudgetGraphBaseError):
+    def __init__(self, bug_id: int, root_bug_id: int):
+        self.bug_id = bug_id
+        self.root_bug_id = root_bug_id
+
+
+class BudgetGraphMoneyWithNoMilestone(BudgetGraphError):
+    def __str__(self):
+        return (f"Bug assigned money but without"
+                f" any assigned milestone: #{self.bug_id}")
+
+
+class BudgetGraphMilestoneMismatch(BudgetGraphError):
+    def __str__(self):
+        return (f"Bug's assigned milestone doesn't match the milestone "
+                f"assigned to the root bug: descendant bug"
+                f" #{self.bug_id}, root bug"
+                f" #{self.root_bug_id}")
+
+
+class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int,
+                 expected_budget_excluding_subtasks: Money):
+        super().__init__(bug_id, root_bug_id)
+        self.expected_budget_excluding_subtasks = \
+            expected_budget_excluding_subtasks
+
+    def __str__(self):
+        return (f"Budget assigned to task excluding subtasks "
+                f"(cf_budget field) doesn't match calculated value: "
+                f"bug #{self.bug_id}, calculated value"
+                f" {self.expected_budget_excluding_subtasks}")
+
+
+class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int,
+                 expected_budget_including_subtasks: Money):
+        super().__init__(bug_id, root_bug_id)
+        self.expected_budget_including_subtasks = \
+            expected_budget_including_subtasks
+
+    def __str__(self):
+        return (f"Budget assigned to task including subtasks "
+                f"(cf_total_budget field) doesn't match calculated value: "
+                f"bug #{self.bug_id}, calculated value"
+                f" {self.expected_budget_including_subtasks}")
+
+
+class BudgetGraphNegativeMoney(BudgetGraphError):
+    def __str__(self):
+        return (f"Budget assigned to task is less than zero: "
+                f"bug #{self.bug_id}")
+
+
+class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money,
+                 expected_payees_total: Money):
+        super().__init__(bug_id, root_bug_id)
+        self.payees_total = payees_total
+        self.expected_payees_total = expected_payees_total
+
+    def __str__(self):
+        return (f"Total budget assigned to payees (cf_payees_list) doesn't "
+                f"match expected value: bug #{self.bug_id}, calculated total "
+                f"{self.payees_total}, expected value "
+                f"{self.expected_payees_total}")
+
+
+class BudgetGraphNegativePayeeMoney(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int, payee_key: str):
+        super().__init__(bug_id, root_bug_id)
+        self.payee_key = payee_key
+
+    def __str__(self):
+        return (f"Budget assigned to payee for task is less than zero: "
+                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]):
-        self.nodes = {}
+    def __init__(self, bugs: Iterable[Bug], config: Config):
+        self.nodes = OrderedDict()
+        self.config = config
         for bug in bugs:
-            self.nodes[bug.id] = Node(bug)
-        for bug_id, node in self.nodes.items():
-            # if bug.
-            pass
+            self.nodes[bug.id] = Node(self, bug)
+        for node in self.nodes.values():
+            if node.parent is None:
+                continue
+            node.parent.immediate_children.add(node)
+        self.milestone_payments = OrderedDict()
+        # useful debug prints
+        #for bug in bugs:
+        #    node = self.nodes[bug.id]
+        #    print ("bug added", bug.id, node, node.parent.immediate_children)
+
+    @cached_property
+    def roots(self) -> Set[Node]:
+        roots = set()
+        for node in self.nodes.values():
+            # calling .root also checks for loop errors
+            root = node.root
+            roots.add(root)
+        return roots
+
+    def _get_node_errors(self, root: Node, node: Node,
+                         errors: List[BudgetGraphBaseError]):
+        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))
+
+        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)
+
+        try:
+            # check for status errors
+            node.status
+        except BudgetGraphBaseError as e:
+            errors.append(e)
+
+        try:
+            # check for assignee errors
+            node.assignee
+        except BudgetGraphBaseError as e:
+            errors.append(e)
+
+        if node.milestone_str != root.milestone_str:
+            errors.append(BudgetGraphMilestoneMismatch(
+                node.bug.id, root.bug.id))
+
+        if node.budget_excluding_subtasks < 0 \
+                or node.budget_including_subtasks < 0:
+            errors.append(BudgetGraphNegativeMoney(
+                node.bug.id, root.bug.id))
+
+        childlist = []
+        subtasks_total = Money(0)
+        for child in node.immediate_children:
+            subtasks_total += child.fixed_budget_including_subtasks
+            childlist.append(child.bug.id)
+        # useful debug prints
+        #print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
+        #                        childlist)
+
+        payees_total = Money(0)
+        payee_payments = OrderedDict()
+        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:
+                    # NOT AN ERROR
+                    print ("NOT AN ERROR", BudgetGraphDuplicatePayeesForTask(
+                           node.bug.id, root.bug.id,
+                           previous_payment[-1].payee_key, payment.payee_key))
+                    payee_payments[payment.payee].append(payment)
+                else:
+                    payee_payments[payment.payee] = [payment]
+            except BudgetGraphBaseError as e:
+                errors.append(e)
+
+        def set_including_from_excluding_and_error():
+            node.fixed_budget_including_subtasks = \
+                node.budget_excluding_subtasks + subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_including_subtasks))
+
+        def set_including_from_payees_and_error():
+            node.fixed_budget_including_subtasks = \
+                payees_total + subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_including_subtasks))
+
+        def set_excluding_from_including_and_error():
+            node.fixed_budget_excluding_subtasks = \
+                node.budget_including_subtasks - subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_excluding_subtasks))
+
+        def set_excluding_from_payees_and_error():
+            node.fixed_budget_excluding_subtasks = \
+                payees_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_excluding_subtasks))
+
+        def set_payees_from_including_and_error():
+            fixed_payees_total = \
+                node.budget_including_subtasks - subtasks_total
+            errors.append(BudgetGraphPayeesMoneyMismatch(
+                node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+        def set_payees_from_excluding_and_error():
+            fixed_payees_total = \
+                node.budget_excluding_subtasks
+            errors.append(BudgetGraphPayeesMoneyMismatch(
+                node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+        payees_matches_including = \
+            node.budget_including_subtasks - subtasks_total == payees_total
+        payees_matches_excluding = \
+            node.budget_excluding_subtasks == payees_total
+        including_matches_excluding = \
+            node.budget_including_subtasks - subtasks_total \
+            == node.budget_excluding_subtasks
+
+        if payees_matches_including \
+                and payees_matches_excluding \
+                and including_matches_excluding:
+            pass  # no error
+        elif payees_matches_including:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_excluding
+            assert not including_matches_excluding
+            if node.budget_including_subtasks == 0 and len(node.payments) == 0:
+                set_including_from_excluding_and_error()
+            else:
+                set_excluding_from_including_and_error()
+        elif payees_matches_excluding:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_including
+            assert not including_matches_excluding
+            if node.budget_excluding_subtasks == 0 and len(node.payments) == 0:
+                if node.budget_including_subtasks == 0:
+                    set_including_from_excluding_and_error()
+                else:
+                    set_excluding_from_including_and_error()
+            else:
+                set_including_from_excluding_and_error()
+        elif including_matches_excluding:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_including
+            assert not payees_matches_excluding
+            if len(node.payments) == 0:
+                pass  # no error -- payees is just not set
+            elif node.budget_excluding_subtasks == 0 \
+                    and node.budget_including_subtasks == 0:
+                set_excluding_from_payees_and_error()
+                set_including_from_payees_and_error()
+            else:
+                set_payees_from_excluding_and_error()
+        else:
+            # nothing matches
+            if len(node.payments) == 0:
+                # payees unset -- don't need to set payees
+                if node.budget_including_subtasks == 0:
+                    set_including_from_excluding_and_error()
+                else:
+                    set_excluding_from_including_and_error()
+            elif node.budget_excluding_subtasks == 0 \
+                    and node.budget_including_subtasks == 0:
+                set_excluding_from_payees_and_error()
+                set_including_from_payees_and_error()
+            elif node.budget_excluding_subtasks == 0:
+                set_excluding_from_including_and_error()
+                set_payees_from_including_and_error()
+            elif node.budget_including_subtasks == 0:
+                set_including_from_excluding_and_error()
+                set_payees_from_excluding_and_error()
+            else:
+                set_including_from_excluding_and_error()
+                set_payees_from_excluding_and_error()
+
+    def get_errors(self) -> List[BudgetGraphBaseError]:
+        errors = []
+        try:
+            roots = self.roots
+        except BudgetGraphBaseError as e:
+            errors.append(e)
+            return errors
+
+        for root in roots:
+            try:
+                for child in reversed(list(root.children_breadth_first())):
+                    try:
+                        self._get_node_errors(root, child, errors)
+                    except BudgetGraphBaseError as e:
+                        errors.append(e)
+                self._get_node_errors(root, root, errors)
+            except BudgetGraphBaseError as e:
+                errors.append(e)
+        return errors
+
+    @cached_property
+    def assigned_nodes(self) -> Dict[Person, List[Node]]:
+        retval = {person: [] for person in self.config.people.values()}
+        for node in self.nodes.values():
+            retval[node.assignee].append(node)
+        return retval
+
+    @cached_property
+    def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
+        retval = OrderedDict()
+        for person in self.config.people.values():
+            milestone_payments = OrderedDict()
+            for milestone in self.config.milestones.values():
+                milestone_payments[milestone] = []      # per-person payments
+                self.milestone_payments[milestone] = [] # global payments
+            retval[person] = milestone_payments
+        for node in self.nodes.values():
+            if node.milestone is not None:
+                for payment in node.payments.values():
+                    retval[payment.payee][node.milestone].append(payment)
+                    # add to global payments as well
+                    self.milestone_payments[node.milestone].append(payment)
+        return retval
+
+    def __repr__(self):
+        nodes = [*self.nodes.values()]
+        try:
+            roots = [_NodeSimpleReprWrapper(i) for i in self.roots]
+            roots.sort()
+            roots_str = repr(roots)
+        except BudgetGraphBaseError:
+            roots_str = "<failed>"
+        return f"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"