/* * Copyright 2010-2014 Ning, Inc. * * Ning licenses this file to you under the Apache License, version 2.0 * (the "License"); you may not use this file except in compliance with the * License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package org.killbill.billing.invoice.tree; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; 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.annotations.VisibleForTesting; import com.google.common.base.Function; 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.Ordering; /** * 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 =new ItemsNodeInterval(); private boolean isBuilt = false; private boolean isMerged = false; private static final Comparator<InvoiceItem> INVOICE_ITEM_COMPARATOR = new Comparator<InvoiceItem>() { @Override public int compare(final InvoiceItem o1, final InvoiceItem o2) { int startDateComp = o1.getStartDate().compareTo(o2.getStartDate()); if (startDateComp != 0) { return startDateComp; } int itemTypeComp = (o1.getInvoiceItemType().ordinal()<o2.getInvoiceItemType().ordinal() ? -1 : (o1.getInvoiceItemType().ordinal()==o2.getInvoiceItemType().ordinal() ? 0 : 1)); if (itemTypeComp != 0) { return itemTypeComp; } Preconditions.checkState(false, "Unexpected list of items for subscription " + o1.getSubscriptionId() + ", type(item1) = " + o1.getInvoiceItemType() + ", start(item1) = " + o1.getStartDate() + ", type(item12) = " + o2.getInvoiceItemType() + ", start(item2) = " + o2.getStartDate()); // Never reached... return 0; } }; // targetInvoiceId is the new invoice id being generated public SubscriptionItemTree(final UUID subscriptionId, final UUID targetInvoiceId) { this.subscriptionId = subscriptionId; this.targetInvoiceId = targetInvoiceId; } /** * 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 (final InvoiceItem item : pendingItemAdj) { final Item fullyAdjustedItem = root.addAdjustment(item, targetInvoiceId); if (fullyAdjustedItem != null) { existingFullyAdjustedItems.add(fullyAdjustedItem); } } pendingItemAdj.clear(); root.buildForExistingItems(items, targetInvoiceId); isBuilt = true; } /** * Flattens the tree so its depth only has one level below root -- becomes a list. * <p> * If the tree was not built, it is first built. The list of items is cleared and the state is now reset to unbuilt. * * @param reverse whether to reverse the existing items (recurring items now show up as CANCEL instead of ADD) */ public void flatten(final boolean reverse) { if (!isBuilt) { build(); } root = new ItemsNodeInterval(); for (final Item item : items) { Preconditions.checkState(item.getAction() == ItemAction.ADD); root.addExistingItem(new ItemsNodeInterval(root, new Item(item, reverse ? ItemAction.CANCEL : ItemAction.ADD))); } items.clear(); isBuilt = false; } /** * 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); switch (invoiceItem.getInvoiceItemType()) { case RECURRING: // merged means we've either matched the proposed to an existing, or triggered a repair 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; case FIXED: final InvoiceItem existingItem = Iterables.tryFind(existingFixedItems, new Predicate<InvoiceItem>() { @Override public boolean apply(final InvoiceItem input) { return input.matches(invoiceItem); } }).orNull(); if (existingItem == null) { remainingFixedItems.put(invoiceItem.getStartDate(), invoiceItem); } break; 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; } /** * Can be called prior or after merge with proposed items. * <ul> * <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() { final List<InvoiceItem> tmp = new LinkedList<InvoiceItem>(); tmp.addAll(remainingFixedItems.values()); tmp.addAll(Collections2.filter(Collections2.transform(items, new Function<Item, InvoiceItem>() { @Override public InvoiceItem apply(final Item input) { final InvoiceItem resultingCandidate = input.toInvoiceItem(); // Post merge, the ADD items are the candidates for the resulting RECURRING items (see toInvoiceItem()). // We will ignore any resulting item matching existing items on disk though as these are the result of full item adjustments. // See https://github.com/killbill/killbill/issues/654 if (isMerged) { for (final Item existingAdjustedItem : existingFullyAdjustedItems) { // Note: we DO keep the item in case of partial matches, e.g. if the new proposed item end date is before // the existing (adjusted) item. See TestSubscriptionItemTree#testMaxedOutProRation final InvoiceItem fullyAdjustedInvoiceItem = existingAdjustedItem.toInvoiceItem(); if (resultingCandidate.matches(fullyAdjustedInvoiceItem)) { return null; } } } return resultingCandidate; } }), new Predicate<InvoiceItem>() { @Override public boolean apply(@Nullable final InvoiceItem input) { return input != null; } })); final List<InvoiceItem> result = Ordering.<InvoiceItem>from(INVOICE_ITEM_COMPARATOR).sortedCopy(tmp); checkItemsListState(result); return result; } // Verify there is no double billing, and no double repair (credits) private void checkItemsListState(final List<InvoiceItem> orderedList) { LocalDate prevRecurringEndDate = null; LocalDate prevRepairEndDate = null; for (InvoiceItem cur : orderedList) { switch (cur.getInvoiceItemType()) { case FIXED: break; case RECURRING: if (prevRecurringEndDate != null) { Preconditions.checkState(prevRecurringEndDate.compareTo(cur.getStartDate()) <= 0); } prevRecurringEndDate = cur.getEndDate(); break; case REPAIR_ADJ: if (prevRepairEndDate != null) { Preconditions.checkState(prevRepairEndDate.compareTo(cur.getStartDate()) <= 0); } prevRepairEndDate = cur.getEndDate(); break; default: Preconditions.checkState(false, "Unexpected item type " + cur.getInvoiceItemType()); } } } @Override public String toString() { final StringBuilder sb = new StringBuilder("SubscriptionItemTree{"); sb.append("targetInvoiceId=").append(targetInvoiceId); sb.append(", subscriptionId=").append(subscriptionId); sb.append(", root=").append(root); sb.append(", isBuilt=").append(isBuilt); sb.append(", isMerged=").append(isMerged); sb.append(", items=").append(items); sb.append(", existingFullyAdjustedItems=").append(existingFullyAdjustedItems); sb.append(", existingFixedItems=").append(existingFixedItems); sb.append(", remainingFixedItems=").append(remainingFixedItems); sb.append(", pendingItemAdj=").append(pendingItemAdj); sb.append('}'); return sb.toString(); } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (!(o instanceof SubscriptionItemTree)) { return false; } final SubscriptionItemTree that = (SubscriptionItemTree) o; if (root != null ? !root.equals(that.root) : that.root != null) { return false; } if (subscriptionId != null ? !subscriptionId.equals(that.subscriptionId) : that.subscriptionId != null) { return false; } return true; } @Override public int hashCode() { int result = subscriptionId != null ? subscriptionId.hashCode() : 0; result = 31 * result + (root != null ? root.hashCode() : 0); return result; } @VisibleForTesting ItemsNodeInterval getRoot() { return root; } }