killbill-uncached

subscription: fix NPE in DefaultInternalBillingApi This

3/13/2018 6:45:50 AM

Details

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 5536865..559519d 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
@@ -1,7 +1,7 @@
 /*
  * Copyright 2010-2013 Ning, Inc.
- * Copyright 2014-2016 Groupon, Inc
- * Copyright 2014-2016 The Billing Project, LLC
+ * Copyright 2014-2018 Groupon, Inc
+ * Copyright 2014-2018 The Billing Project, LLC
  *
  * The Billing Project 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
@@ -26,7 +26,6 @@ import java.util.UUID;
 import org.joda.time.DateTime;
 import org.joda.time.Interval;
 import org.joda.time.LocalDate;
-import org.killbill.billing.ErrorCode;
 import org.killbill.billing.account.api.Account;
 import org.killbill.billing.account.api.AccountData;
 import org.killbill.billing.api.TestApiListener.NextEvent;
@@ -43,6 +42,7 @@ import org.killbill.billing.entitlement.api.Entitlement.EntitlementActionPolicy;
 import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
 import org.killbill.billing.entitlement.api.SubscriptionBundle;
 import org.killbill.billing.entitlement.api.SubscriptionEventType;
+import org.killbill.billing.invoice.api.DryRunArguments;
 import org.killbill.billing.invoice.api.DryRunType;
 import org.killbill.billing.invoice.api.Invoice;
 import org.killbill.billing.invoice.api.InvoiceApiException;
@@ -55,13 +55,132 @@ import org.testng.annotations.Test;
 
 import com.google.common.collect.ImmutableList;
 
+import static org.killbill.billing.ErrorCode.INVOICE_NOTHING_TO_DO;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotEquals;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
 
 public class TestIntegration extends TestIntegrationBase {
 
+    @Test(groups = "slow", description = "https://github.com/killbill/killbill/issues/897")
+    public void testFutureCancelBPWithAOBeforePhase() 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(2012, 4, 1));
+
+        final AccountData accountData = getAccountData(1);
+        final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
+        accountChecker.checkAccount(account.getId(), accountData, callContext);
+
+        final List<ExpectedInvoiceItemCheck> expectedInvoices = new ArrayList<ExpectedInvoiceItemCheck>();
+
+        //
+        // CREATE SUBSCRIPTION AND EXPECT BOTH EVENTS: NextEvent.CREATE, NextEvent.BLOCK NextEvent.INVOICE
+        //
+        final DefaultEntitlement bpSubscription = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+        // Check bundle after BP got created otherwise we get an error from auditApi.
+        subscriptionChecker.checkSubscriptionCreated(bpSubscription.getId(), internalCallContext);
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 4, 1), null, InvoiceItemType.FIXED, new BigDecimal("0")));
+        invoiceChecker.checkInvoice(account.getId(), 1, callContext, expectedInvoices);
+        expectedInvoices.clear();
+
+        addMonthsAndCheckForCompletion(1, NextEvent.PHASE, NextEvent.INVOICE, NextEvent.INVOICE_PAYMENT, NextEvent.PAYMENT);
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 5, 1), new LocalDate(2012, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("249.95")));
+        final Invoice invoice2 = invoiceChecker.checkInvoice(account.getId(), 2, callContext, expectedInvoices);
+        paymentChecker.checkPayment(account.getId(), 1, callContext, new ExpectedPaymentCheck(new LocalDate(2012, 5, 1), new BigDecimal("249.95"), TransactionStatus.SUCCESS, invoice2.getId(), Currency.USD));
+        expectedInvoices.clear();
+
+        //
+        // ADD ADD_ON (Laser-Scope has a START_OF_SUBSCRIPTION create alignment)
+        //
+        addAOEntitlementAndCheckForCompletion(bpSubscription.getBundleId(), "Laser-Scope", ProductCategory.ADD_ON, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 5, 1), new LocalDate(2012, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("999.95")));
+        final Invoice invoice3 = invoiceChecker.checkInvoice(account.getId(), 3, callContext, expectedInvoices);
+        paymentChecker.checkPayment(account.getId(), 2, callContext, new ExpectedPaymentCheck(new LocalDate(2012, 5, 1), new BigDecimal("999.95"), TransactionStatus.SUCCESS, invoice3.getId(), Currency.USD));
+        expectedInvoices.clear();
+
+        //
+        // CANCEL BP
+        //
+        cancelEntitlementAndCheckForCompletion(bpSubscription, EntitlementActionPolicy.END_OF_TERM, BillingActionPolicy.END_OF_TERM);
+
+        // Verify we can trigger a dry-run invoice while the cancellation is pending
+        final DryRunArguments dryRun = new TestDryRunArguments(DryRunType.SUBSCRIPTION_ACTION, null, null, null, null, null, SubscriptionEventType.STOP_BILLING, bpSubscription.getId(),
+                                                               bpSubscription.getBundleId(), null, null);
+        try {
+            invoiceUserApi.triggerInvoiceGeneration(account.getId(), new LocalDate(2012, 6, 1), dryRun, callContext);
+            fail();
+        } catch (final InvoiceApiException e) {
+            assertEquals(e.getCode(), INVOICE_NOTHING_TO_DO.getCode());
+        }
+
+        addMonthsAndCheckForCompletion(1, NextEvent.BLOCK, NextEvent.BLOCK, NextEvent.CANCEL, NextEvent.CANCEL, NextEvent.NULL_INVOICE, NextEvent.NULL_INVOICE);
+
+        checkNoMoreInvoiceToGenerate(account);
+    }
+
+    @Test(groups = "slow", description = "https://github.com/killbill/killbill/issues/897")
+    public void testFutureCancelBPWithAOAfterPhase() 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(2012, 4, 1));
+
+        final AccountData accountData = getAccountData(1);
+        final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
+        accountChecker.checkAccount(account.getId(), accountData, callContext);
+
+        final List<ExpectedInvoiceItemCheck> expectedInvoices = new ArrayList<ExpectedInvoiceItemCheck>();
+
+        //
+        // CREATE SUBSCRIPTION AND EXPECT BOTH EVENTS: NextEvent.CREATE, NextEvent.BLOCK NextEvent.INVOICE
+        //
+        final DefaultEntitlement bpSubscription = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+        // Check bundle after BP got created otherwise we get an error from auditApi.
+        subscriptionChecker.checkSubscriptionCreated(bpSubscription.getId(), internalCallContext);
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 4, 1), null, InvoiceItemType.FIXED, new BigDecimal("0")));
+        invoiceChecker.checkInvoice(account.getId(), 1, callContext, expectedInvoices);
+        expectedInvoices.clear();
+
+        //
+        // ADD ADD_ON
+        //
+        addAOEntitlementAndCheckForCompletion(bpSubscription.getBundleId(), "Telescopic-Scope", ProductCategory.ADD_ON, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 4, 1), new LocalDate(2012, 5, 1), InvoiceItemType.RECURRING, new BigDecimal("399.95")));
+        final Invoice invoice2 = invoiceChecker.checkInvoice(account.getId(), 2, callContext, expectedInvoices);
+        paymentChecker.checkPayment(account.getId(), 1, callContext, new ExpectedPaymentCheck(new LocalDate(2012, 4, 1), new BigDecimal("399.95"), TransactionStatus.SUCCESS, invoice2.getId(), Currency.USD));
+        expectedInvoices.clear();
+
+        // Go past the PHASE events
+        addMonthsAndCheckForCompletion(1, NextEvent.PHASE, NextEvent.PHASE, NextEvent.NULL_INVOICE, NextEvent.NULL_INVOICE, NextEvent.INVOICE, NextEvent.INVOICE_PAYMENT, NextEvent.PAYMENT);
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 5, 1), new LocalDate(2012, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("249.95")));
+        expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2012, 5, 1), new LocalDate(2012, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("999.95")));
+        final Invoice invoice3 = invoiceChecker.checkInvoice(account.getId(), 3, callContext, expectedInvoices);
+        expectedInvoices.clear();
+        paymentChecker.checkPayment(account.getId(), 2, callContext, new ExpectedPaymentCheck(new LocalDate(2012, 5, 1), new BigDecimal("1249.9"), TransactionStatus.SUCCESS, invoice3.getId(), Currency.USD));
+        expectedInvoices.clear();
+
+        //
+        // CANCEL BP EOT
+        //
+        cancelEntitlementAndCheckForCompletion(bpSubscription, EntitlementActionPolicy.END_OF_TERM, BillingActionPolicy.END_OF_TERM);
+
+        // Verify we can trigger a dry-run invoice while the cancellation is pending
+        final DryRunArguments dryRun = new TestDryRunArguments(DryRunType.SUBSCRIPTION_ACTION, null, null, null, null, null, SubscriptionEventType.STOP_BILLING, bpSubscription.getId(),
+                                                               bpSubscription.getBundleId(), null, null);
+        try {
+            invoiceUserApi.triggerInvoiceGeneration(account.getId(), new LocalDate(2012, 6, 1), dryRun, callContext);
+            fail();
+        } catch (final InvoiceApiException e) {
+            assertEquals(e.getCode(), INVOICE_NOTHING_TO_DO.getCode());
+        }
+
+        addMonthsAndCheckForCompletion(1, NextEvent.BLOCK, NextEvent.BLOCK, NextEvent.CANCEL, NextEvent.CANCEL, NextEvent.NULL_INVOICE, NextEvent.NULL_INVOICE);
+
+        checkNoMoreInvoiceToGenerate(account);
+    }
+
     @Test(groups = "slow")
     public void testCancelBPWithAOTheSameDay() throws Exception {
         // We take april as it has 30 days (easier to play with BCD)
@@ -291,7 +410,7 @@ public class TestIntegration extends TestIntegrationBase {
            invoiceUserApi.triggerInvoiceGeneration(account.getId(), clock.getUTCToday(), dryRun, callContext);
             Assert.fail("Call should return no invoices");
         } catch (final InvoiceApiException e) {
-            assertEquals(e.getCode(), ErrorCode.INVOICE_NOTHING_TO_DO.getCode());
+            assertEquals(e.getCode(), INVOICE_NOTHING_TO_DO.getCode());
         }
 
         baseEntitlement = changeEntitlementAndCheckForCompletion(baseEntitlement, "Pistol", BillingPeriod.MONTHLY, null);
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java
index 6c98b7a..b9620f5 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java
@@ -1,7 +1,7 @@
 /*
  * Copyright 2010-2013 Ning, Inc.
- * Copyright 2014-2017 Groupon, Inc
- * Copyright 2014-2017 The Billing Project, LLC
+ * Copyright 2014-2018 Groupon, Inc
+ * Copyright 2014-2018 The Billing Project, LLC
  *
  * The Billing Project 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
@@ -62,6 +62,7 @@ import org.killbill.billing.entitlement.api.BlockingState;
 import org.killbill.billing.entitlement.api.BlockingStateType;
 import org.killbill.billing.entitlement.api.DefaultEntitlement;
 import org.killbill.billing.entitlement.api.Entitlement;
+import org.killbill.billing.entitlement.api.Entitlement.EntitlementActionPolicy;
 import org.killbill.billing.entitlement.api.EntitlementApi;
 import org.killbill.billing.entitlement.api.EntitlementApiException;
 import org.killbill.billing.entitlement.api.SubscriptionApi;
@@ -125,9 +126,7 @@ import org.skife.config.TimeSpan;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testng.Assert;
-import org.testng.IHookCallBack;
 import org.testng.IHookable;
-import org.testng.ITestResult;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.BeforeMethod;
@@ -760,6 +759,26 @@ public class TestIntegrationBase extends BeatrixTestSuiteWithEmbeddedDB implemen
         }, events);
     }
 
+    protected DefaultEntitlement cancelEntitlementAndCheckForCompletion(final Entitlement entitlement,
+                                                                        final EntitlementActionPolicy entitlementActionPolicy,
+                                                                        final BillingActionPolicy billingActionPolicy,
+                                                                        final NextEvent... events) {
+        return (DefaultEntitlement) doCallAndCheckForCompletion(new Function<Void, Entitlement>() {
+            @Override
+            public Entitlement apply(@Nullable final Void dontcare) {
+                try {
+                    // Need to fetch again to get latest CTD updated from the system
+                    Entitlement refreshedEntitlement = entitlementApi.getEntitlementForId(entitlement.getId(), callContext);
+                    refreshedEntitlement = refreshedEntitlement.cancelEntitlementWithPolicyOverrideBillingPolicy(entitlementActionPolicy, billingActionPolicy, ImmutableList.<PluginProperty>of(), callContext);
+                    return refreshedEntitlement;
+                } catch (final EntitlementApiException e) {
+                    fail(e.getMessage());
+                    return null;
+                }
+            }
+        }, events);
+    }
+
     protected void fullyAdjustInvoiceAndCheckForCompletion(final Account account, final Invoice invoice, final NextEvent... events) {
         doCallAndCheckForCompletion(new Function<Void, Void>() {
             @Override
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java b/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java
index 05c2133..6472ef5 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java
@@ -679,12 +679,29 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
 
         transitions = new LinkedList<SubscriptionBaseTransition>();
 
+        // DefaultSubscriptionDao#buildBundleSubscriptions may have added an out-of-order cancellation event (https://github.com/killbill/killbill/issues/897)
+        final SubscriptionBaseEvent cancellationEvent = Iterables.tryFind(inputEvents,
+                                                                          new Predicate<SubscriptionBaseEvent>() {
+                                                                              @Override
+                                                                              public boolean apply(final SubscriptionBaseEvent input) {
+                                                                                  return input.getType() == EventType.API_USER && ((ApiEvent) input).getApiEventType() == ApiEventType.CANCEL;
+                                                                              }
+                                                                          }).orNull();
         for (final SubscriptionBaseEvent cur : inputEvents) {
-
             if (!cur.isActive()) {
                 continue;
             }
 
+            if (cancellationEvent != null) {
+                if (cur.getId().compareTo(cancellationEvent.getId()) == 0) {
+                    // Keep the cancellation event
+                } else if (cur.getType() == EventType.API_USER && (((ApiEvent) cur).getApiEventType() == ApiEventType.TRANSFER || ((ApiEvent) cur).getApiEventType() == ApiEventType.CREATE)) {
+                    // Keep the initial event (SOT use-case)
+                } else if (cur.getEffectiveDate().compareTo(cancellationEvent.getEffectiveDate()) >= 0) {
+                    // Event to ignore past cancellation date
+                    continue;
+                }
+            }
 
             ApiEventType apiEventType = null;
             boolean isFromDisk = true;
@@ -795,7 +812,14 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
             public int compare(final SubscriptionBaseEvent o1, final SubscriptionBaseEvent o2) {
                 int res = o1.getEffectiveDate().compareTo(o2.getEffectiveDate());
                 if (res == 0) {
-                    res = o1.getTotalOrdering() < (o2.getTotalOrdering()) ? -1 : 1;
+                    // In-memory events have a total order of 0, make sure they are after on disk event
+                    if (o1.getTotalOrdering() == 0 && o2.getTotalOrdering() > 0) {
+                        return 1;
+                    } else if (o1.getTotalOrdering() > 0 && o2.getTotalOrdering() == 0) {
+                        return -1;
+                    } else {
+                        res = o1.getTotalOrdering() < (o2.getTotalOrdering()) ? -1 : 1;
+                    }
                 }
                 return res;
             }
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestDefaultSubscriptionBase.java b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestDefaultSubscriptionBase.java
new file mode 100644
index 0000000..9dff853
--- /dev/null
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestDefaultSubscriptionBase.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2014-2018 Groupon, Inc
+ * Copyright 2014-2018 The Billing Project, LLC
+ *
+ * The Billing Project 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.subscription.api.user;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.UUID;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
+import org.killbill.billing.subscription.SubscriptionTestSuiteNoDB;
+import org.killbill.billing.subscription.events.SubscriptionBaseEvent;
+import org.killbill.billing.subscription.events.phase.PhaseEventBuilder;
+import org.killbill.billing.subscription.events.phase.PhaseEventData;
+import org.killbill.billing.subscription.events.user.ApiEventBuilder;
+import org.killbill.billing.subscription.events.user.ApiEventCancel;
+import org.killbill.billing.subscription.events.user.ApiEventCreate;
+import org.killbill.billing.subscription.events.user.ApiEventType;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import static org.killbill.billing.subscription.events.user.ApiEventType.CREATE;
+
+public class TestDefaultSubscriptionBase extends SubscriptionTestSuiteNoDB {
+
+    @Test(groups = "fast")
+    public void testCancelSOT() throws Exception {
+        final DefaultSubscriptionBase subscriptionBase = new DefaultSubscriptionBase(new SubscriptionBuilder());
+
+        final UUID subscriptionId = UUID.randomUUID();
+        final List<SubscriptionBaseEvent> inputEvents = new LinkedList<SubscriptionBaseEvent>();
+        inputEvents.add(new ApiEventCreate(new ApiEventBuilder().setApiEventType(CREATE)
+                                                                .setEventPlan("laser-scope-monthly")
+                                                                .setEventPlanPhase("laser-scope-monthly-discount")
+                                                                .setEventPriceList("DEFAULT")
+                                                                .setFromDisk(true)
+                                                                .setUuid(UUID.randomUUID())
+                                                                .setSubscriptionId(subscriptionId)
+                                                                .setCreatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setUpdatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setEffectiveDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setTotalOrdering(3)
+                                                                .setActive(true)));
+        inputEvents.add(new ApiEventCancel(new ApiEventBuilder().setApiEventType(ApiEventType.CANCEL)
+                                                                .setEventPlan(null)
+                                                                .setEventPlanPhase(null)
+                                                                .setEventPriceList(null)
+                                                                .setFromDisk(false)
+                                                                .setUuid(UUID.randomUUID())
+                                                                .setSubscriptionId(subscriptionId)
+                                                                .setCreatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setUpdatedDate(null)
+                                                                .setEffectiveDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setTotalOrdering(0) // In-memory event
+                                                                .setActive(true)));
+        subscriptionBase.rebuildTransitions(inputEvents, catalog);
+
+        Assert.assertEquals(subscriptionBase.getAllTransitions().size(), 2);
+        Assert.assertNull(subscriptionBase.getAllTransitions().get(0).getPreviousState());
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(0).getNextState(), EntitlementState.ACTIVE);
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(0).getEffectiveTransitionTime(), new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC));
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(1).getPreviousState(), EntitlementState.ACTIVE);
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(1).getNextState(), EntitlementState.CANCELLED);
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(1).getEffectiveTransitionTime(), new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC));
+    }
+
+    @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/897")
+    public void testFutureCancelBeforePhase() throws Exception {
+        final DefaultSubscriptionBase subscriptionBase = new DefaultSubscriptionBase(new SubscriptionBuilder());
+
+        final UUID subscriptionId = UUID.randomUUID();
+        final List<SubscriptionBaseEvent> inputEvents = new LinkedList<SubscriptionBaseEvent>();
+        inputEvents.add(new ApiEventCreate(new ApiEventBuilder().setApiEventType(CREATE)
+                                                                .setEventPlan("laser-scope-monthly")
+                                                                .setEventPlanPhase("laser-scope-monthly-discount")
+                                                                .setEventPriceList("DEFAULT")
+                                                                .setFromDisk(true)
+                                                                .setUuid(UUID.randomUUID())
+                                                                .setSubscriptionId(subscriptionId)
+                                                                .setCreatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setUpdatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setEffectiveDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setTotalOrdering(3)
+                                                                .setActive(true)));
+        inputEvents.add(new PhaseEventData(new PhaseEventBuilder().setPhaseName("laser-scope-monthly-evergreen")
+                                                                  .setUuid(UUID.randomUUID())
+                                                                  .setSubscriptionId(subscriptionId)
+                                                                  .setCreatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                  .setUpdatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                  .setEffectiveDate(new DateTime(2012, 6, 1, 0, 0, DateTimeZone.UTC))
+                                                                  .setTotalOrdering(4)
+                                                                  .setActive(true)));
+        inputEvents.add(new ApiEventCancel(new ApiEventBuilder().setApiEventType(ApiEventType.CANCEL)
+                                                                .setEventPlan(null)
+                                                                .setEventPlanPhase(null)
+                                                                .setEventPriceList(null)
+                                                                .setFromDisk(false)
+                                                                .setUuid(UUID.randomUUID())
+                                                                .setSubscriptionId(subscriptionId)
+                                                                .setCreatedDate(new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setUpdatedDate(null)
+                                                                .setEffectiveDate(new DateTime(2012, 6, 1, 0, 0, DateTimeZone.UTC))
+                                                                .setTotalOrdering(0) // In-memory event
+                                                                .setActive(true)));
+        subscriptionBase.rebuildTransitions(inputEvents, catalog);
+
+        Assert.assertEquals(subscriptionBase.getAllTransitions().size(), 2);
+        Assert.assertNull(subscriptionBase.getAllTransitions().get(0).getPreviousState());
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(0).getNextState(), EntitlementState.ACTIVE);
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(0).getEffectiveTransitionTime(), new DateTime(2012, 5, 1, 0, 0, DateTimeZone.UTC));
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(1).getPreviousState(), EntitlementState.ACTIVE);
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(1).getNextState(), EntitlementState.CANCELLED);
+        Assert.assertEquals(subscriptionBase.getAllTransitions().get(1).getEffectiveTransitionTime(), new DateTime(2012, 6, 1, 0, 0, DateTimeZone.UTC));
+    }
+}