killbill-aplcache

Rework the NodeInterval class to abstract the custom logic

2/27/2014 8:17:07 PM

Details

diff --git a/invoice/src/main/java/com/ning/billing/invoice/tree/AccountItemTree.java b/invoice/src/main/java/com/ning/billing/invoice/tree/AccountItemTree.java
index 36e7b9a..96bbda4 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/tree/AccountItemTree.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/tree/AccountItemTree.java
@@ -68,11 +68,11 @@ public class AccountItemTree {
     public void build() {
         Preconditions.checkState(!isBuilt);
 
-        if (allExistingItems.size() > 0) {
-            for (InvoiceItem item : allExistingItems) {
+        if (pendingItemAdj.size() > 0) {
+            for (InvoiceItem item : pendingItemAdj) {
                 addExistingItem(item, true);
             }
-            allExistingItems.clear();
+            pendingItemAdj.clear();
         }
         for (SubscriptionItemTree tree : subscriptionItemTree.values()) {
             tree.build();
diff --git a/invoice/src/main/java/com/ning/billing/invoice/tree/ItemsNodeInterval.java b/invoice/src/main/java/com/ning/billing/invoice/tree/ItemsNodeInterval.java
new file mode 100644
index 0000000..df31e26
--- /dev/null
+++ b/invoice/src/main/java/com/ning/billing/invoice/tree/ItemsNodeInterval.java
@@ -0,0 +1,208 @@
+package com.ning.billing.invoice.tree;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.UUID;
+
+import org.joda.time.LocalDate;
+
+import com.google.common.base.Preconditions;
+
+public class ItemsNodeInterval extends NodeInterval {
+
+    private ItemsInterval items;
+
+    public ItemsNodeInterval() {
+        this.items = new ItemsInterval(this);
+    }
+
+    public ItemsNodeInterval(final NodeInterval parent, final Item item) {
+        super(parent, item.getStartDate(), item.getEndDate());
+        this.items = new ItemsInterval(this, item);
+    }
+
+    public ItemsInterval getItems() {
+        return items;
+    }
+
+    public boolean containsItem(final UUID targetId) {
+        return items.containsItem(targetId);
+    }
+
+    /**
+     * <p/>
+     * There is no limit in the depth of the tree,
+     * and the build strategy is to first consider the lowest child for a given period
+     * and go up the tree adding missing interval if needed. For e.g, one of the possible scenario:
+     * <pre>
+     * D1                                                  D2
+     * |---------------------------------------------------|   Plan P1
+     *       D1'             D2'
+     *       |---------------|/////////////////////////////|   Plan P2, REPAIR
+     *
+     *  In that case we will generate:
+     *  [D1,D1') on Plan P1; [D1', D2') on Plan P2, and [D2', D2) repair item
+     *
+     * <pre/>
+     *
+     * In the merge mode, the strategy is different, the tree is fairly shallow
+     * and the goal is to generate the repair items; @see addProposedItem
+     *
+     * @param output result list of built items
+     */
+    public void buildForExistingItems(final List<Item> output) {
+        build(new BuildNodeCallback() {
+            @Override
+            public void buildMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
+                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItems();
+                items.buildForMissingInterval(startDate, endDate, output, false);
+            }
+
+            @Override
+            public void buildNode(final NodeInterval curNode) {
+                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItems();
+                items.buildFromItems(output, false);
+            }
+        });
+    }
+
+    /**
+     * The merge tree is initially constructed by flattening all the existing items and reversing them (CANCEL node).
+     * <p/>
+     * That means that if we were to not merge any new proposed items, we would end up with only those reversed existing
+     * items, and they would all end up repaired-- which is what we want.
+     * <p/>
+     * However, if there are new proposed items, then we look to see if they are children one our existing reverse items
+     * so that we can generate the repair pieces missing. For e.g, below is one scenario among so many:
+     * <p/>
+     * <pre>
+     * D1                                                  D2
+     * |---------------------------------------------------| (existing reversed (CANCEL) item
+     *       D1'             D2'
+     *       |---------------| (proposed same plan)
+     * </pre>
+     * In that case we want to generated a repair for [D1, D1') and [D2',D2)
+     * <p/>
+     * Note that this tree is never very deep, only 3 levels max, with exiting at the first level
+     * and proposed that are the for the exact same plan but for different dates below.
+     *
+     * @param output result list of built items
+     */
+    public void mergeExistingAndProposed(final List<Item> output) {
+        build(new BuildNodeCallback() {
+            @Override
+            public void buildMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
+                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItems();
+                items.buildForMissingInterval(startDate, endDate, output, true);
+            }
+
+            @Override
+            public void buildNode(final NodeInterval curNode) {
+                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItems();
+                items.buildFromItems(output, true);
+            }
+        });
+    }
+
+    /**
+     * Add existing item into the tree.
+     *
+     * @param newNode an existing item
+     */
+    public boolean addExistingItem(final ItemsNodeInterval newNode) {
+
+        return addNode(newNode, new AddNodeCallback() {
+            @Override
+            public boolean onExistingNode(final NodeInterval existingNode) {
+                if (!existingNode.isRoot() && newNode.getStart().compareTo(existingNode.getStart()) == 0 && newNode.getEnd().compareTo(existingNode.getEnd()) == 0) {
+                    final Item item = newNode.getItems().getItems().get(0);
+                    final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItems();
+                    existingOrNewNodeItems.insertSortedItem(item);
+                }
+                // There is no new node added but instead we just populated the list of items for the already existing node.
+                return false;
+            }
+
+            @Override
+            public boolean shouldInsertNode(final NodeInterval insertionNode) {
+                // Always want to insert node in the tree when we find the right place.
+                return true;
+            }
+        });
+    }
+
+    /**
+     * Add proposed item into the (flattened and reversed) tree.
+     *
+     * @param newNode a new proposed item
+     * @return true if the item was merged and will trigger a repair or false if the proposed item should be kept as such
+     *         and no repair generated.
+     */
+    public boolean addProposedItem(final ItemsNodeInterval newNode) {
+
+        return addNode(newNode, new AddNodeCallback() {
+            @Override
+            public boolean onExistingNode(final NodeInterval existingNode) {
+                if (!shouldInsertNode(existingNode)) {
+                    return false;
+                }
+
+                Preconditions.checkState(newNode.getStart().compareTo(existingNode.getStart()) == 0 && newNode.getEnd().compareTo(existingNode.getEnd()) == 0);
+                final Item item = newNode.getItems().getItems().get(0);
+                final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItems();
+                existingOrNewNodeItems.cancelItems(item);
+                // In the merge logic, whether we really insert the node or find an existing node on which to insert items should be seen
+                // as an insertion (so as to avoid keeping that proposed item, see how return value of addProposedItem is used)
+                return true;
+            }
+
+            @Override
+            public boolean shouldInsertNode(final NodeInterval insertionNode) {
+                // The root level is solely for the reversed existing items. If there is a new node that does not fit below the level
+                // of reversed existing items, we want to return false and keep it outside of the tree. It should be 'kept as such'.
+                if (insertionNode.isRoot()) {
+                    return false;
+                }
+
+                final ItemsInterval insertionNodeItems = ((ItemsNodeInterval) insertionNode).getItems();
+                Preconditions.checkState(insertionNodeItems.getItems().size() == 1, "Expected existing node to have only one item");
+                final Item insertionNodeItem = insertionNodeItems.getItems().get(0);
+                final Item newNodeItem = newNode.getItems().getItems().get(0);
+
+                // If we receive a new proposed that is the same kind as the reversed existing we want to insert it to generate
+                // a piece of repair
+                if (insertionNodeItem.isSameKind(newNodeItem)) {
+                    return true;
+                    // If not, then keep the proposed outside of the tree.
+                } else {
+                    return false;
+                }
+            }
+        });
+    }
+
+    /**
+     * Add the adjustment amount on the item specified by the targetId.
+     *
+     * @param adjustementDate date of the adjustment
+     * @param amount          amount of the adjustment
+     * @param targetId        item that has been adjusted
+     */
+    public void addAdjustment(final LocalDate adjustementDate, final BigDecimal amount, final UUID targetId) {
+        // TODO we should really be using findNode(adjustementDate, new SearchCallback() instead but wrong dates in test
+        // creates test panic.
+        final NodeInterval node = findNode(new SearchCallback() {
+            @Override
+            public boolean isMatch(final NodeInterval curNode) {
+                return ((ItemsNodeInterval) curNode).getItems().containsItem(targetId);
+            }
+        });
+        Preconditions.checkNotNull(node, "Cannot add adjustement for item = " + targetId + ", date = " + adjustementDate);
+        ((ItemsNodeInterval) node).setAdjustment(amount.negate(), targetId);
+    }
+
+    protected void setAdjustment(final BigDecimal amount, final UUID linkedId) {
+        items.setAdjustment(amount, linkedId);
+    }
+
+}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/tree/NodeInterval.java b/invoice/src/main/java/com/ning/billing/invoice/tree/NodeInterval.java
index f6dcaab..0f90d62 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/tree/NodeInterval.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/tree/NodeInterval.java
@@ -16,66 +16,45 @@
 
 package com.ning.billing.invoice.tree;
 
-import java.math.BigDecimal;
 import java.util.List;
-import java.util.UUID;
 
 import org.joda.time.LocalDate;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.Lists;
 
-public class NodeInterval {
+public abstract class NodeInterval {
 
-    private LocalDate start;
-    private LocalDate end;
-    private ItemsInterval items;
+    protected NodeInterval parent;
+    protected NodeInterval leftChild;
+    protected NodeInterval rightSibling;
 
-    private NodeInterval parent;
-    private NodeInterval leftChild;
-    private NodeInterval rightSibling;
+    protected LocalDate start;
+    protected LocalDate end;
 
     public NodeInterval() {
-        this.items = new ItemsInterval(this);
+        this(null, null, null);
     }
 
-    public NodeInterval(final NodeInterval parent, final Item item) {
-        this.start = item.getStartDate();
-        this.end = item.getEndDate();
-        this.items = new ItemsInterval(this, item);
+    public NodeInterval(final NodeInterval parent, final LocalDate startDate, final LocalDate endDate) {
+        this.start = startDate;
+        this.end = endDate;
         this.parent = parent;
         this.leftChild = null;
         this.rightSibling = null;
     }
 
     /**
-     * Build the output list from the elements in the tree.
-     * <p/>
-     * In the simple mode, mergeMode = false, there is no limit in the depth of the tree,
-     * and the build strategy is to first consider the lowest child for a given period
-     * and go up the tree adding missing interval if needed. For e.g, one of the possible scenario:
-     * <pre>
-     * D1                                                  D2
-     * |---------------------------------------------------|   Plan P1
-     *       D1'             D2'
-     *       |---------------|/////////////////////////////|   Plan P2, REPAIR
+     * Build the tree by calling the callback on the last node in the tree or remaining part with no children.
      *
-     *  In that case we will generate:
-     *  [D1,D1') on Plan P1; [D1', D2') on Plan P2, and [D2', D2) repair item
-     *
-     * <pre/>
-     *
-     * In the merge mode, the strategy is different, the tree is fairly shallow
-     * and the goal is to generate the repair items; @see mergeProposedItem
-     *
-     * @param output    result list of items
-     * @param mergeMode mode used to produce output list
+     * @param callback the callback which perform the build logic.
      */
-    public void build(final List<Item> output, final boolean mergeMode) {
+    public void build(final BuildNodeCallback callback) {
+
+        Preconditions.checkNotNull(callback);
 
-        // There is no sub-interval, just add our own items.
         if (leftChild == null) {
-            items.buildFromItems(output, mergeMode);
+            callback.buildNode(this);
             return;
         }
 
@@ -83,151 +62,103 @@ public class NodeInterval {
         NodeInterval curChild = leftChild;
         while (curChild != null) {
             if (curChild.getStart().compareTo(curDate) > 0) {
-                               items.buildForMissingInterval(curDate, curChild.getStart(), output, mergeMode);
+                callback.buildMissingInterval(this, curDate, curChild.getStart());
             }
-                      curChild.build(output, mergeMode);
-                        curDate = curChild.getEnd();
+            curChild.build(callback);
+            curDate = curChild.getEnd();
             curChild = curChild.getRightSibling();
         }
 
         // Finally if there is a hole at the end, we build the missing piece from ourself
         if (curDate.compareTo(end) < 0) {
-            items.buildForMissingInterval(curDate, end, output, mergeMode);
+            callback.buildMissingInterval(this, curDate, end);
         }
     }
 
     /**
-     * The merge tree is initially constructed by flattening all the existing items and reversing them (CANCEL node).
-     * That means that if we were to not merge any new proposed items, we would end up with only those reversed existing
-     * items, and they would all end up repaired-- which is what we want.
-     * <p/>
-     * However, if there are new proposed items, then we look to see if they are children one our existing reverse items
-     * so that we can generate the repair pieces missing. For e.g, below is one scenario among so many:
-     * <p/>
-     * <pre>
-     * D1                                                  D2
-     * |---------------------------------------------------| (existing reversed (CANCEL) item
-     *       D1'             D2'
-     *       |---------------| (proposed same plan)
-     * </pre>
-     * In that case we want to generated a repair for [D1, D1') and [D2',D2)
-     * <p/>
-     * Note that this tree is never very deep, only 3 levels max, with exiting at the first level
-     * and proposed that are the for the exact same plan but for different dates below.
+     * Add a new node in the tree.
      *
-     * @param newNode a new proposed item
-     * @return true if the item was merged and will trigger a repair or false if the proposed item should be kept as such
-     *         and no repair generated.
+     * @param newNode  the node to be added
+     * @param callback the callback that will allow to specify insertion and return behavior.
+     * @return true if node was inserted. Note that this is driven by the callback, this method is generic
+     *         and specific behavior can be tuned through specific callbacks.
      */
-    public boolean mergeProposedItem(final NodeInterval newNode) {
+    public boolean addNode(final NodeInterval newNode, final AddNodeCallback callback) {
 
-        Preconditions.checkState(newNode.getItems().size() == 1, "Expected new node to have only one item");
-        final Item newNodeItem = newNode.getItems().get(0);
+        Preconditions.checkNotNull(newNode);
+        Preconditions.checkNotNull(callback);
 
-        if (!isRoot() && newNodeItem.getStartDate().compareTo(start) == 0 && newNodeItem.getEndDate().compareTo(end) == 0) {
-            items.cancelItems(newNodeItem);
-            return true;
+        if (!isRoot() && newNode.getStart().compareTo(start) == 0 && newNode.getEnd().compareTo(end) == 0) {
+            return callback.onExistingNode(this);
         }
+
         computeRootInterval(newNode);
 
+        newNode.parent = this;
         if (leftChild == null) {
-            // There is no existing items, only new proposed one, nothing to add in that merge tree
-            if (isRoot()) {
-                return false;
-            } else {
-                // Proposed item is the first child of an existing item with the same product info.
-                newNode.parent = this;
+            if (callback.shouldInsertNode(this)) {
                 leftChild = newNode;
                 return true;
-
+            } else {
+                return false;
             }
         }
 
         NodeInterval prevChild = null;
         NodeInterval curChild = leftChild;
-        do {
-            if (curChild.isItemContained(newNodeItem)) {
-                final Item existingNodeItem = curChild.getItems().get(0);
+        while (curChild != null) {
+            if (curChild.isItemContained(newNode)) {
+                return curChild.addNode(newNode, callback);
+            }
 
-                Preconditions.checkState(curChild.getItems().size() == 1, "Expected existing node to have only one item");
-                if (existingNodeItem.isSameKind(newNodeItem)) {
-                    // Proposed item has same product info than parent and is contained so insert it at the right place in the tree
-                    curChild.mergeProposedItem(newNode);
+            if (curChild.isItemOverlap(newNode)) {
+                if (callback.shouldInsertNode(this)) {
+                    rebalance(newNode);
                     return true;
                 } else {
                     return false;
                 }
             }
 
-            if (newNodeItem.getStartDate().compareTo(curChild.getStart()) < 0) {
-                newNode.parent = this;
-                newNode.rightSibling = curChild;
-                if (prevChild == null) {
-                    leftChild = newNode;
+            if (newNode.getStart().compareTo(curChild.getStart()) < 0) {
+                if (callback.shouldInsertNode(this)) {
+                    newNode.rightSibling = curChild;
+                    if (prevChild == null) {
+                        leftChild = newNode;
+                    } else {
+                        prevChild.rightSibling = newNode;
+                    }
+                    return true;
                 } else {
-                    prevChild.rightSibling = newNode;
+                    return false;
                 }
-                return true;
             }
-
             prevChild = curChild;
             curChild = curChild.rightSibling;
-        } while (curChild != null);
+        }
 
-        if (isRoot()) {
-            // The new proposed item spans over a new interval, nothing to add in the merge tree
-            return false;
-        } else {
-            newNode.parent = this;
+        if (callback.shouldInsertNode(this)) {
             prevChild.rightSibling = newNode;
             return true;
+        } else {
+            return false;
         }
     }
 
-    /**
-     * Add an existing item in the tree of items.
-     *
-     * @param newNode new existing item to be added
-     */
-    public void addExistingItem(final NodeInterval newNode) {
-        final Item item = newNode.getItems().get(0);
-        if (!isRoot() && item.getStartDate().compareTo(start) == 0 && item.getEndDate().compareTo(end) == 0) {
-            items.insertSortedItem(item);
-            return;
-        }
-        computeRootInterval(newNode);
-        addNode(newNode);
-    }
-
-    /**
-     * Add the adjustment amount on the item specified by the targetId.
-     *
-     * @param adjustementDate date of the adjustment
-     * @param amount amount of the adjustment
-     * @param targetId item that has been adjusted
-     */
-    public void addAdjustment(final LocalDate adjustementDate, final BigDecimal amount, final UUID targetId) {
-        NodeInterval node = findNode(adjustementDate, targetId);
-        Preconditions.checkNotNull(node, "Cannot add adjustement for item = " + targetId + ", date = " + adjustementDate);
-        node.setAdjustment(amount.negate(), targetId);
-    }
-
-    public boolean isItemContained(final Item item) {
-        return (item.getStartDate().compareTo(start) >= 0 &&
-                item.getStartDate().compareTo(end) <= 0 &&
-                item.getEndDate().compareTo(start) >= 0 &&
-                item.getEndDate().compareTo(end) <= 0);
+    public boolean isItemContained(final NodeInterval newNode) {
+        return (newNode.getStart().compareTo(start) >= 0 &&
+                newNode.getStart().compareTo(end) <= 0 &&
+                newNode.getEnd().compareTo(start) >= 0 &&
+                newNode.getEnd().compareTo(end) <= 0);
     }
 
-    public boolean isItemOverlap(final Item item) {
-        return ((item.getStartDate().compareTo(start) < 0 &&
-                 item.getEndDate().compareTo(end) >= 0) ||
-                (item.getStartDate().compareTo(start) <= 0 &&
-                 item.getEndDate().compareTo(end) > 0));
+    public boolean isItemOverlap(final NodeInterval newNode) {
+        return ((newNode.getStart().compareTo(start) < 0 &&
+                 newNode.getEnd().compareTo(end) >= 0) ||
+                (newNode.getStart().compareTo(start) <= 0 &&
+                 newNode.getEnd().compareTo(end) > 0));
     }
 
-
-
     public boolean isRoot() {
         return parent == null;
     }
@@ -252,7 +183,6 @@ public class NodeInterval {
         return rightSibling;
     }
 
-
     public int getNbChildren() {
         int result = 0;
         NodeInterval curChild = leftChild;
@@ -263,52 +193,6 @@ public class NodeInterval {
         return result;
     }
 
-    public List<Item> getItems() {
-        return items.getItems();
-    }
-
-    public boolean containsItem(final UUID targetId) {
-        return items.containsItem(targetId);
-    }
-
-    private void addNode(final NodeInterval newNode) {
-
-        newNode.parent = this;
-        final Item item = newNode.getItems().get(0);
-        if (leftChild == null) {
-            leftChild = newNode;
-            return;
-        }
-
-        NodeInterval prevChild = null;
-        NodeInterval curChild = leftChild;
-        while (curChild != null) {
-            if (curChild.isItemContained(item)) {
-                curChild.addExistingItem(newNode);
-                return;
-            }
-
-            if (curChild.isItemOverlap(item)) {
-                rebalance(newNode);
-                return;
-            }
-
-            if (item.getStartDate().compareTo(curChild.getStart()) < 0) {
-                newNode.rightSibling = curChild;
-                if (prevChild == null) {
-                    leftChild = newNode;
-                } else {
-                    prevChild.rightSibling = newNode;
-                }
-                return;
-            }
-            prevChild = curChild;
-            curChild = curChild.rightSibling;
-        }
-
-        prevChild.rightSibling = newNode;
-    }
-
     /**
      * Since items may be added out of order, there is no guarantee that we don't suddenly have a new node
      * whose interval emcompasses cuurent node(s). In which case we need to rebalance the tree.
@@ -317,13 +201,11 @@ public class NodeInterval {
      */
     private void rebalance(final NodeInterval newNode) {
 
-        final Item item = newNode.getItems().get(0);
-
         NodeInterval prevRebalanced = null;
         NodeInterval curChild = leftChild;
         List<NodeInterval> toBeRebalanced = Lists.newLinkedList();
         do {
-            if (curChild.isItemOverlap(item)) {
+            if (curChild.isItemOverlap(newNode)) {
                 toBeRebalanced.add(curChild);
             } else {
                 if (toBeRebalanced.size() > 0) {
@@ -364,28 +246,31 @@ public class NodeInterval {
         this.end = (end == null || end.compareTo(newNode.getEnd()) < 0) ? newNode.getEnd() : end;
     }
 
-    private void setAdjustment(final BigDecimal amount, final UUID linkedId) {
-        items.setAdjustment(amount, linkedId);
-    }
+    /**
+     * Return the first node satisfying the date and match callback.
+     *
+     * @param targetDate target date for possible match nodes whose interval comprises that date
+     * @param callback   custom logic to decide if a given node is a match
+     * @return the found node or null if there is nothing.
+     */
+    public NodeInterval findNode(final LocalDate targetDate, final SearchCallback callback) {
 
-    private NodeInterval findNode(final LocalDate date, final UUID targetItemId) {
-        Preconditions.checkState(isRoot(), "findNode can only be called from root");
-        return findNodeRecursively2(this, date, targetItemId);
-    }
+        Preconditions.checkNotNull(callback);
+        Preconditions.checkNotNull(targetDate);
 
-    // TODO That method should be use instead of findNodeRecursively2 to search the node more effectively using the time
-    // but unfortunately that fails because of our test that use the wrong date when doing adjustments.
-    private NodeInterval findNodeRecursively(final NodeInterval curNode, final LocalDate date, final UUID targetItemId) {
-        if (date.compareTo(curNode.getStart()) < 0 || date.compareTo(curNode.getEnd()) > 0) {
+        if (targetDate.compareTo(getStart()) < 0 || targetDate.compareTo(getEnd()) > 0) {
             return null;
         }
-        NodeInterval curChild = curNode.getLeftChild();
+
+        NodeInterval curChild = leftChild;
         while (curChild != null) {
-            if (curChild.getStart().compareTo(date) <= 0 && curChild.getEnd().compareTo(date) >= 0) {
-                if (curChild.containsItem(targetItemId)) {
+            if (curChild.getStart().compareTo(targetDate) <= 0 && curChild.getEnd().compareTo(targetDate) >= 0) {
+                if (callback.isMatch(curChild)) {
                     return curChild;
-                } else {
-                    return findNodeRecursively(curChild, date, targetItemId);
+                }
+                NodeInterval result = findNode(targetDate, callback);
+                if (result != null) {
+                    return result;
                 }
             }
             curChild = curChild.getRightSibling();
@@ -393,15 +278,22 @@ public class NodeInterval {
         return null;
     }
 
-    private NodeInterval findNodeRecursively2(final NodeInterval curNode, final LocalDate date, final UUID targetItemId) {
+    /**
+     * Return the first node satisfying the date and match callback.
+     *
+     * @param callback custom logic to decide if a given node is a match
+     * @return the found node or null if there is nothing.
+     */
+    public NodeInterval findNode(final SearchCallback callback) {
 
-        if (!curNode.isRoot() && curNode.containsItem(targetItemId)) {
-            return curNode;
+        Preconditions.checkNotNull(callback);
+        if (/*!isRoot() && */callback.isMatch(this)) {
+            return this;
         }
 
-        NodeInterval curChild = curNode.getLeftChild();
+        NodeInterval curChild = leftChild;
         while (curChild != null) {
-            final NodeInterval result = findNodeRecursively2(curChild, date, targetItemId);
+            final NodeInterval result = curChild.findNode(callback);
             if (result != null) {
                 return result;
             }
@@ -409,4 +301,57 @@ public class NodeInterval {
         }
         return null;
     }
+
+    /**
+     * Provides custom logic for the search.
+     */
+    public interface SearchCallback {
+
+        boolean isMatch(NodeInterval curNode);
+    }
+
+    /**
+     * Provides the custom logic for when building resulting state from the tree.
+     */
+    public interface BuildNodeCallback {
+
+        /**
+         * Called when we hit a missing interval where there is no child.
+         *
+         * @param curNode   current node
+         * @param startDate startDate of the new interval to build
+         * @param endDate   endDate of the new interval to build
+         */
+        public void buildMissingInterval(NodeInterval curNode, LocalDate startDate, LocalDate endDate);
+
+        /**
+         * Called when we hit a node with no children
+         *
+         * @param curNode current node
+         */
+        public void buildNode(NodeInterval curNode);
+    }
+
+    /**
+     * Provides the custom logic for when adding nodes in the tree.
+     */
+    public interface AddNodeCallback {
+
+        /**
+         * Called when trying to insert a new node in the tree but there is already
+         * such a node for that same interval.
+         *
+         * @param existingNode
+         * @return this is the return value for the addNode method
+         */
+        public boolean onExistingNode(final NodeInterval existingNode);
+
+        /**
+         * Called prior to insert the new node in the tree
+         *
+         * @param insertionNode the parent node where this new node would be inserted
+         * @return true if addNode should proceed with the insertion and false otherwise
+         */
+        public boolean shouldInsertNode(final NodeInterval insertionNode);
+    }
 }
diff --git a/invoice/src/main/java/com/ning/billing/invoice/tree/SubscriptionItemTree.java b/invoice/src/main/java/com/ning/billing/invoice/tree/SubscriptionItemTree.java
index 0c4a250..b04f343 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/tree/SubscriptionItemTree.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/tree/SubscriptionItemTree.java
@@ -43,7 +43,7 @@ public class SubscriptionItemTree {
     private boolean isBuilt;
 
     private final UUID subscriptionId;
-    private NodeInterval root;
+    private ItemsNodeInterval root;
 
     private List<Item> items;
 
@@ -71,7 +71,7 @@ public class SubscriptionItemTree {
 
     public SubscriptionItemTree(final UUID subscriptionId) {
         this.subscriptionId = subscriptionId;
-        this.root = new NodeInterval();
+        this.root = new ItemsNodeInterval();
         this.items = new LinkedList<Item>();
         this.existingFixedItems = new LinkedList<InvoiceItem>();
         this.remainingFixedItems = new LinkedList<InvoiceItem>();
@@ -88,7 +88,7 @@ public class SubscriptionItemTree {
             root.addAdjustment(item.getStartDate(), item.getAmount(), item.getLinkedItemId());
         }
         pendingItemAdj.clear();
-        root.build(items, false);
+        root.buildForExistingItems(items);
         isBuilt = true;
     }
 
@@ -103,10 +103,10 @@ public class SubscriptionItemTree {
         if (!isBuilt) {
             build();
         }
-        root = new NodeInterval();
+        root = new ItemsNodeInterval();
         for (Item item : items) {
             Preconditions.checkState(item.getAction() == ItemAction.ADD);
-            root.addExistingItem(new NodeInterval(root, new Item(item, reverse ? ItemAction.CANCEL : ItemAction.ADD)));
+            root.addExistingItem(new ItemsNodeInterval(root, new Item(item, reverse ? ItemAction.CANCEL : ItemAction.ADD)));
         }
         items.clear();
         isBuilt = false;
@@ -114,7 +114,7 @@ public class SubscriptionItemTree {
 
     public void buildForMerge() {
         Preconditions.checkState(!isBuilt);
-        root.build(items, true);
+        root.mergeExistingAndProposed(items);
         isBuilt = true;
     }
 
@@ -128,11 +128,11 @@ public class SubscriptionItemTree {
         Preconditions.checkState(!isBuilt);
         switch (invoiceItem.getInvoiceItemType()) {
             case RECURRING:
-                root.addExistingItem(new NodeInterval(root, new Item(invoiceItem, ItemAction.ADD)));
+                root.addExistingItem(new ItemsNodeInterval(root, new Item(invoiceItem, ItemAction.ADD)));
                 break;
 
             case REPAIR_ADJ:
-                root.addExistingItem(new NodeInterval(root, new Item(invoiceItem, ItemAction.CANCEL)));
+                root.addExistingItem(new ItemsNodeInterval(root, new Item(invoiceItem, ItemAction.CANCEL)));
                 break;
 
             case FIXED:
@@ -158,7 +158,7 @@ public class SubscriptionItemTree {
         Preconditions.checkState(!isBuilt);
         switch (invoiceItem.getInvoiceItemType()) {
             case RECURRING:
-                final boolean result = root.mergeProposedItem(new NodeInterval(root, new Item(invoiceItem, ItemAction.ADD)));
+                final boolean result = root.addProposedItem(new ItemsNodeInterval(root, new Item(invoiceItem, ItemAction.ADD)));
                 if (!result) {
                     items.add(new Item(invoiceItem, ItemAction.ADD));
                 }
diff --git a/invoice/src/test/java/com/ning/billing/invoice/tree/TestNodeInterval.java b/invoice/src/test/java/com/ning/billing/invoice/tree/TestNodeInterval.java
index 530f12f..8c1d3f2 100644
--- a/invoice/src/test/java/com/ning/billing/invoice/tree/TestNodeInterval.java
+++ b/invoice/src/test/java/com/ning/billing/invoice/tree/TestNodeInterval.java
@@ -20,46 +20,65 @@ import java.math.BigDecimal;
 import java.util.UUID;
 
 import org.joda.time.LocalDate;
-import org.testng.Assert;
 import org.testng.annotations.Test;
 
 import com.ning.billing.catalog.api.Currency;
 import com.ning.billing.invoice.api.InvoiceItem;
 import com.ning.billing.invoice.model.RecurringInvoiceItem;
 import com.ning.billing.invoice.tree.Item.ItemAction;
+import com.ning.billing.invoice.tree.NodeInterval.AddNodeCallback;
 
 import static org.testng.Assert.assertEquals;
 
 public class TestNodeInterval /* extends InvoiceTestSuiteNoDB  */ {
 
-    private final UUID invoiceId = UUID.randomUUID();
-    private final UUID accountId = UUID.randomUUID();
-    private final UUID subscriptionId = UUID.randomUUID();
-    private final UUID bundleId = UUID.randomUUID();
-    private final String planName = "my-plan";
-    private final String phaseName = "my-phase";
-    private final Currency currency = Currency.USD;
+
+    private AddNodeCallback CALLBACK = new DummyAddNodeCallback();
+
+    public class DummyNodeInterval extends NodeInterval {
+
+        public DummyNodeInterval() {
+        }
+
+        public DummyNodeInterval(final NodeInterval parent, final LocalDate startDate, final LocalDate endDate) {
+            super(parent, startDate, endDate);
+        }
+
+    }
+
+    public class DummyAddNodeCallback implements AddNodeCallback {
+
+        @Override
+        public boolean onExistingNode(final NodeInterval existingNode) {
+            return false;
+        }
+
+        @Override
+        public boolean shouldInsertNode(final NodeInterval insertionNode) {
+            return true;
+        }
+    }
 
     @Test(groups = "fast")
     public void testAddExistingItemSimple() {
-        final NodeInterval root = new NodeInterval();
+        final DummyNodeInterval root = new DummyNodeInterval();
 
-        final NodeInterval top = createNodeInterval("2014-01-01", "2014-02-01");
-        root.addExistingItem(top);
+        final DummyNodeInterval top = createNodeInterval("2014-01-01", "2014-02-01");
+        root.addNode(top, CALLBACK);
 
-        final NodeInterval firstChildLevel1 = createNodeInterval("2014-01-01", "2014-01-07");
-        final NodeInterval secondChildLevel1 = createNodeInterval("2014-01-08", "2014-01-15");
-        final NodeInterval thirdChildLevel1 = createNodeInterval("2014-01-16", "2014-02-01");
-        root.addExistingItem(firstChildLevel1);
-        root.addExistingItem(secondChildLevel1);
-        root.addExistingItem(thirdChildLevel1);
+        final DummyNodeInterval firstChildLevel1 = createNodeInterval("2014-01-01", "2014-01-07");
+        final DummyNodeInterval secondChildLevel1 = createNodeInterval("2014-01-08", "2014-01-15");
+        final DummyNodeInterval thirdChildLevel1 = createNodeInterval("2014-01-16", "2014-02-01");
+        root.addNode(firstChildLevel1, CALLBACK);
+        root.addNode(secondChildLevel1, CALLBACK);
+        root.addNode(thirdChildLevel1, CALLBACK);
 
-        final NodeInterval firstChildLevel2 = createNodeInterval("2014-01-01", "2014-01-03");
-        final NodeInterval secondChildLevel2 = createNodeInterval("2014-01-03", "2014-01-5");
-        final NodeInterval thirdChildLevel2 = createNodeInterval("2014-01-16", "2014-01-17");
-        root.addExistingItem(firstChildLevel2);
-        root.addExistingItem(secondChildLevel2);
-        root.addExistingItem(thirdChildLevel2);
+        final DummyNodeInterval firstChildLevel2 = createNodeInterval("2014-01-01", "2014-01-03");
+        final DummyNodeInterval secondChildLevel2 = createNodeInterval("2014-01-03", "2014-01-5");
+        final DummyNodeInterval thirdChildLevel2 = createNodeInterval("2014-01-16", "2014-01-17");
+        root.addNode(firstChildLevel2, CALLBACK);
+        root.addNode(secondChildLevel2, CALLBACK);
+        root.addNode(thirdChildLevel2, CALLBACK);
 
         checkNode(top, 3, root, firstChildLevel1, null);
         checkNode(firstChildLevel1, 2, top, firstChildLevel2, secondChildLevel1);
@@ -74,24 +93,24 @@ public class TestNodeInterval /* extends InvoiceTestSuiteNoDB  */ {
 
     @Test(groups = "fast")
     public void testAddExistingItemWithRebalance() {
-        final NodeInterval root = new NodeInterval();
+        final DummyNodeInterval root = new DummyNodeInterval();
 
-        final NodeInterval top = createNodeInterval("2014-01-01", "2014-02-01");
-        root.addExistingItem(top);
+        final DummyNodeInterval top = createNodeInterval("2014-01-01", "2014-02-01");
+        root.addNode(top, CALLBACK);
 
-        final NodeInterval firstChildLevel2 = createNodeInterval("2014-01-01", "2014-01-03");
-        final NodeInterval secondChildLevel2 = createNodeInterval("2014-01-03", "2014-01-5");
-        final NodeInterval thirdChildLevel2 = createNodeInterval("2014-01-16", "2014-01-17");
-        root.addExistingItem(firstChildLevel2);
-        root.addExistingItem(secondChildLevel2);
-        root.addExistingItem(thirdChildLevel2);
+        final DummyNodeInterval firstChildLevel2 = createNodeInterval("2014-01-01", "2014-01-03");
+        final DummyNodeInterval secondChildLevel2 = createNodeInterval("2014-01-03", "2014-01-5");
+        final DummyNodeInterval thirdChildLevel2 = createNodeInterval("2014-01-16", "2014-01-17");
+        root.addNode(firstChildLevel2, CALLBACK);
+        root.addNode(secondChildLevel2, CALLBACK);
+        root.addNode(thirdChildLevel2, CALLBACK);
 
-        final NodeInterval firstChildLevel1 = createNodeInterval("2014-01-01", "2014-01-07");
-        final NodeInterval secondChildLevel1 = createNodeInterval("2014-01-08", "2014-01-15");
-        final NodeInterval thirdChildLevel1 = createNodeInterval("2014-01-16", "2014-02-01");
-        root.addExistingItem(firstChildLevel1);
-        root.addExistingItem(secondChildLevel1);
-        root.addExistingItem(thirdChildLevel1);
+        final DummyNodeInterval firstChildLevel1 = createNodeInterval("2014-01-01", "2014-01-07");
+        final DummyNodeInterval secondChildLevel1 = createNodeInterval("2014-01-08", "2014-01-15");
+        final DummyNodeInterval thirdChildLevel1 = createNodeInterval("2014-01-16", "2014-02-01");
+        root.addNode(firstChildLevel1, CALLBACK);
+        root.addNode(secondChildLevel1, CALLBACK);
+        root.addNode(thirdChildLevel1, CALLBACK);
 
         checkNode(top, 3, root, firstChildLevel1, null);
         checkNode(firstChildLevel1, 2, top, firstChildLevel2, secondChildLevel1);
@@ -105,7 +124,7 @@ public class TestNodeInterval /* extends InvoiceTestSuiteNoDB  */ {
 
 
 
-    private void checkNode(final NodeInterval node, final int expectedChildren, final NodeInterval expectedParent, final NodeInterval expectedLeftChild, final NodeInterval expectedRightSibling) {
+    private void checkNode(final NodeInterval node, final int expectedChildren, final DummyNodeInterval expectedParent, final DummyNodeInterval expectedLeftChild, final DummyNodeInterval expectedRightSibling) {
         assertEquals(node.getNbChildren(), expectedChildren);
         assertEquals(node.getParent(), expectedParent);
         assertEquals(node.getRightSibling(), expectedRightSibling);
@@ -113,15 +132,8 @@ public class TestNodeInterval /* extends InvoiceTestSuiteNoDB  */ {
         assertEquals(node.getLeftChild(), expectedLeftChild);
     }
 
-    private NodeInterval createNodeInterval(final String startDate, final String endDate) {
-        return new NodeInterval(null, createItem(startDate, endDate));
+    private DummyNodeInterval createNodeInterval(final String startDate, final String endDate) {
+        return new DummyNodeInterval(null, new LocalDate(startDate), new LocalDate(endDate));
     }
 
-    private Item createItem(final String startDate, final String endDate) {
-        final BigDecimal amount = BigDecimal.TEN;
-        final BigDecimal rate = BigDecimal.TEN;
-        final InvoiceItem invoiceItem = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, new LocalDate(startDate), new LocalDate(endDate), amount, rate, currency);
-        Item item = new Item(invoiceItem, ItemAction.ADD);
-        return item;
-    }
 }
diff --git a/invoice/src/test/java/com/ning/billing/invoice/tree/TestSubscriptionItemTree.java b/invoice/src/test/java/com/ning/billing/invoice/tree/TestSubscriptionItemTree.java
index 9152117..5b8428a 100644
--- a/invoice/src/test/java/com/ning/billing/invoice/tree/TestSubscriptionItemTree.java
+++ b/invoice/src/test/java/com/ning/billing/invoice/tree/TestSubscriptionItemTree.java
@@ -76,7 +76,6 @@ public class TestSubscriptionItemTree /* extends InvoiceTestSuiteNoDB  */ {
         tree.addItem(repair);
         tree.build();
         verifyResult(tree.getView(), expectedResult);
-
         tree = new SubscriptionItemTree(subscriptionId);
         tree.addItem(repair);
         tree.addItem(newItem);
@@ -500,6 +499,7 @@ public class TestSubscriptionItemTree /* extends InvoiceTestSuiteNoDB  */ {
         verifyResult(tree.getView(), expectedResult);
     }
 
+
     @Test(groups = "fast")
     public void testMergeCancellationWithTwoMiddleRepair() {
 
@@ -524,7 +524,6 @@ public class TestSubscriptionItemTree /* extends InvoiceTestSuiteNoDB  */ {
 
         tree.mergeProposedItem(proposed1);
         tree.mergeProposedItem(proposed2);
-        tree.mergeProposedItem(proposed1);
         tree.mergeProposedItem(proposed3);
         tree.buildForMerge();
 
@@ -536,7 +535,7 @@ public class TestSubscriptionItemTree /* extends InvoiceTestSuiteNoDB  */ {
         verifyResult(tree.getView(), expectedResult);
 
 
-        // Dot it again but with propsoed items out of order
+        // Dot it again but with proposed items out of order
         final SubscriptionItemTree treeAgain = new SubscriptionItemTree(subscriptionId);
         final InvoiceItem monthlyAgain = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate, endDate, monthlyAmount, monthlyRate, currency);
         treeAgain.addItem(monthlyAgain);
@@ -548,7 +547,6 @@ public class TestSubscriptionItemTree /* extends InvoiceTestSuiteNoDB  */ {
 
         treeAgain.mergeProposedItem(proposed1Again);
         treeAgain.mergeProposedItem(proposed2Again);
-        treeAgain.mergeProposedItem(proposed1Again);
         treeAgain.mergeProposedItem(proposed3Again);
         treeAgain.buildForMerge();