diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java
index e832853..366de44 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java
@@ -664,4 +664,73 @@ public class TestIntegration extends TestIntegrationBase {
checkNoMoreInvoiceToGenerate(account);
}
+
+ @Test(groups = "slow")
+ public void testFixedTermPlan() throws Exception {
+ // We take april as it has 30 days (easier to play with BCD)
+ // Set clock to the initial start date - we implicitly assume here that the account timezone is UTC
+ clock.setDay(new LocalDate(2015, 4, 1));
+
+ final AccountData accountData = getAccountData(1);
+ final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
+ accountChecker.checkAccount(account.getId(), accountData, callContext);
+
+
+ // First invoice
+ final DefaultEntitlement bpSubscription = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", "Pistol", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, /* NextEvent.BLOCK, */ NextEvent.INVOICE);
+
+
+ // Second invoice -> first recurring for Refurbish-Maintenance
+ addAOEntitlementAndCheckForCompletion(bpSubscription.getBundleId(), "Refurbish-Maintenance", ProductCategory.ADD_ON, BillingPeriod.MONTHLY, NextEvent.CREATE, /* NextEvent.BLOCK, */ NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+
+ final List<ExpectedInvoiceItemCheck> expectedInvoices = new ArrayList<ExpectedInvoiceItemCheck>();
+
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2015, 4, 1), null, InvoiceItemType.FIXED, new BigDecimal("599.95")));
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2015, 4, 1), new LocalDate(2015, 5, 1), InvoiceItemType.RECURRING, new BigDecimal("199.95")));
+ invoiceChecker.checkInvoice(account.getId(), 2, callContext, expectedInvoices);
+ expectedInvoices.clear();
+
+
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2015, 5, 1), new LocalDate(2015, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("199.95")));
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2015, 5, 1), new LocalDate(2015, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("29.95")));
+
+ // 2015-5-1
+ // Third invoice -> second recurring for Refurbish-Maintenance
+ busHandler.pushExpectedEvents(NextEvent.PHASE, NextEvent.NULL_INVOICE, NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+ clock.addDays(30); // Also = 1 month because or initial date 2015, 4, 1
+ assertListenerStatus();
+
+ invoiceChecker.checkInvoice(account.getId(), 3, callContext, expectedInvoices);
+ expectedInvoices.clear();
+
+
+ // Next 10 invoices -> third to twelve **and last** recurring for Refurbish-Maintenance
+ LocalDate startDate = new LocalDate(2015, 6, 1);
+ for (int i = 0; i < 10; i++) {
+
+ final LocalDate endDate = startDate.plusMonths(1);
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(startDate, endDate, InvoiceItemType.RECURRING, new BigDecimal("199.95")));
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(startDate, endDate, InvoiceItemType.RECURRING, new BigDecimal("29.95")));
+
+ busHandler.pushExpectedEvents(NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+ clock.addMonths(1);
+ assertListenerStatus();
+
+ invoiceChecker.checkInvoice(account.getId(), 4 + i, callContext, expectedInvoices);
+ expectedInvoices.clear();
+
+ startDate = endDate;
+ }
+
+ // We check there is no more recurring for Refurbish-Maintenance
+ expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2016, 4, 1), new LocalDate(2016, 5, 1), InvoiceItemType.RECURRING, new BigDecimal("29.95")));
+
+ busHandler.pushExpectedEvents(NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+ clock.addMonths(1);
+ assertListenerStatus();
+
+
+ invoiceChecker.checkInvoice(account.getId(), 14, callContext, expectedInvoices);
+ expectedInvoices.clear();
+ }
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/generator/FixedAndRecurringInvoiceItemGenerator.java b/invoice/src/main/java/org/killbill/billing/invoice/generator/FixedAndRecurringInvoiceItemGenerator.java
index f6726f6..38a5bfd 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/generator/FixedAndRecurringInvoiceItemGenerator.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/generator/FixedAndRecurringInvoiceItemGenerator.java
@@ -33,9 +33,12 @@ import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.catalog.api.BillingMode;
import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.Currency;
+import org.killbill.billing.catalog.api.Duration;
+import org.killbill.billing.catalog.api.PhaseType;
import org.killbill.billing.invoice.api.Invoice;
import org.killbill.billing.invoice.api.InvoiceApiException;
import org.killbill.billing.invoice.api.InvoiceItem;
+import org.killbill.billing.invoice.api.InvoiceItemType;
import org.killbill.billing.invoice.generator.InvoiceWithMetadata.SubscriptionFutureNotificationDates;
import org.killbill.billing.invoice.model.FixedPriceInvoiceItem;
import org.killbill.billing.invoice.model.InvalidDateSequenceException;
@@ -51,6 +54,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
import com.google.inject.Inject;
import static org.killbill.billing.invoice.generator.InvoiceDateUtils.calculateNumberOfWholeBillingPeriods;
@@ -84,7 +89,7 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
// Generate list of proposed invoice items based on billing events from junction-- proposed items are ALL items since beginning of time
final List<InvoiceItem> proposedItems = new ArrayList<InvoiceItem>();
- processRecurringBillingEvents(invoiceId, account.getId(), eventSet, targetDate, targetCurrency, proposedItems, perSubscriptionFutureNotificationDate);
+ processRecurringBillingEvents(invoiceId, account.getId(), eventSet, targetDate, targetCurrency, proposedItems, perSubscriptionFutureNotificationDate, existingInvoices);
processFixedBillingEvents(invoiceId, account.getId(), eventSet, targetDate, targetCurrency, proposedItems);
accountItemTree.mergeWithProposedItems(proposedItems);
@@ -92,8 +97,9 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
}
private void processRecurringBillingEvents(final UUID invoiceId, final UUID accountId, final BillingEventSet events,
- final LocalDate targetDate, final Currency currency, final List<InvoiceItem> proposedItems,
- final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDate) throws InvoiceApiException {
+ final LocalDate targetDate, final Currency currency, final List<InvoiceItem> proposedItems,
+ final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDate,
+ @Nullable final List<Invoice> existingInvoices) throws InvoiceApiException {
if (events.size() == 0) {
return;
@@ -113,11 +119,11 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
if (!events.getSubscriptionIdsWithAutoInvoiceOff().
contains(thisEvent.getSubscription().getId())) { // don't consider events for subscriptions that have auto_invoice_off
final BillingEvent adjustedNextEvent = (thisEvent.getSubscription().getId() == nextEvent.getSubscription().getId()) ? nextEvent : null;
- final List<InvoiceItem> newProposedItems = processRecurringEvent(invoiceId, accountId, thisEvent, adjustedNextEvent, targetDate, currency, logStringBuilder, events.getRecurringBillingMode(), perSubscriptionFutureNotificationDate, events.getAccountDateAndTimeZoneContext());
+ final List<InvoiceItem> newProposedItems = processRecurringEvent(invoiceId, accountId, thisEvent, adjustedNextEvent, targetDate, currency, logStringBuilder, events.getRecurringBillingMode(), perSubscriptionFutureNotificationDate, events.getAccountDateAndTimeZoneContext(), existingInvoices);
proposedItems.addAll(newProposedItems);
}
}
- final List<InvoiceItem> newProposedItems = processRecurringEvent(invoiceId, accountId, nextEvent, null, targetDate, currency, logStringBuilder, events.getRecurringBillingMode(), perSubscriptionFutureNotificationDate, events.getAccountDateAndTimeZoneContext());
+ final List<InvoiceItem> newProposedItems = processRecurringEvent(invoiceId, accountId, nextEvent, null, targetDate, currency, logStringBuilder, events.getRecurringBillingMode(), perSubscriptionFutureNotificationDate, events.getAccountDateAndTimeZoneContext(), existingInvoices);
proposedItems.addAll(newProposedItems);
log.info(logStringBuilder.toString());
@@ -166,9 +172,15 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
final LocalDate targetDate, final Currency currency,
final StringBuilder logStringBuilder, final BillingMode billingMode,
final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDate,
- final AccountDateAndTimeZoneContext dateAndTimeZoneContext) throws InvoiceApiException {
+ final AccountDateAndTimeZoneContext dateAndTimeZoneContext,
+ @Nullable final List<Invoice> existingInvoices) throws InvoiceApiException {
final List<InvoiceItem> items = new ArrayList<InvoiceItem>();
+ // For FIXEDTERM phases we need to stop when the specified duration has been reached
+ final LocalDate maxEndDate = thisEvent.getPlanPhase().getPhaseType() == PhaseType.FIXEDTERM ?
+ computeMaxEndDateForFixedTermPlanPhase(thisEvent, existingInvoices) :
+ null;
+
// Handle recurring items
final BillingPeriod billingPeriod = thisEvent.getBillingPeriod();
if (billingPeriod != BillingPeriod.NO_BILLING_PERIOD) {
@@ -187,8 +199,12 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
}
for (final RecurringInvoiceItemData itemDatum : itemDataWithNextBillingCycleDate.getItemData()) {
- final BigDecimal rate = thisEvent.getRecurringPrice();
+ // Stop if there a maxEndDate and we have reached it
+ if (maxEndDate != null && maxEndDate.compareTo(itemDatum.getEndDate()) < 0) {
+ break;
+ }
+ final BigDecimal rate = thisEvent.getRecurringPrice();
if (rate != null) {
final BigDecimal amount = KillBillMoney.of(itemDatum.getNumberOfCycles().multiply(rate), currency);
final RecurringInvoiceItem recurringItem = new RecurringInvoiceItem(invoiceId,
@@ -197,7 +213,8 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
thisEvent.getSubscription().getId(),
thisEvent.getPlan().getName(),
thisEvent.getPlanPhase().getName(),
- itemDatum.getStartDate(), itemDatum.getEndDate(),
+ itemDatum.getStartDate(),
+ itemDatum.getEndDate(),
amount, rate, currency);
items.add(recurringItem);
}
@@ -216,6 +233,38 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
return items;
}
+ //
+ // Go through invoices in reverse order and for each invoice extract a possible item for that subscription and the phaseName associated with this billing event
+ // Computes the startDate for that first one seen in that uninterrupted sequence and then add the FIXEDTERM duration to compute the max endDate
+ //
+ private LocalDate computeMaxEndDateForFixedTermPlanPhase(final BillingEvent thisEvent, @Nullable final List<Invoice> existingInvoices) {
+ if (existingInvoices == null || existingInvoices.isEmpty()) {
+ return null;
+ }
+
+ LocalDate firstStartDate = null;
+ for (int i = existingInvoices.size() - 1; i >= 0; i--) {
+ final Invoice cur = existingInvoices.get(i);
+
+ final InvoiceItem matchItem = Iterables.tryFind(cur.getInvoiceItems(), new Predicate<InvoiceItem>() {
+ @Override
+ public boolean apply(final InvoiceItem input) {
+ return input.getInvoiceItemType() == InvoiceItemType.RECURRING &&
+ thisEvent.getSubscription().getId().equals(input.getSubscriptionId()) &&
+ thisEvent.getPlanPhase().getName().equals(input.getPhaseName());
+ }
+ }).orNull();
+
+ if (matchItem == null) {
+ break;
+ }
+ firstStartDate = matchItem.getStartDate();
+ }
+
+ final LocalDate result = addDurationToLocalDate(firstStartDate, thisEvent.getPlanPhase().getDuration());
+ return result;
+ }
+
private void updatePerSubscriptionNextNotificationDate(final UUID subscriptionId, final LocalDate nextBillingCycleDate, final List<InvoiceItem> newProposedItems, final BillingMode billingMode, final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDates) {
LocalDate nextNotificationDate = null;
@@ -359,4 +408,26 @@ public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator
}
}
}
+
+ // That code should belong to Duration/DefaultDuration but requires a change api (not possible for 0.16.3, but will be moreved in 0.17.0)
+ private LocalDate addDurationToLocalDate(@Nullable final LocalDate inputDate, final Duration duration) {
+
+ if (inputDate == null) {
+ return inputDate;
+ }
+
+ switch (duration.getUnit()) {
+ case DAYS:
+ return inputDate.plusDays(duration.getNumber());
+ case MONTHS:
+ return inputDate.plusMonths(duration.getNumber());
+ case YEARS:
+ return inputDate.plusYears(duration.getNumber());
+ case UNLIMITED:
+ return inputDate.plusYears(100);
+ default:
+ throw new IllegalStateException("Unknwon duration " + duration.getUnit());
+ }
+ }
+
}