killbill-memoizeit

invoice: first pass at cleaning up the code No functional

12/23/2016 3:03:27 PM

Details

diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java
index e9c6342..6403b91 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java
@@ -19,79 +19,93 @@
 package org.killbill.billing.invoice.tree;
 
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Set;
 import java.util.UUID;
 
+import javax.annotation.Nullable;
+
 import org.joda.time.LocalDate;
 import org.killbill.billing.invoice.api.InvoiceItem;
 import org.killbill.billing.invoice.tree.Item.ItemAction;
 
 import com.google.common.base.Preconditions;
 import com.google.common.base.Predicate;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 
 /**
- * Keeps track of all the items existing on a specified interval.
+ * Keeps track of all the items existing on a specified ItemsNodeInterval
  */
 public class ItemsInterval {
 
-    private final UUID targetInvoiceId;
+    // Parent (enclosing) interval
     private final ItemsNodeInterval interval;
-    private LinkedList<Item> items;
+    private final LinkedList<Item> items;
 
-    public ItemsInterval(final ItemsNodeInterval interval, final UUID targetInvoiceId) {
-        this(interval, targetInvoiceId, null);
+    public ItemsInterval(final ItemsNodeInterval interval) {
+        this(interval, null);
     }
 
-    public ItemsInterval(final ItemsNodeInterval interval, final UUID targetInvoiceId, final Item initialItem) {
+    public ItemsInterval(final ItemsNodeInterval interval, final Item initialItem) {
         this.interval = interval;
-        this.targetInvoiceId = targetInvoiceId;
         this.items = Lists.newLinkedList();
         if (initialItem != null) {
             items.add(initialItem);
         }
     }
 
-    public Item findItem(final UUID targetId) {
-        return Iterables.tryFind(items, new Predicate<Item>() {
-            @Override
-            public boolean apply(final Item input) {
-                return input.getId().equals(targetId);
-            }
-        }).orNull();
-    }
-
     public List<Item> getItems() {
         return items;
     }
 
-    public void buildForMissingInterval(final LocalDate startDate, final LocalDate endDate, final List<Item> output, final boolean addRepair) {
-        final Item item = createNewItem(startDate, endDate, addRepair);
-        if (item != null) {
-            output.add(item);
-        }
+    public Iterable<Item> get_ADD_items() {
+        return findItems(ItemAction.ADD);
     }
 
-    /**
-     * Determines what is left based on the mergeMode and the action for each item.
-     *
-     * @param output
-     * @param mergeMode
-     * @return whether or not the parent should ignore the interval covered by the child interval
-     */
-    public void buildFromItems(final List<Item> output, final boolean mergeMode) {
-        final Item item = getResultingItem(mergeMode);
-        if (item != null) {
-            output.add(item);
-        }
+    public Iterable<Item> get_CANCEL_items() {
+        return findItems(ItemAction.CANCEL);
+    }
+
+    public Item getCancellingItemIfExists(final UUID targetId) {
+        return Iterables.tryFind(items,
+                                 new Predicate<Item>() {
+                                     @Override
+                                     public boolean apply(final Item input) {
+                                         return input.getAction() == ItemAction.CANCEL && input.getLinkedId().equals(targetId);
+                                     }
+                                 }).orNull();
+    }
+
+    public Item getCancelledItemIfExists(final UUID linkedId) {
+        return Iterables.tryFind(items,
+                                 new Predicate<Item>() {
+                                     @Override
+                                     public boolean apply(final Item input) {
+                                         return input.getAction() == ItemAction.ADD && input.getId().equals(linkedId);
+                                     }
+                                 }).orNull();
+    }
+
+    public NodeInterval getNodeInterval() {
+        return interval;
+    }
+
+    public Item findItem(final UUID targetId) {
+        final Collection<Item> matchingItems = Collections2.<Item>filter(items,
+                                                                         new Predicate<Item>() {
+                                                                             @Override
+                                                                             public boolean apply(final Item input) {
+                                                                                 return input.getId().equals(targetId);
+                                                                             }
+                                                                         });
+        Preconditions.checkState(matchingItems.size() < 2, "Too many items matching id='%s' among items='%s'", targetId, items);
+        return matchingItems.size() == 1 ? matchingItems.iterator().next() : null;
     }
 
     /**
@@ -119,26 +133,64 @@ public class ItemsInterval {
         return items.isEmpty();
     }
 
-    public Iterable<Item> get_ADD_items() {
-        return Iterables.filter(items, new Predicate<Item>() {
-            @Override
-            public boolean apply(final Item input) {
-                return input.getAction() == ItemAction.ADD;
-            }
-        });
+    public void add(final Item item) {
+        items.add(item);
     }
 
-    public Iterable<Item> get_CANCEL_items() {
-        return Iterables.filter(items, new Predicate<Item>() {
-            @Override
-            public boolean apply(final Item input) {
-                return input.getAction() == ItemAction.CANCEL;
-            }
-        });
+    public void cancelItems(final Item item) {
+        Preconditions.checkState(item.getAction() == ItemAction.ADD);
+        Preconditions.checkState(items.size() == 1);
+        Preconditions.checkState(items.get(0).getAction() == ItemAction.CANCEL);
+        items.clear();
     }
 
-    public NodeInterval getNodeInterval() {
-        return interval;
+    public void remove(final Item item) {
+        items.remove(item);
+    }
+
+    // Called for missing service periods
+    public void buildForMissingInterval(@Nullable final LocalDate startDate, @Nullable final LocalDate endDate, @Nullable final UUID targetInvoiceId, final Collection<Item> output, final boolean addRepair) {
+        final Item item = createNewItem(startDate, endDate, targetInvoiceId, addRepair);
+        if (item != null) {
+            output.add(item);
+        }
+    }
+
+    // Called on the last node
+    public void buildFromItems(final Collection<Item> output, final boolean mergeMode) {
+        buildForMissingInterval(null, null, null, output, mergeMode);
+    }
+
+    /**
+     * Create a new item based on the existing items and new service period
+     * <p/>
+     * <ul>
+     * <li>During the build phase, we only consider ADD items. This happens when for instance an existing item was partially repaired
+     * and there is a need to create a new item which represents the part left -- that was not repaired.
+     * <li>During the merge phase, we create new items that are the missing repaired items (CANCEL).
+     * </ul>
+     *
+     * @param startDate start date of the new item to create
+     * @param endDate   end date of the new item to create
+     * @param mergeMode mode to consider.
+     * @return new item for this service period or null
+     */
+    private Item createNewItem(@Nullable final LocalDate startDate, @Nullable final LocalDate endDate, @Nullable final UUID targetInvoiceId, final boolean mergeMode) {
+        // Find the ADD (build phase) or CANCEL (merge phase) item of this interval
+        final Item item = getResultingItem(mergeMode);
+        if (item == null || startDate == null || endDate == null || targetInvoiceId == null) {
+            return item;
+        }
+
+        // Prorate (build phase) or repair (merge phase) this item, as needed
+        final InvoiceItem proratedInvoiceItem = item.toProratedInvoiceItem(startDate, endDate);
+        if (proratedInvoiceItem == null) {
+            return null;
+        } else {
+            // Keep track of the repaired amount for this item
+            item.incrementCurrentRepairedAmount(proratedInvoiceItem.getAmount().abs());
+            return new Item(proratedInvoiceItem, targetInvoiceId, item.getAction());
+        }
     }
 
     private Item getResultingItem(final boolean mergeMode) {
@@ -146,24 +198,18 @@ public class ItemsInterval {
     }
 
     private Item getResulting_CANCEL_Item() {
-        Preconditions.checkState(items.size() == 0 || items.size() == 1);
+        Preconditions.checkState(items.size() <= 1, "Too many items=%s", items);
         return getResulting_CANCEL_ItemNoChecks();
     }
 
     private Item getResulting_CANCEL_ItemNoChecks() {
-        return Iterables.tryFind(items, new Predicate<Item>() {
-            @Override
-            public boolean apply(final Item input) {
-                return input.getAction() == ItemAction.CANCEL;
-            }
-        }).orNull();
+        return findItem(ItemAction.CANCEL);
     }
 
     private Item getResulting_ADD_Item() {
-
         //
         // At this point we pruned the items so that we can have either:
-        // - 2 items (ADD + CANCEL, where CANCEL does NOT point to ADD item-- otherwise this is a cancelling pair that
+        // - 2 items (ADD + CANCEL, where CANCEL does NOT point to ADD item -- otherwise this is a cancelling pair that
         //            would have been removed in mergeCancellingPairs logic)
         // - 1 ADD item, simple enough we return it
         // - 1 CANCEL, there is nothing to return but the period will be ignored by the parent
@@ -172,12 +218,18 @@ public class ItemsInterval {
         //
         Preconditions.checkState(items.size() <= 2, "Double billing detected: %s", items);
 
-        final Item item = items.size() > 0 && items.get(0).getAction() == ItemAction.ADD ? items.get(0) : null;
+        final Collection<Item> addItems = findItems(ItemAction.ADD);
+        Preconditions.checkState(addItems.size() <= 1, "Double billing detected: %s", items);
 
+        final Item item = findItem(ItemAction.ADD);
+
+        // Double billing sanity check across nodes
         if (item != null) {
             final Set<UUID> addItemsCancelled = new HashSet<UUID>();
-            if (items.size() > 1) {
-                addItemsCancelled.add(items.get(1).getLinkedId());
+            final Item cancelItem = findItem(ItemAction.CANCEL);
+            if (cancelItem != null) {
+                Preconditions.checkState(cancelItem.getLinkedId() != null, "Invalid CANCEL item=%s", cancelItem);
+                addItemsCancelled.add(cancelItem.getLinkedId());
             }
             final Set<UUID> addItemsToBeCancelled = new HashSet<UUID>();
             checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
@@ -196,100 +248,39 @@ public class ItemsInterval {
 
         final Item parentAddItem = parentItemsInterval.getResulting_ADD_Item();
         if (parentAddItem != null) {
+            Preconditions.checkState(parentAddItem.getId() != null, "Invalid ADD item=%s", parentAddItem);
             addItemsToBeCancelled.add(parentAddItem.getId());
         }
 
         final Item parentCancelItem = parentItemsInterval.getResulting_CANCEL_ItemNoChecks();
         if (parentCancelItem != null) {
+            Preconditions.checkState(parentCancelItem.getLinkedId() != null, "Invalid CANCEL item=%s", parentCancelItem);
             addItemsCancelled.add(parentCancelItem.getLinkedId());
         }
 
         parentItemsInterval.checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
     }
 
-    // Just ensure that ADD items precedes CANCEL items
-    public void insertSortedItem(final Item item) {
-        items.add(item);
-        Collections.sort(items, new Comparator<Item>() {
-            @Override
-            public int compare(final Item o1, final Item o2) {
-                if (o1.getAction() == ItemAction.ADD && o2.getAction() == ItemAction.CANCEL) {
-                    return -1;
-                } else if (o1.getAction() == ItemAction.CANCEL && o2.getAction() == ItemAction.ADD) {
-                    return 1;
-                } else {
-                    return 0;
-                }
-            }
-        });
-    }
-
-    public void cancelItems(final Item item) {
-        Preconditions.checkState(item.getAction() == ItemAction.ADD);
-        Preconditions.checkState(items.size() == 1);
-        Preconditions.checkState(items.get(0).getAction() == ItemAction.CANCEL);
-        items.clear();
+    private Item findItem(final ItemAction itemAction) {
+        final Collection<Item> matchingItems = findItems(itemAction);
+        return matchingItems.size() == 1 ? matchingItems.iterator().next() : null;
     }
 
-    public void remove(final Item item) {
-        items.remove(item);
+    private Collection<Item> findItems(final ItemAction itemAction) {
+        return Collections2.<Item>filter(items,
+                                         new Predicate<Item>() {
+                                             @Override
+                                             public boolean apply(final Item input) {
+                                                 return input.getAction() == itemAction;
+                                             }
+                                         });
     }
 
-    public Item getCancellingItemIfExists(final UUID targetId) {
-        return Iterables.tryFind(items,
-                                 new Predicate<Item>() {
-                                     @Override
-                                     public boolean apply(final Item input) {
-                                         return input.getAction() == ItemAction.CANCEL && input.getLinkedId().equals(targetId);
-                                     }
-                                 }).orNull();
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder("ItemsInterval{");
+        sb.append("items=").append(items);
+        sb.append('}');
+        return sb.toString();
     }
-
-    public Item getCancelledItemIfExists(final UUID linkedId) {
-        return Iterables.tryFind(items,
-                                 new Predicate<Item>() {
-                                     @Override
-                                     public boolean apply(final Item input) {
-                                         return input.getAction() == ItemAction.ADD && input.getId().equals(linkedId);
-                                     }
-                                 }).orNull();
-    }
-
-    public int size() {
-        return items.size();
-    }
-
-    /**
-     * Creates a new item.
-     * <p/>
-     * <ul>
-     * <li>In normal mode, we only consider ADD items. This happens when for instance an existing item was partially repaired
-     * and there is a need to create a new item which represents the part left -- that was not repaired.
-     * <li>In mergeMode, we allow to create new items that are the missing repaired items (CANCEL).
-     * </ul>
-     *
-     * @param startDate start date of the new item to create
-     * @param endDate   end date of the new item to create
-     * @param mergeMode mode to consider.
-     * @return
-     */
-    private Item createNewItem(final LocalDate startDate, final LocalDate endDate, final boolean mergeMode) {
-
-        final Item item = getResultingItem(mergeMode);
-        if (item == null) {
-            return null;
-        }
-
-        final InvoiceItem proratedInvoiceItem = item.toProratedInvoiceItem(startDate, endDate);
-        if (proratedInvoiceItem == null) {
-            return null;
-        }
-
-        final Item result = new Item(proratedInvoiceItem, targetInvoiceId, item.getAction());
-        if (item.getAction() == ItemAction.CANCEL) {
-            item.incrementCurrentRepairedAmount(result.getAmount());
-        }
-        return result;
-    }
-
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java
index af2ea82..515b88a 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java
@@ -22,6 +22,7 @@ import java.io.IOException;
 import java.io.OutputStream;
 import java.math.BigDecimal;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -36,22 +37,23 @@ import org.killbill.billing.util.jackson.ObjectMapper;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.core.JsonGenerator;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 
+/**
+ * Node in the SubscriptionItemTree
+ */
 public class ItemsNodeInterval extends NodeInterval {
 
-    private final UUID targetInvoiceId;
-    private ItemsInterval items;
+    private final ItemsInterval items;
 
-    public ItemsNodeInterval(final UUID targetInvoiceId) {
-        this.items = new ItemsInterval(this, targetInvoiceId);
-        this.targetInvoiceId = targetInvoiceId;
+    public ItemsNodeInterval() {
+        this.items = new ItemsInterval(this);
     }
 
-    public ItemsNodeInterval(final ItemsNodeInterval parent, final UUID targetInvoiceId, final Item item) {
+    public ItemsNodeInterval(final ItemsNodeInterval parent, final Item item) {
         super(parent, item.getStartDate(), item.getEndDate());
-        this.items = new ItemsInterval(this, targetInvoiceId, item);
-        this.targetInvoiceId = targetInvoiceId;
+        this.items = new ItemsInterval(this, item);
     }
 
     @JsonIgnore
@@ -64,6 +66,33 @@ public class ItemsNodeInterval extends NodeInterval {
     }
 
     /**
+     * Add existing item into the tree
+     *
+     * @param newNode an existing item
+     */
+    public void addExistingItem(final ItemsNodeInterval newNode) {
+        Preconditions.checkState(newNode.getItems().size() == 1, "Invalid node=%s", newNode);
+        final Item item = newNode.getItems().get(0);
+
+        addNode(newNode,
+                new AddNodeCallback() {
+                    @Override
+                    public boolean onExistingNode(final NodeInterval existingNode) {
+                        final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItemsInterval();
+                        existingOrNewNodeItems.add(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;
+                    }
+                });
+    }
+
+    /**
      * <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
@@ -83,103 +112,24 @@ public class ItemsNodeInterval extends NodeInterval {
      * and the goal is to generate the repair items; @see addProposedItem
      *
      * @param output result list of built items
+     * @param targetInvoiceId
      */
-    public void buildForExistingItems(final List<Item> output) {
-
+    public void buildForExistingItems(final Collection<Item> output, final UUID targetInvoiceId) {
         // We start by pruning useless entries to simplify the build phase.
-        pruneTree();
-
-        build(new BuildNodeCallback() {
-            @Override
-            public void onMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
-                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
-                items.buildForMissingInterval(startDate, endDate, output, false);
-            }
-
-            @Override
-            public void onLastNode(final NodeInterval curNode) {
-                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
-                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 onMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
-                      final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
-                      items.buildForMissingInterval(startDate, endDate, output, true);
-                  }
-
-                  @Override
-                  public void onLastNode(final NodeInterval curNode) {
-                      final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
-                      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().get(0);
-                    final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItemsInterval();
-                    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;
-            }
+        pruneAndValidateTree();
 
-            @Override
-            public boolean shouldInsertNode(final NodeInterval insertionNode) {
-                // Always want to insert node in the tree when we find the right place.
-                return true;
-            }
-        });
+        build(output, targetInvoiceId, false);
     }
 
     /**
-     * Add proposed item into the (flattened and reversed) tree.
+     * 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.
+     * @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) {
+        Preconditions.checkState(newNode.getItems().size() == 1, "Invalid node=%s", newNode);
+        final Item item = newNode.getItems().get(0);
 
         return addNode(newNode, new AddNodeCallback() {
             @Override
@@ -188,8 +138,6 @@ public class ItemsNodeInterval extends NodeInterval {
                     return false;
                 }
 
-                Preconditions.checkState(newNode.getStart().compareTo(existingNode.getStart()) == 0 && newNode.getEnd().compareTo(existingNode.getEnd()) == 0);
-                final Item item = newNode.getItems().get(0);
                 final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItemsInterval();
                 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
@@ -205,17 +153,16 @@ public class ItemsNodeInterval extends NodeInterval {
                     return false;
                 }
 
-                final ItemsInterval insertionNodeItems = ((ItemsNodeInterval) insertionNode).getItemsInterval();
-                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().get(0);
+                final List<Item> insertionNodeItems = ((ItemsNodeInterval) insertionNode).getItems();
+                Preconditions.checkState(insertionNodeItems.size() == 1, "Expected existing node to have only one item");
+                final Item insertionNodeItem = insertionNodeItems.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)) {
+                if (insertionNodeItem.isSameKind(item)) {
                     return true;
-                    // If not, then keep the proposed outside of the tree.
                 } else {
+                    // If not, then keep the proposed outside of the tree.
                     return false;
                 }
             }
@@ -223,14 +170,39 @@ public class ItemsNodeInterval extends NodeInterval {
     }
 
     /**
+     * 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 Collection<Item> output, final UUID targetInvoiceId) {
+        build(output, targetInvoiceId, true);
+    }
+
+    /**
      * Add the adjustment amount on the item specified by the targetId.
      *
      * @return linked item if fully adjusted, null otherwise
      */
-    public Item addAdjustment(final InvoiceItem item) {
+    public Item addAdjustment(final InvoiceItem item, final UUID targetInvoiceId) {
         final UUID targetId = item.getLinkedItemId();
 
-        // TODO we should really be using findNode(adjustmentDate, callback) instead but wrong dates in test creates panic.
         final NodeInterval node = findNode(new SearchCallback() {
             @Override
             public boolean isMatch(final NodeInterval curNode) {
@@ -240,14 +212,13 @@ public class ItemsNodeInterval extends NodeInterval {
         Preconditions.checkNotNull(node, "Unable to find item interval for id='%s', tree=%s", targetId, this);
 
         final ItemsInterval targetItemsInterval = ((ItemsNodeInterval) node).getItemsInterval();
-        final List<Item> targetItems = targetItemsInterval.getItems();
         final Item targetItem = targetItemsInterval.findItem(targetId);
-        Preconditions.checkNotNull(targetItem, "Unable to find item with id='%s', items=%s", targetId, targetItems);
+        Preconditions.checkNotNull(targetItem, "Unable to find item with id='%s', itemsInterval=%s", targetId, targetItemsInterval);
 
         final BigDecimal adjustmentAmount = item.getAmount().negate();
         if (targetItem.getAmount().compareTo(adjustmentAmount) == 0) {
             // Full item adjustment - treat it like a repair
-            addExistingItem(new ItemsNodeInterval(this, targetInvoiceId, new Item(item, targetItem.getStartDate(), targetItem.getEndDate(), targetInvoiceId, ItemAction.CANCEL)));
+            addExistingItem(new ItemsNodeInterval(this, new Item(item, targetItem.getStartDate(), targetItem.getEndDate(), targetInvoiceId, ItemAction.CANCEL)));
             return targetItem;
         } else {
             targetItem.incrementAdjustedAmount(adjustmentAmount);
@@ -255,37 +226,20 @@ public class ItemsNodeInterval extends NodeInterval {
         }
     }
 
-    public void jsonSerializeTree(final ObjectMapper mapper, final OutputStream output) throws IOException {
-
-        final JsonGenerator generator = mapper.getFactory().createJsonGenerator(output);
-        generator.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
-
-        walkTree(new WalkCallback() {
-
-            private int curDepth = 0;
-
+    private void build(final Collection<Item> output, final UUID targetInvoiceId, final boolean mergeMode) {
+        build(new BuildNodeCallback() {
             @Override
-            public void onCurrentNode(final int depth, final NodeInterval curNode, final NodeInterval parent) {
-                final ItemsNodeInterval node = (ItemsNodeInterval) curNode;
-                if (node.isRoot()) {
-                    return;
-                }
+            public void onMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
+                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
+                items.buildForMissingInterval(startDate, endDate, targetInvoiceId, output, mergeMode);
+            }
 
-                try {
-                    if (curDepth < depth) {
-                        generator.writeStartArray();
-                        curDepth = depth;
-                    } else if (curDepth > depth) {
-                        generator.writeEndArray();
-                        curDepth = depth;
-                    }
-                    generator.writeObject(node);
-                } catch (IOException e) {
-                    throw new RuntimeException("Failed to deserialize tree", e);
-                }
+            @Override
+            public void onLastNode(final NodeInterval curNode) {
+                final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
+                items.buildFromItems(output, mergeMode);
             }
         });
-        generator.close();
     }
 
     //
@@ -297,7 +251,7 @@ public class ItemsNodeInterval extends NodeInterval {
     // whose children completely map the interval (isPartitionedByChildren) and where each child will have a CANCEL item pointing to the ADD.
     // When we detect such nodes, we delete both the ADD in the parent interval and the CANCEL in the children (and cleanup the interval if it does not have items)
     //
-    private void pruneTree() {
+    private void pruneAndValidateTree() {
         final NodeInterval root = this;
         walkTree(new WalkCallback() {
             @Override
@@ -395,7 +349,7 @@ public class ItemsNodeInterval extends NodeInterval {
                     if (foundFullRepairByParts) {
                         for (ItemsInterval curItemsInterval : childrenCancellingToBeRemoved.keySet()) {
                             curItemsInterval.remove(childrenCancellingToBeRemoved.get(curItemsInterval));
-                            if (curItemsInterval.size() == 0) {
+                            if (curItemsInterval.getItems().isEmpty()) {
                                 curNode.removeChild(curItemsInterval.getNodeInterval());
                             }
                         }
@@ -409,4 +363,45 @@ public class ItemsNodeInterval extends NodeInterval {
             }
         });
     }
+
+    @VisibleForTesting
+    public void jsonSerializeTree(final ObjectMapper mapper, final OutputStream output) throws IOException {
+        final JsonGenerator generator = mapper.getFactory().createGenerator(output);
+        generator.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
+
+        walkTree(new WalkCallback() {
+
+            private int curDepth = 0;
+
+            @Override
+            public void onCurrentNode(final int depth, final NodeInterval curNode, final NodeInterval parent) {
+                final ItemsNodeInterval node = (ItemsNodeInterval) curNode;
+                if (node.isRoot()) {
+                    return;
+                }
+
+                try {
+                    if (curDepth < depth) {
+                        generator.writeStartArray();
+                        curDepth = depth;
+                    } else if (curDepth > depth) {
+                        generator.writeEndArray();
+                        curDepth = depth;
+                    }
+                    generator.writeObject(node);
+                } catch (IOException e) {
+                    throw new RuntimeException("Failed to deserialize tree", e);
+                }
+            }
+        });
+        generator.close();
+    }
+
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder("ItemsNodeInterval{");
+        sb.append("items=").append(items);
+        sb.append('}');
+        return sb.toString();
+    }
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java
index 70effcf..9c90462 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java
@@ -38,21 +38,22 @@ import com.google.common.collect.Iterables;
 import com.google.common.collect.Ordering;
 
 /**
- * Tree of invoice items for a given subscription.
+ * Tree of invoice items for a given subscription
  */
 public class SubscriptionItemTree {
 
+    private final List<Item> items = new LinkedList<Item>();
+    private final List<Item> existingFullyAdjustedItems = new LinkedList<Item>();
+    private final List<InvoiceItem> existingFixedItems = new LinkedList<InvoiceItem>();
+    private final Map<LocalDate, InvoiceItem> remainingFixedItems = new HashMap<LocalDate, InvoiceItem>();
+    private final List<InvoiceItem> pendingItemAdj = new LinkedList<InvoiceItem>();
+
     private final UUID targetInvoiceId;
     private final UUID subscriptionId;
 
-    private ItemsNodeInterval root;
-    private boolean isBuilt;
-    private boolean isMerged;
-    private List<Item> items;
-    private List<Item> existingFullyAdjustedItems;
-    private List<InvoiceItem> existingFixedItems;
-    private Map<LocalDate, InvoiceItem> remainingFixedItems;
-    private List<InvoiceItem> pendingItemAdj;
+    private ItemsNodeInterval root =new ItemsNodeInterval();
+    private boolean isBuilt = false;
+    private boolean isMerged = false;
 
     private static final Comparator<InvoiceItem> INVOICE_ITEM_COMPARATOR = new Comparator<InvoiceItem>() {
         @Override
@@ -74,32 +75,57 @@ public class SubscriptionItemTree {
         }
     };
 
+    // targetInvoiceId is the new invoice id being generated
     public SubscriptionItemTree(final UUID subscriptionId, final UUID targetInvoiceId) {
         this.subscriptionId = subscriptionId;
         this.targetInvoiceId = targetInvoiceId;
-        this.root = new ItemsNodeInterval(targetInvoiceId);
-        this.items = new LinkedList<Item>();
-        this.existingFullyAdjustedItems = new LinkedList<Item>();
-        this.existingFixedItems = new LinkedList<InvoiceItem>();
-        this.remainingFixedItems = new HashMap<LocalDate, InvoiceItem>();
-        this.pendingItemAdj = new LinkedList<InvoiceItem>();
-        this.isBuilt = false;
     }
 
     /**
-     * Build the tree to return the list of existing items.
+     * Add an existing item in the tree. A new node is inserted or an existing one updated, if one for the same period already exists.
+     *
+     * @param invoiceItem new existing invoice item on disk.
+     */
+    public void addItem(final InvoiceItem invoiceItem) {
+        Preconditions.checkState(!isBuilt, "Tree already built, unable to add new invoiceItem=%s", invoiceItem);
+
+        switch (invoiceItem.getInvoiceItemType()) {
+            case RECURRING:
+                root.addExistingItem(new ItemsNodeInterval(root, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
+                break;
+
+            case REPAIR_ADJ:
+                root.addExistingItem(new ItemsNodeInterval(root, new Item(invoiceItem, targetInvoiceId, ItemAction.CANCEL)));
+                break;
+
+            case FIXED:
+                existingFixedItems.add(invoiceItem);
+                break;
+
+            case ITEM_ADJ:
+                pendingItemAdj.add(invoiceItem);
+                break;
+
+            default:
+                break;
+        }
+    }
+
+    /**
+     * Build the tree and process adjustments
      */
     public void build() {
         Preconditions.checkState(!isBuilt);
 
-        for (InvoiceItem item : pendingItemAdj) {
-            final Item fullyAdjustedItem = root.addAdjustment(item);
+        for (final InvoiceItem item : pendingItemAdj) {
+            final Item fullyAdjustedItem = root.addAdjustment(item, targetInvoiceId);
             if (fullyAdjustedItem != null) {
                 existingFullyAdjustedItems.add(fullyAdjustedItem);
             }
         }
         pendingItemAdj.clear();
-        root.buildForExistingItems(items);
+
+        root.buildForExistingItems(items, targetInvoiceId);
         isBuilt = true;
     }
 
@@ -110,68 +136,32 @@ public class SubscriptionItemTree {
      *
      * @param reverse whether to reverse the existing items (recurring items now show up as CANCEL instead of ADD)
      */
-    public void flatten(boolean reverse) {
+    public void flatten(final boolean reverse) {
         if (!isBuilt) {
             build();
         }
-        root = new ItemsNodeInterval(targetInvoiceId);
-        for (Item item : items) {
+
+        root = new ItemsNodeInterval();
+        for (final Item item : items) {
             Preconditions.checkState(item.getAction() == ItemAction.ADD);
-            root.addExistingItem(new ItemsNodeInterval(root, targetInvoiceId,  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;
     }
 
-    public void buildForMerge() {
-        Preconditions.checkState(!isBuilt);
-        root.mergeExistingAndProposed(items);
-        isBuilt = true;
-        isMerged = true;
-    }
-
     /**
-     * Add an existing item in the tree.
-     *
-     * @param invoiceItem new existing invoice item on disk.
-     */
-    public void addItem(final InvoiceItem invoiceItem) {
-
-        Preconditions.checkState(!isBuilt);
-        switch (invoiceItem.getInvoiceItemType()) {
-            case RECURRING:
-                root.addExistingItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
-                break;
-
-            case REPAIR_ADJ:
-                root.addExistingItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(invoiceItem, targetInvoiceId, ItemAction.CANCEL)));
-                break;
-
-            case FIXED:
-                existingFixedItems.add(invoiceItem);
-                break;
-
-            case ITEM_ADJ:
-                pendingItemAdj.add(invoiceItem);
-                break;
-
-            default:
-                break;
-        }
-    }
-
-    /**
-     * Merge a new proposed ietm in the tree.
+     * Merge a new proposed item in the tree.
      *
      * @param invoiceItem new proposed item that should be merged in the existing tree
      */
     public void mergeProposedItem(final InvoiceItem invoiceItem) {
+        Preconditions.checkState(!isBuilt, "Tree already built, unable to add new invoiceItem=%s", invoiceItem);
 
-        Preconditions.checkState(!isBuilt);
         switch (invoiceItem.getInvoiceItemType()) {
             case RECURRING:
-                final boolean result = root.addProposedItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
-                if (!result) {
+                final boolean merged = root.addProposedItem(new ItemsNodeInterval(root, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
+                if (!merged) {
                     items.add(new Item(invoiceItem, targetInvoiceId, ItemAction.ADD));
                 }
                 break;
@@ -191,7 +181,14 @@ public class SubscriptionItemTree {
             default:
                 Preconditions.checkState(false, "Unexpected proposed item " + invoiceItem);
         }
+    }
 
+    // Build tree post merge
+    public void buildForMerge() {
+        Preconditions.checkState(!isBuilt, "Tree already built");
+        root.mergeExistingAndProposed(items, targetInvoiceId);
+        isBuilt = true;
+        isMerged = true;
     }
 
     /**
@@ -200,6 +197,7 @@ public class SubscriptionItemTree {
      * <li>When called prior, the merge this gives a flat view of the existing items on disk
      * <li>When called after the merge with proposed items, this gives the list of items that should now be written to disk -- new fixed, recurring and repair.
      * </ul>
+     *
      * @return a flat view of the items in the tree.
      */
     public List<InvoiceItem> getView() {
@@ -269,10 +267,6 @@ public class SubscriptionItemTree {
         }
     }
 
-    public UUID getSubscriptionId() {
-        return subscriptionId;
-    }
-
     @Override
     public String toString() {
         final StringBuilder sb = new StringBuilder("SubscriptionItemTree{");
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java b/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java
index 853b179..9f592b3 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java
@@ -100,6 +100,7 @@ public class TestSubscriptionItemTree extends InvoiceTestSuiteNoDB {
         // Stage II: Try again.. with existing items
         existingItems.addAll(tree.getView());
 
+        assertEquals(existingItems.size(), 5);
         tree = new SubscriptionItemTree(subscriptionId, invoiceId);
         for (InvoiceItem e : existingItems) {
             tree.addItem(e);
@@ -488,6 +489,28 @@ public class TestSubscriptionItemTree extends InvoiceTestSuiteNoDB {
         }
     }
 
+    @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+    public void testDoubleBillingOnDifferentInvoices() {
+        final LocalDate startDate1 = new LocalDate(2012, 5, 1);
+        final LocalDate endDate = new LocalDate(2012, 6, 1);
+
+        final BigDecimal rate = BigDecimal.TEN;
+        final BigDecimal amount = rate;
+
+        final InvoiceItem recurring1 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate1, endDate, amount, rate, currency);
+        final InvoiceItem recurring2 = new RecurringInvoiceItem(UUID.randomUUID(), accountId, bundleId, subscriptionId, planName, phaseName, startDate1, endDate, amount, rate, currency);
+
+        final SubscriptionItemTree tree = new SubscriptionItemTree(subscriptionId, invoiceId);
+        tree.addItem(recurring1);
+        tree.addItem(recurring2);
+
+        try {
+            tree.build();
+            fail();
+        } catch (final IllegalStateException e) {
+        }
+    }
+
     @Test(groups = "fast")
     public void testInvalidRepairCausingOverlappingRecurring() {
         final LocalDate startDate = new LocalDate(2014, 1, 1);