Details
diff --git a/api/src/main/java/com/ning/billing/entitlement/api/billing/IBillingEvent.java b/api/src/main/java/com/ning/billing/entitlement/api/billing/IBillingEvent.java
index 5db4815..db477be 100644
--- a/api/src/main/java/com/ning/billing/entitlement/api/billing/IBillingEvent.java
+++ b/api/src/main/java/com/ning/billing/entitlement/api/billing/IBillingEvent.java
@@ -84,4 +84,10 @@ public interface IBillingEvent extends Comparable<IBillingEvent> {
* @return the billing mode for the current event
*/
public BillingMode getBillingMode();
+
+ /**
+ *
+ * @return the description of the billing event
+ */
+ public String getDescription();
}
diff --git a/api/src/main/java/com/ning/billing/invoice/api/BillingEvent.java b/api/src/main/java/com/ning/billing/invoice/api/BillingEvent.java
index 6457466..d17bc26 100644
--- a/api/src/main/java/com/ning/billing/invoice/api/BillingEvent.java
+++ b/api/src/main/java/com/ning/billing/invoice/api/BillingEvent.java
@@ -36,6 +36,7 @@ public class BillingEvent implements IBillingEvent {
private final int billCycleDay;
private final BillingMode billingMode;
+
public BillingEvent(UUID subscriptionId, DateTime startDate, String planName, String planPhaseName, IInternationalPrice price,
BillingPeriod billingPeriod, int billCycleDay, BillingMode billingMode) {
this.subscriptionId = subscriptionId;
@@ -48,6 +49,17 @@ public class BillingEvent implements IBillingEvent {
this.billingMode = billingMode;
}
+ public BillingEvent(IBillingEvent event, DateTime startDate) {
+ this.subscriptionId = event.getSubscriptionId();
+ this.startDate = startDate;
+ this.planName = event.getPlanName();
+ this.planPhaseName = event.getPlanPhaseName();
+ this.price = event.getPrice();
+ this.billingPeriod = event.getBillingPeriod();
+ this.billCycleDay = event.getBillCycleDay();
+ this.billingMode = event.getBillingMode();
+ }
+
@Override
public DateTime getEffectiveDate() {
return startDate;
@@ -94,10 +106,13 @@ public class BillingEvent implements IBillingEvent {
}
@Override
- public int compareTo(IBillingEvent billingEvent) {
-// // strict date comparison here breaks SortedTree if multiple events occur on the same day
-// return getEffectiveDate().compareTo(billingEvent.getEffectiveDate()) > 0 ? 1 : -1;
+ public String getDescription() {
+ return planName + "(" + planPhaseName + ")";
+ }
+ @Override
+ public int compareTo(IBillingEvent billingEvent) {
+ // strict date comparison here breaks SortedTree if multiple events occur on the same day
int compareSubscriptions = getSubscriptionId().compareTo(billingEvent.getSubscriptionId());
if (compareSubscriptions == 0) {
diff --git a/api/src/main/java/com/ning/billing/invoice/api/BillingEventSet.java b/api/src/main/java/com/ning/billing/invoice/api/BillingEventSet.java
index 37b536a..c85f540 100644
--- a/api/src/main/java/com/ning/billing/invoice/api/BillingEventSet.java
+++ b/api/src/main/java/com/ning/billing/invoice/api/BillingEventSet.java
@@ -16,34 +16,11 @@
package com.ning.billing.invoice.api;
-import com.ning.billing.catalog.api.Currency;
import com.ning.billing.entitlement.api.billing.IBillingEvent;
-import org.joda.time.DateTime;
import java.util.ArrayList;
public class BillingEventSet extends ArrayList<IBillingEvent> {
- private Currency targetCurrency;
- private DateTime targetDate;
-
- public BillingEventSet(Currency targetCurrency) {
- this.targetCurrency = targetCurrency;
- this.targetDate = new DateTime();
- }
-
- public BillingEventSet(Currency targetCurrency, DateTime targetDate) {
- this.targetCurrency = targetCurrency;
- this.targetDate = targetDate;
- }
-
- public Currency getTargetCurrency() {
- return targetCurrency;
- }
-
- public DateTime getTargetDate() {
- return targetDate;
- }
-
public IBillingEvent getLast() {
if (this.size() == 0) {return null;}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/BillingModeBase.java b/invoice/src/main/java/com/ning/billing/invoice/model/BillingModeBase.java
index 094ab2d..ba1dc88 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/BillingModeBase.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/BillingModeBase.java
@@ -53,12 +53,8 @@ public abstract class BillingModeBase implements IBillingMode {
DateTime endBillCycleDate = calculateBillingCycleDateAfter(targetDate, firstBillCycleDate, billingCycleDay, billingPeriod);
BigDecimal numberOfBillingPeriods = calculateNumberOfWholeBillingPeriods(firstBillCycleDate, endBillCycleDate, billingPeriod);
- if (targetDate.equals(endBillCycleDate)) {
- numberOfBillingPeriods = numberOfBillingPeriods.add(BigDecimal.ONE);
- }
-
return precedingProRation.add(numberOfBillingPeriods);
-}
+ }
protected DateTime buildDate(final int year, final int month, final int day) {
return new DateTime(year, month, day, 0, 0, 0, 0);
@@ -68,7 +64,9 @@ public abstract class BillingModeBase implements IBillingMode {
return !(targetDate.isBefore(startDate) || !targetDate.isBefore(endDate));
}
- protected abstract DateTime calculateEffectiveEndDate(DateTime billCycleDate, DateTime targetDate, DateTime endDate, BillingPeriod billingPeriod);
+ public abstract DateTime calculateEffectiveEndDate(final DateTime startDate, final DateTime targetDate, final int billingCycleDay, final BillingPeriod billingPeriod);
+
+ public abstract DateTime calculateEffectiveEndDate(final DateTime startDate, final DateTime endDate, final DateTime targetDate, final int billingCycleDay, final BillingPeriod billingPeriod);
protected abstract BigDecimal calculateNumberOfWholeBillingPeriods(final DateTime startDate, final DateTime endDate, final BillingPeriod billingPeriod);
@@ -81,4 +79,6 @@ public abstract class BillingModeBase implements IBillingMode {
protected abstract BigDecimal calculateProRationBeforeFirstBillingPeriod(final DateTime startDate, final int billingCycleDay, final BillingPeriod billingPeriod);
protected abstract BigDecimal calculateProRationAfterLastBillingCycleDate(final DateTime endDate, final DateTime previousBillThroughDate, final BillingPeriod billingPeriod);
+
+ protected abstract DateTime calculateEffectiveEndDate(final DateTime billCycleDate, final DateTime targetDate, final DateTime endDate, final BillingPeriod billingPeriod);
}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/DateRange.java b/invoice/src/main/java/com/ning/billing/invoice/model/DateRange.java
new file mode 100644
index 0000000..2a68a5b
--- /dev/null
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/DateRange.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2010-2011 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 com.ning.billing.invoice.model;
+
+import org.joda.time.DateTime;
+
+public class DateRange {
+ private final DateTime startDate;
+ private final DateTime endDate;
+
+ public DateRange(DateTime startDate, DateTime endDate) {
+ this.startDate = startDate;
+ this.endDate = endDate;
+ }
+
+ public DateTime getStartDate() {
+ return startDate;
+ }
+
+ public DateTime getEndDate() {
+ return endDate;
+ }
+
+ /**
+ *
+ * @param date the DateTime in question
+ * @return whether the DateRange contains (inclusively) the DateTime in question
+ */
+ public boolean contains(DateTime date) {
+ return (!date.isBefore(startDate)) && (!date.isAfter(endDate));
+ }
+
+ public boolean overlaps(DateRange range) {
+ return (this.contains(range.getStartDate()) || this.contains(range.getEndDate()));
+ }
+
+ public DateRange calculateUnionWith(DateRange range) {
+ if (this.contains(range.startDate) && this.contains(range.endDate)) {
+ return this;
+ }
+
+ if (this.contains(range.startDate)) {
+ return new DateRange(this.startDate, range.endDate);
+ }
+
+ if (this.contains(range.endDate)) {
+ return new DateRange(range.startDate, this.endDate);
+ }
+
+ return null;
+ }
+}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/DateRangeHashMap.java b/invoice/src/main/java/com/ning/billing/invoice/model/DateRangeHashMap.java
new file mode 100644
index 0000000..b1acc73
--- /dev/null
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/DateRangeHashMap.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2010-2011 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 com.ning.billing.invoice.model;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+public class DateRangeHashMap extends HashMap<UUID, List<DateRange>> {
+ public void collapseDateRanges() {
+ for (UUID key: this.keySet()) {
+ List<DateRange> dateRanges = this.get(key);
+
+ if (dateRanges.size() > 1) {
+ List<DateRange> newDateRanges = collapseDateRanges(dateRanges);
+
+ this.put(key, newDateRanges);
+ }
+ }
+ }
+
+ private List<DateRange> collapseDateRanges(List<DateRange> dateRanges) {
+ if (dateRanges.size() < 2) {return dateRanges;}
+
+ int originalSize = dateRanges.size();
+ List<DateRange> newDateRanges = new ArrayList<DateRange>();
+
+ DateRange range = dateRanges.get(0);
+
+ for (int i = 1; i < dateRanges.size(); i++) {
+ DateRange thisRange = dateRanges.get(i);
+ if (range.overlaps(thisRange)) {
+ range = range.calculateUnionWith(thisRange);
+ newDateRanges.add(range);
+ } else {
+ newDateRanges.add(range);
+ range = thisRange;
+ }
+ }
+
+ if (originalSize > newDateRanges.size()) {
+ newDateRanges = collapseDateRanges(newDateRanges);
+ }
+
+ return newDateRanges;
+ }
+}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/DefaultInvoiceGenerator.java b/invoice/src/main/java/com/ning/billing/invoice/model/DefaultInvoiceGenerator.java
index c99ff5a..94036d5 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/DefaultInvoiceGenerator.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/DefaultInvoiceGenerator.java
@@ -24,17 +24,59 @@ import com.ning.billing.invoice.api.BillingEventSet;
import org.joda.time.DateTime;
import java.math.BigDecimal;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.List;
public class DefaultInvoiceGenerator implements IInvoiceGenerator {
@Override
- public Invoice generateInvoice(BillingEventSet events) {
+ public Invoice generateInvoice(final BillingEventSet events, final InvoiceItemList existingItems, final DateTime targetDate, final Currency targetCurrency) {
if (events == null) {return new Invoice();}
if (events.size() == 0) {return new Invoice();}
- Currency targetCurrency = events.getTargetCurrency();
+ InvoiceItemList currentItems = generateInvoiceItems(events, targetDate, targetCurrency);
+ InvoiceItemList itemsToPost = reconcileInvoiceItems(currentItems, existingItems);
+
Invoice invoice = new Invoice(targetCurrency);
- DateTime targetDate = events.getTargetDate();
+ invoice.add(itemsToPost);
+
+ return invoice;
+ }
+
+ private InvoiceItemList reconcileInvoiceItems(final InvoiceItemList currentInvoiceItems, final InvoiceItemList existingInvoiceItems) {
+ InvoiceItemList currentItems = (InvoiceItemList) currentInvoiceItems.clone();
+ InvoiceItemList existingItems = (InvoiceItemList) existingInvoiceItems.clone();
+
+ Collections.sort(currentItems);
+ Collections.sort(existingItems);
+
+ List<InvoiceItem> existingItemsToRemove = new ArrayList<InvoiceItem>();
+
+ for (InvoiceItem currentItem : currentItems) {
+ // see if there are any existing items that are covered by the current item
+ for (InvoiceItem existingItem : existingItems) {
+ if (currentItem.duplicates(existingItem)) {
+ currentItem.subtract(existingItem);
+ existingItemsToRemove.add(existingItem);
+ }
+ }
+ }
+
+ existingItems.removeAll(existingItemsToRemove);
+
+ // remove zero-dollar invoice items
+ currentItems.removeZeroDollarItems();
+
+ // add existing items that aren't covered by current items as credit items
+ for (InvoiceItem existingItem : existingItems) {
+ currentItems.add(existingItem.asCredit());
+ }
+
+ return currentItems;
+ }
+
+ private InvoiceItemList generateInvoiceItems(BillingEventSet events, DateTime targetDate, Currency targetCurrency) {
+ InvoiceItemList items = new InvoiceItemList();
// sort events; this relies on the sort order being by subscription id then start date
Collections.sort(events);
@@ -46,36 +88,42 @@ public class DefaultInvoiceGenerator implements IInvoiceGenerator {
IBillingEvent nextEvent = events.get(i + 1);
if (thisEvent.getSubscriptionId() == nextEvent.getSubscriptionId()) {
- processEvents(thisEvent, nextEvent, invoice, targetDate, targetCurrency);
+ processEvents(thisEvent, nextEvent, items, targetDate, targetCurrency);
} else {
- processEvent(thisEvent, invoice, targetDate, targetCurrency);
+ processEvent(thisEvent, items, targetDate, targetCurrency);
}
}
// process the last item in the event set
- processEvent(events.getLast(), invoice, targetDate, targetCurrency);
+ if (events.size() > 0) {
+ processEvent(events.getLast(), items, targetDate, targetCurrency);
+ }
- return invoice;
+ return items;
}
- private void processEvent(IBillingEvent event, Invoice invoice, DateTime targetDate, Currency targetCurrency) {
+ private void processEvent(IBillingEvent event, List<InvoiceItem> items, DateTime targetDate, Currency targetCurrency) {
BigDecimal rate = event.getPrice(targetCurrency);
BigDecimal invoiceItemAmount = calculateInvoiceItemAmount(event, targetDate, rate);
+ IBillingMode billingMode = getBillingMode(event.getBillingMode());
+ DateTime billThroughDate = billingMode.calculateEffectiveEndDate(event.getEffectiveDate(), targetDate, event.getBillCycleDay(), event.getBillingPeriod());
- addInvoiceItem(invoice, invoiceItemAmount);
+ addInvoiceItem(items, event, billThroughDate, invoiceItemAmount, rate, targetCurrency);
}
- private void processEvents(IBillingEvent firstEvent, IBillingEvent secondEvent, Invoice invoice, DateTime targetDate, Currency targetCurrency) {
+ private void processEvents(IBillingEvent firstEvent, IBillingEvent secondEvent, List<InvoiceItem> items, DateTime targetDate, Currency targetCurrency) {
BigDecimal rate = firstEvent.getPrice(targetCurrency);
BigDecimal invoiceItemAmount = calculateInvoiceItemAmount(firstEvent, secondEvent, targetDate, rate);
+ IBillingMode billingMode = getBillingMode(firstEvent.getBillingMode());
+ DateTime billThroughDate = billingMode.calculateEffectiveEndDate(firstEvent.getEffectiveDate(), secondEvent.getEffectiveDate(), targetDate, firstEvent.getBillCycleDay(), firstEvent.getBillingPeriod());
- addInvoiceItem(invoice, invoiceItemAmount);
+ addInvoiceItem(items, firstEvent, billThroughDate, invoiceItemAmount, rate, targetCurrency);
}
- private void addInvoiceItem(Invoice invoice, BigDecimal amount) {
- if (!amount.equals(BigDecimal.ZERO)) {
- InvoiceItem item = new InvoiceItem(amount);
- invoice.add(item);
+ private void addInvoiceItem(List<InvoiceItem> items, IBillingEvent event, DateTime billThroughDate, BigDecimal amount, BigDecimal rate, Currency currency) {
+ if (!(amount.compareTo(BigDecimal.ZERO) == 0)) {
+ InvoiceItem item = new InvoiceItem(event.getSubscriptionId(), event.getEffectiveDate(), billThroughDate, event.getDescription(), amount, rate, currency);
+ items.add(item);
}
}
@@ -114,6 +162,11 @@ public class DefaultInvoiceGenerator implements IInvoiceGenerator {
}
private IBillingMode getBillingMode(BillingMode billingMode) {
- return new InAdvanceBillingMode();
+ switch (billingMode) {
+ case IN_ADVANCE:
+ return new InAdvanceBillingMode();
+ default:
+ return null;
+ }
}
}
\ No newline at end of file
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/IBillingMode.java b/invoice/src/main/java/com/ning/billing/invoice/model/IBillingMode.java
index e716e47..899d7fe 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/IBillingMode.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/IBillingMode.java
@@ -25,4 +25,8 @@ public interface IBillingMode {
BigDecimal calculateNumberOfBillingCycles(DateTime startDate, DateTime endDate, DateTime targetDate, int billingCycleDay, BillingPeriod billingPeriod) throws InvalidDateSequenceException;
BigDecimal calculateNumberOfBillingCycles(DateTime startDate, DateTime targetDate, int billingCycleDay, BillingPeriod billingPeriod) throws InvalidDateSequenceException;
+
+ DateTime calculateEffectiveEndDate(DateTime startDate, DateTime targetDate, int billingCycleDay, BillingPeriod billingPeriod);
+
+ DateTime calculateEffectiveEndDate(DateTime startDate, DateTime endDate, DateTime targetDate, int billingCycleDay, BillingPeriod billingPeriod);
}
\ No newline at end of file
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/IInvoiceGenerator.java b/invoice/src/main/java/com/ning/billing/invoice/model/IInvoiceGenerator.java
index 6d03a9b..3070307 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/IInvoiceGenerator.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/IInvoiceGenerator.java
@@ -16,9 +16,11 @@
package com.ning.billing.invoice.model;
+import com.ning.billing.catalog.api.Currency;
import com.ning.billing.invoice.api.BillingEventSet;
+import org.joda.time.DateTime;
// TODO: Jeff -- Determine what the consequence of account-level currency changes are on repair scenarios
public interface IInvoiceGenerator {
- public Invoice generateInvoice(BillingEventSet events);
+ public Invoice generateInvoice(BillingEventSet events, InvoiceItemList items, DateTime targetDate, Currency targetCurrency);
}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/InAdvanceBillingMode.java b/invoice/src/main/java/com/ning/billing/invoice/model/InAdvanceBillingMode.java
index caf7e3d..ff8f9ac 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/InAdvanceBillingMode.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/InAdvanceBillingMode.java
@@ -28,6 +28,18 @@ public class InAdvanceBillingMode extends BillingModeBase {
private static final int NUMBER_OF_DECIMALS = InvoicingConfiguration.getNumberOfDecimals();
@Override
+ public DateTime calculateEffectiveEndDate(final DateTime startDate, final DateTime targetDate, final int billingCycleDay, final BillingPeriod billingPeriod) {
+ DateTime firstBillCycleDate = calculateBillingCycleDateOnOrAfter(startDate, billingCycleDay);
+ return calculateBillingCycleDateAfter(targetDate, firstBillCycleDate, billingCycleDay, billingPeriod);
+ }
+
+ @Override
+ public DateTime calculateEffectiveEndDate(final DateTime startDate, final DateTime endDate, final DateTime targetDate, final int billingCycleDay, final BillingPeriod billingPeriod) {
+ DateTime firstBillCycleDate = calculateBillingCycleDateOnOrAfter(startDate, billingCycleDay);
+ return calculateEffectiveEndDate(firstBillCycleDate, targetDate, endDate, billingPeriod);
+ }
+
+ @Override
protected BigDecimal calculateNumberOfWholeBillingPeriods(final DateTime startDate, final DateTime endDate, final BillingPeriod billingPeriod) {
int numberOfMonths = Months.monthsBetween(startDate, endDate).getMonths();
BigDecimal numberOfMonthsInPeriod = new BigDecimal(billingPeriod.getNumberOfMonths());
@@ -55,7 +67,7 @@ public class InAdvanceBillingMode extends BillingModeBase {
protected DateTime calculateBillingCycleDateAfter(final DateTime date, final DateTime billingCycleDate, final int billingCycleDay, final BillingPeriod billingPeriod) {
DateTime proposedDate = billingCycleDate;
- while (proposedDate.isBefore(date)) {
+ while (!proposedDate.isAfter(date)) {
proposedDate = proposedDate.plusMonths(billingPeriod.getNumberOfMonths());
if (proposedDate.dayOfMonth().get() != billingCycleDay) {
@@ -119,6 +131,7 @@ public class InAdvanceBillingMode extends BillingModeBase {
return days.divide(daysInPeriod, NUMBER_OF_DECIMALS, ROUNDING_METHOD);
}
+ @Override
protected DateTime calculateEffectiveEndDate(DateTime billCycleDate, DateTime targetDate, DateTime endDate, BillingPeriod billingPeriod) {
if (targetDate.isBefore(endDate)) {
if (targetDate.isBefore(billCycleDate)) {
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/Invoice.java b/invoice/src/main/java/com/ning/billing/invoice/model/Invoice.java
index 5ba92cf..53965f3 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/Invoice.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/Invoice.java
@@ -19,13 +19,10 @@ package com.ning.billing.invoice.model;
import com.ning.billing.catalog.api.Currency;
import java.math.BigDecimal;
-import java.util.ArrayList;
import java.util.List;
public class Invoice {
- private static final int NUMBER_OF_DECIMALS = InvoicingConfiguration.getNumberOfDecimals();
-
- private final List<InvoiceItem> items = new ArrayList<InvoiceItem>();
+ private final InvoiceItemList items = new InvoiceItemList();
private Currency currency;
public Invoice() {}
@@ -34,23 +31,29 @@ public class Invoice {
this.currency = currency;
}
+ public Invoice(List<InvoiceItem> items, Currency currency) {
+ this.currency = currency;
+ this.items.addAll(items);
+ }
+
public boolean add(InvoiceItem item) {
return items.add(item);
}
+ public boolean add(List<InvoiceItem> items) {
+ return this.items.addAll(items);
+ }
+
+ public List<InvoiceItem> getItems() {
+ return items;
+ }
+
public Currency getCurrency() {
return currency;
}
public BigDecimal getTotalAmount() {
- // TODO: Jeff -- naive implementation, assumes all invoice items share the same currency
- BigDecimal total = new BigDecimal("0");
-
- for (InvoiceItem item : items) {
- total = total.add(item.getAmount());
- }
-
- return total.setScale(NUMBER_OF_DECIMALS);
+ return items.getTotalAmount();
}
public int getNumberOfItems() {
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItem.java b/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItem.java
index 45880bb..c49edf1 100644
--- a/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItem.java
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItem.java
@@ -16,26 +16,94 @@
package com.ning.billing.invoice.model;
+import com.ning.billing.catalog.api.Currency;
+import org.joda.time.DateTime;
+
import java.math.BigDecimal;
+import java.util.UUID;
+
+public class InvoiceItem implements Comparable<InvoiceItem> {
+ private final UUID subscriptionId;
+ private DateTime startDate;
+ private DateTime endDate;
+ private final String description;
+ private BigDecimal amount;
+ private final BigDecimal rate;
+ private final Currency currency;
-public class InvoiceItem {
-// private final String description;
-// private final DateTime startDate;
-// private final DateTime endDate;
- private final BigDecimal amount;
-// private final Currency currency;
-
- // TODO: Jeff -- determine if a default constructor is required for InvoiceItem
- //public InvoiceItem(DateTime startDate, DateTime endDate, String description, BigDecimal amount, Currency currency) {
- public InvoiceItem(BigDecimal amount) {
- //this.description = description;
+ public InvoiceItem(UUID subscriptionId, DateTime startDate, DateTime endDate, String description, BigDecimal amount, BigDecimal rate, Currency currency) {
+ this.subscriptionId = subscriptionId;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ this.description = description;
this.amount = amount;
-// this.currency = currency;
-// this.startDate = startDate;
-// this.endDate = endDate;
+ this.rate = rate;
+ this.currency = currency;
+ }
+
+ public InvoiceItem asCredit() {
+ return new InvoiceItem(subscriptionId, startDate, endDate, description, amount.negate(), rate, currency);
+ }
+
+ public UUID getSubscriptionId() {
+ return subscriptionId;
+ }
+
+ public DateTime getStartDate() {
+ return startDate;
+ }
+
+ public DateTime getEndDate() {
+ return endDate;
}
public BigDecimal getAmount() {
return amount;
}
+
+ public BigDecimal getRate() {
+ return rate;
+ }
+
+ public Currency getCurrency() {
+ return currency;
+ }
+
+ @Override
+ public int compareTo(InvoiceItem invoiceItem) {
+ int compareSubscriptions = getSubscriptionId().compareTo(invoiceItem.getSubscriptionId());
+
+ if (compareSubscriptions == 0) {
+ return getStartDate().compareTo(invoiceItem.getStartDate());
+ } else {
+ return compareSubscriptions;
+ }
+ }
+
+ // TODO: deal with error cases
+ public void subtract(InvoiceItem that) {
+ if (this.startDate.equals(that.startDate) && this.endDate.equals(that.endDate)) {
+ this.startDate = this.endDate;
+ this.amount = this.amount.subtract(that.amount);
+ } else {
+ if (this.startDate.equals(that.startDate)) {
+ this.startDate = that.endDate;
+ this.amount = this.amount.subtract(that.amount);
+ }
+
+ if (this.endDate.equals(that.endDate)) {
+ this.endDate = that.startDate;
+ this.amount = this.amount.subtract(that.amount);
+ }
+ }
+ }
+
+ public boolean duplicates(InvoiceItem that) {
+ if(!this.getSubscriptionId().equals(that.getSubscriptionId())) {return false;}
+ if(!this.getRate().equals(that.getRate())) {return false;}
+ if(!this.getCurrency().equals(that.getCurrency())) {return false;}
+
+ DateRange thisDateRange = new DateRange(this.getStartDate(), this.getEndDate());
+ return thisDateRange.contains(that.getStartDate()) && thisDateRange.contains(that.getEndDate());
+ }
}
diff --git a/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItemList.java b/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItemList.java
new file mode 100644
index 0000000..617405d
--- /dev/null
+++ b/invoice/src/main/java/com/ning/billing/invoice/model/InvoiceItemList.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2010-2011 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 com.ning.billing.invoice.model;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+public class InvoiceItemList extends ArrayList<InvoiceItem> {
+ private static final int NUMBER_OF_DECIMALS = InvoicingConfiguration.getNumberOfDecimals();
+
+ public BigDecimal getTotalAmount() {
+ // TODO: Jeff -- naive implementation, assumes all invoice items share the same currency
+ BigDecimal total = new BigDecimal("0");
+
+ for (InvoiceItem item : this) {
+ total = total.add(item.getAmount());
+ }
+
+ return total.setScale(NUMBER_OF_DECIMALS);
+ }
+
+ public List<InvoiceItem> getBySubscriptionId(UUID subscriptionId) {
+ List<InvoiceItem> thisList = new ArrayList<InvoiceItem>();
+
+ for (InvoiceItem item : this) {
+ if (item.getSubscriptionId() == subscriptionId) {
+ thisList.add(item);
+ }
+ }
+
+ return thisList;
+ }
+
+ public void removeZeroDollarItems() {
+ List<InvoiceItem> itemsToRemove = new ArrayList<InvoiceItem>();
+
+ for (InvoiceItem item : this) {
+ if (item.getAmount().compareTo(BigDecimal.ZERO) == 0) {
+ itemsToRemove.add(item);
+ }
+ }
+
+ this.removeAll(itemsToRemove);
+ }
+}
diff --git a/invoice/src/test/java/com/ning/billing/invoice/tests/DefaultInvoiceGeneratorTests.java b/invoice/src/test/java/com/ning/billing/invoice/tests/DefaultInvoiceGeneratorTests.java
index cfa0ce1..8e6d671 100644
--- a/invoice/src/test/java/com/ning/billing/invoice/tests/DefaultInvoiceGeneratorTests.java
+++ b/invoice/src/test/java/com/ning/billing/invoice/tests/DefaultInvoiceGeneratorTests.java
@@ -22,12 +22,11 @@ import com.ning.billing.entitlement.api.billing.BillingMode;
import com.ning.billing.entitlement.api.billing.IBillingEvent;
import com.ning.billing.invoice.api.BillingEvent;
import com.ning.billing.invoice.api.BillingEventSet;
-import com.ning.billing.invoice.model.DefaultInvoiceGenerator;
-import com.ning.billing.invoice.model.IInvoiceGenerator;
-import com.ning.billing.invoice.model.Invoice;
+import com.ning.billing.invoice.model.*;
import org.joda.time.DateTime;
import org.testng.annotations.Test;
+import java.math.BigDecimal;
import java.util.UUID;
import static org.testng.Assert.assertEquals;
@@ -38,8 +37,8 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
private final IInvoiceGenerator generator = new DefaultInvoiceGenerator();
@Test
- public void testWithNullEventSet() {
- Invoice invoice = generator.generateInvoice(null);
+ public void testWithNullEventSetAndNullInvoiceSet() {
+ Invoice invoice = generator.generateInvoice(null, null, new DateTime(), Currency.USD);
assertNotNull(invoice);
assertEquals(invoice.getNumberOfItems(), 0);
@@ -48,9 +47,10 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
@Test
public void testWithEmptyEventSet() {
- BillingEventSet events = new BillingEventSet(Currency.USD);
+ BillingEventSet events = new BillingEventSet();
- Invoice invoice = generator.generateInvoice(events);
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, new DateTime(), Currency.USD);
assertNotNull(invoice);
assertEquals(invoice.getNumberOfItems(), 0);
@@ -58,9 +58,8 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
}
@Test
- public void testWithSingleSimpleEvent() {
- DateTime targetDate = buildDateTime(2011, 10, 3);
- BillingEventSet events = new BillingEventSet(Currency.USD, targetDate);
+ public void testWithSingleMonthlyEvent() {
+ BillingEventSet events = new BillingEventSet();
UUID subscriptionId = UUID.randomUUID();
DateTime startDate = buildDateTime(2011, 9, 1);
@@ -72,7 +71,10 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
events.add(event);
- Invoice invoice = generator.generateInvoice(events);
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+
+ DateTime targetDate = buildDateTime(2011, 10, 3);
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, Currency.USD);
assertNotNull(invoice);
assertEquals(invoice.getNumberOfItems(), 1);
@@ -80,9 +82,37 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
}
@Test
- public void testTwoSubscriptionsWithAlignedBillingDates() {
- DateTime targetDate = buildDateTime(2011, 10, 3);
- BillingEventSet events = new BillingEventSet(Currency.USD, targetDate);
+ public void testWithSingleMonthlyEventWithLeadingProRation() {
+ BillingEventSet events = new BillingEventSet();
+
+ UUID subscriptionId = UUID.randomUUID();
+ DateTime startDate = buildDateTime(2011, 9, 1);
+ String planName = "World Domination";
+ String phaseName = "Build Space Laser";
+ BigDecimal rate = TEN;
+ IBillingEvent event = new BillingEvent(subscriptionId, startDate, planName, phaseName,
+ new InternationalPriceMock(rate), BillingPeriod.MONTHLY,
+ 15, BillingMode.IN_ADVANCE);
+
+ events.add(event);
+
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+
+ DateTime targetDate = buildDateTime(2011, 10, 3);
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, Currency.USD);
+
+ assertNotNull(invoice);
+ assertEquals(invoice.getNumberOfItems(), 1);
+
+ BigDecimal expectedNumberOfBillingCycles;
+ expectedNumberOfBillingCycles = ONE.add(FOURTEEN.divide(THIRTY_ONE, NUMBER_OF_DECIMALS, ROUNDING_METHOD));
+ BigDecimal expectedAmount = expectedNumberOfBillingCycles.multiply(rate).setScale(NUMBER_OF_DECIMALS);
+ assertEquals(invoice.getTotalAmount(), expectedAmount);
+ }
+
+ @Test
+ public void testTwoMonthlySubscriptionsWithAlignedBillingDates() {
+ BillingEventSet events = new BillingEventSet();
IBillingEvent event1 = new BillingEvent(UUID.randomUUID(), buildDateTime(2011, 9, 1),
"World Domination", "Build Space Laser",
@@ -96,7 +126,9 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
1, BillingMode.IN_ADVANCE);
events.add(event2);
- Invoice invoice = generator.generateInvoice(events);
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+ DateTime targetDate = buildDateTime(2011, 10, 3);
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, Currency.USD);
assertNotNull(invoice);
assertEquals(invoice.getNumberOfItems(), 2);
@@ -104,9 +136,45 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
}
@Test
- public void testOnePlan_ThreePhases_ChangeEOT() {
+ public void testOnePlan_TwoMonthlyPhases_ChangeImmediate() {
+ BillingEventSet events = new BillingEventSet();
+
+ UUID subscriptionId = UUID.randomUUID();
+ IBillingEvent event1 = new BillingEvent(subscriptionId, buildDateTime(2011, 9, 1),
+ "World Domination", "Build Space Laser",
+ new InternationalPriceMock(FIVE), BillingPeriod.MONTHLY,
+ 1, BillingMode.IN_ADVANCE);
+ events.add(event1);
+
+ IBillingEvent event2 = new BillingEvent(subscriptionId, buildDateTime(2011, 10, 15),
+ "World Domination", "Incinerate James Bond",
+ new InternationalPriceMock(TEN), BillingPeriod.MONTHLY,
+ 15, BillingMode.IN_ADVANCE);
+ events.add(event2);
+
+ InvoiceItemList invoiceItems = new InvoiceItemList();
DateTime targetDate = buildDateTime(2011, 12, 3);
- BillingEventSet events = new BillingEventSet(Currency.USD, targetDate);
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, Currency.USD);
+
+ assertNotNull(invoice);
+ assertEquals(invoice.getNumberOfItems(), 2);
+
+ BigDecimal numberOfCyclesEvent1;
+ numberOfCyclesEvent1 = ONE.add(FOURTEEN.divide(THIRTY_ONE, NUMBER_OF_DECIMALS, ROUNDING_METHOD));
+
+ BigDecimal numberOfCyclesEvent2 = TWO;
+
+ BigDecimal expectedValue;
+ expectedValue = numberOfCyclesEvent1.multiply(FIVE);
+ expectedValue = expectedValue.add(numberOfCyclesEvent2.multiply(TEN));
+ expectedValue = expectedValue.setScale(NUMBER_OF_DECIMALS);
+
+ assertEquals(invoice.getTotalAmount(), expectedValue);
+ }
+
+ @Test
+ public void testOnePlan_ThreeMonthlyPhases_ChangeEOT() {
+ BillingEventSet events = new BillingEventSet();
UUID subscriptionId = UUID.randomUUID();
IBillingEvent event1 = new BillingEvent(subscriptionId, buildDateTime(2011, 9, 1),
@@ -127,10 +195,136 @@ public class DefaultInvoiceGeneratorTests extends InvoicingTestBase {
1, BillingMode.IN_ADVANCE);
events.add(event3);
- Invoice invoice = generator.generateInvoice(events);
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+ DateTime targetDate = buildDateTime(2011, 12, 3);
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, Currency.USD);
assertNotNull(invoice);
assertEquals(invoice.getNumberOfItems(), 3);
assertEquals(invoice.getTotalAmount(), FIVE.add(TEN).add(TWO.multiply(THIRTY)).setScale(NUMBER_OF_DECIMALS));
}
-}
+
+ @Test
+ public void testSingleEventWithExistingInvoice() {
+ BillingEventSet events = new BillingEventSet();
+
+ UUID subscriptionId = UUID.randomUUID();
+ DateTime startDate = buildDateTime(2011, 9, 1);
+
+ BigDecimal rate = FIVE;
+ IBillingEvent event1 = new BillingEvent(subscriptionId, startDate,
+ "World Domination", "Build Space Laser",
+ new InternationalPriceMock(rate), BillingPeriod.MONTHLY,
+ 1, BillingMode.IN_ADVANCE);
+ events.add(event1);
+
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+ InvoiceItem invoiceItem = new InvoiceItem(subscriptionId, startDate, buildDateTime(2012, 1, 1), "",
+ rate.multiply(FOUR), rate, Currency.USD);
+ invoiceItems.add(invoiceItem);
+
+ DateTime targetDate = buildDateTime(2011, 12, 3);
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, Currency.USD);
+
+ assertNotNull(invoice);
+ assertEquals(invoice.getNumberOfItems(), 0);
+ assertEquals(invoice.getTotalAmount(), ZERO);
+ }
+
+ @Test
+ public void testMultiplePlansWithUtterChaos() {
+ // plan 1: change of term from monthly to annual followed by immediate cancellation; (covers term change, cancel, double pro-ration)
+ // plan 2: single plan that moves from trial to discount to evergreen; BCD = 10 (covers phase change)
+ // plan 3: change of term from annual (BCD = 20) to monthly (BCD = 31; immediate)
+ // plan 4: change of plan, effective EOT, BCD = 10, start = 2/10/2011 (covers change of plan, multiple BCD)
+
+ BillingMode billingMode = BillingMode.IN_ADVANCE;
+ BillingEventSet events = new BillingEventSet();
+
+ UUID subscriptionId1 = UUID.randomUUID();
+ DateTime startDate1 = buildDateTime(2011, 1, 5);
+ String planName1 = "World Domination";
+
+ IBillingEvent event;
+ BigDecimal expectedAmount;
+ InvoiceItemList invoiceItems = new InvoiceItemList();
+
+ event = new BillingEvent(subscriptionId1, startDate1,
+ planName1, "Conceive diabolical plan",
+ new InternationalPriceMock(EIGHT), BillingPeriod.MONTHLY, 5, billingMode);
+ events.add(event);
+
+ // invoice for 2011/1/5 through 2011/2/5 on plan 1
+ expectedAmount = EIGHT;
+
+ // initial invoice on subscription creation
+ testInvoiceGeneration(events, invoiceItems, startDate1, 1, expectedAmount);
+ assertEquals(invoiceItems.size(), 1);
+
+ // attempt to invoice again the following day; should have no invoice items
+ testInvoiceGeneration(events, invoiceItems, buildDateTime(2011, 1, 6), 0, ZERO);
+ assertEquals(invoiceItems.size(), 1);
+
+ // add a second plan to the account
+ UUID subscriptionId2 = UUID.randomUUID();
+ DateTime startDate2 = buildDateTime(2011, 3, 10);
+ String planName2 = "Build Invoicing System";
+
+ event = new BillingEvent(subscriptionId2, startDate2,
+ planName2, "Implement and test pro-ration algorithm",
+ new InternationalPriceMock(TWENTY), BillingPeriod.MONTHLY, 10, billingMode);
+ events.add(event);
+
+ // invoice for 2011/2/5 - 2011/4/5 on plan 1; invoice for 2011/3/10 - 2011/4/10 on plan 2
+ expectedAmount = EIGHT.multiply(TWO).add(TWENTY).setScale(NUMBER_OF_DECIMALS);
+ testInvoiceGeneration(events, invoiceItems, startDate2, 2, expectedAmount);
+ assertEquals(invoiceItems.size(), 3);
+
+ // first plan rolls into discount period on 4/5
+ DateTime phaseChangeDate = buildDateTime(2011, 4, 5);
+ event = new BillingEvent(subscriptionId1, phaseChangeDate,
+ planName1, "Hire minions",
+ new InternationalPriceMock(TWELVE), BillingPeriod.MONTHLY, 5, billingMode);
+ events.add(event);
+
+ // on plan creation, invoice for 2011/4/5 through 2011/5/5 on plan 1
+ testInvoiceGeneration(events, invoiceItems, phaseChangeDate, 1, TWELVE);
+ assertEquals(invoiceItems.size(), 4);
+
+ // on 2011/4/11, invoice for 2011/4/10 - 2011/5/10 on plan 2
+ DateTime billRunDate = buildDateTime(2011, 4, 11);
+ testInvoiceGeneration(events, invoiceItems, billRunDate, 1, TWENTY);
+ assertEquals(invoiceItems.size(), 5);
+
+ // on 2011/4/29, cancel plan 1, effective immediately
+ DateTime plan1CancelDate = buildDateTime(2011, 4, 29);
+ event = new BillingEvent(subscriptionId1, plan1CancelDate,
+ planName1, "Defeated by James Bond",
+ new InternationalPriceMock(ZERO), BillingPeriod.MONTHLY, 5, billingMode);
+ events.add(event);
+
+ // generate correcting invoice item for cancellation
+ Invoice invoice = generator.generateInvoice(events, new InvoiceItemList(), plan1CancelDate, Currency.USD);
+ BigDecimal totalToDate = invoice.getTotalAmount();
+
+ BigDecimal invoicedAmount = ZERO;
+ for (InvoiceItem item : invoiceItems) {
+ invoicedAmount = invoicedAmount.add(item.getAmount());
+ }
+
+ BigDecimal creditAmount = totalToDate.subtract(invoicedAmount).setScale(NUMBER_OF_DECIMALS);
+
+ testInvoiceGeneration(events, invoiceItems, plan1CancelDate, 2, creditAmount);
+ }
+
+ private void testInvoiceGeneration(BillingEventSet events, InvoiceItemList invoiceItems, DateTime targetDate, int expectedNumberOfItems, BigDecimal expectedAmount) {
+ Currency currency = Currency.USD;
+ Invoice invoice = generator.generateInvoice(events, invoiceItems, targetDate, currency);
+ invoiceItems.addAll(invoice.getItems());
+ assertNotNull(invoice);
+ assertEquals(invoice.getNumberOfItems(), expectedNumberOfItems);
+ assertEquals(invoice.getTotalAmount(), expectedAmount);
+ }
+
+ // TODO: Jeff C -- how do we ensure that an annual add-on is properly aligned *at the end* with the base plan?
+}
\ No newline at end of file