killbill-memoizeit

subscription: Fix several issues related to future subscription

10/7/2016 9:12:10 PM

Details

diff --git a/entitlement/src/test/java/org/killbill/billing/entitlement/engine/core/TestEntitlementUtils.java b/entitlement/src/test/java/org/killbill/billing/entitlement/engine/core/TestEntitlementUtils.java
index 301d6f0..2f98acc 100644
--- a/entitlement/src/test/java/org/killbill/billing/entitlement/engine/core/TestEntitlementUtils.java
+++ b/entitlement/src/test/java/org/killbill/billing/entitlement/engine/core/TestEntitlementUtils.java
@@ -354,8 +354,14 @@ public class TestEntitlementUtils extends EntitlementTestSuiteWithEmbeddedDB {
         Assert.assertEquals(entitlementApi.getEntitlementForId(addOnEntitlement.getId(), callContext).getEffectiveEndDate(), addOn1CancellationDate);
         Assert.assertEquals(entitlementApi.getEntitlementForId(addOn2Entitlement.getId(), callContext).getEffectiveEndDate(), baseCancellationDate);
 
-        testListener.pushExpectedEvents(NextEvent.CANCEL, NextEvent.CANCEL, NextEvent.CANCEL, NextEvent.BLOCK, NextEvent.BLOCK, NextEvent.BLOCK);
-        clock.setDay(new LocalDate(2013, 10, 30));
+        // Move to addOn1CancellationDate
+        testListener.pushExpectedEvents(NextEvent.CANCEL, NextEvent.BLOCK);
+        clock.setDay(new LocalDate(2013, 9, 9));
+        assertListenerStatus();
+
+
+        testListener.pushExpectedEvents(NextEvent.CANCEL, NextEvent.CANCEL, NextEvent.BLOCK, NextEvent.BLOCK);
+        clock.setDay(new LocalDate(2013, 10, 10));
         assertListenerStatus();
 
         // Verify the cancellation dates
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 3db0cd5..90a581e 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
@@ -20,6 +20,8 @@ package org.killbill.billing.subscription.api.user;
 
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.UUID;
@@ -60,6 +62,9 @@ import org.killbill.clock.Clock;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
+
 public class DefaultSubscriptionBase extends EntityBase implements SubscriptionBase {
 
     private static final Logger log = LoggerFactory.getLogger(DefaultSubscriptionBase.class);
@@ -343,6 +348,7 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
         final SubscriptionBaseTransitionDataIterator it = new SubscriptionBaseTransitionDataIterator(
                 clock, transitions, Order.DESC_FROM_FUTURE,
                 Visibility.FROM_DISK_ONLY, TimeLimit.PAST_OR_PRESENT_ONLY);
+
         return it.hasNext() ? it.next() : null;
     }
 
@@ -574,6 +580,8 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
 
         this.events = inputEvents;
 
+        filterOutDuplicateCancelEvents(events);
+
         UUID nextUserToken = null;
 
         UUID nextEventId = null;
@@ -599,8 +607,8 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
                 continue;
             }
 
-            ApiEventType apiEventType = null;
 
+            ApiEventType apiEventType = null;
             boolean isFromDisk = true;
 
             nextEventId = cur.getId();
@@ -690,6 +698,71 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
             prevEventId = nextEventId;
             prevCreatedDate = nextCreatedDate;
             previousBillingCycleDayLocal = nextBillingCycleDayLocal;
+
+        }
+    }
+
+    //
+    // Hardening against data integrity issues where we have multiple active CANCEL (See #619):
+    // We skip any cancel events after the first one (subscription cannot be cancelled multiple times).
+    // The code should prevent such cases from happening but because of #619, some invalid data could be there so to be safe we added this code
+    //
+    // Also we remove !onDisk cancel events when there is an onDisk cancel event (can happen during the path where we process the base plan cancel notification, and are
+    // in the process of adding the new cancel events for the AO)
+    //
+    private void filterOutDuplicateCancelEvents(final List<SubscriptionBaseEvent> inputEvents) {
+
+        Collections.sort(inputEvents, new Comparator<SubscriptionBaseEvent>() {
+            @Override
+            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;
+                }
+                return res;
+            }
+        });
+
+        final boolean isCancelled = Iterables.any(inputEvents, new Predicate<SubscriptionBaseEvent>() {
+            @Override
+            public boolean apply(final SubscriptionBaseEvent input) {
+                if (input.isActive() && input.getType() == EventType.API_USER) {
+                    final ApiEvent userEV = (ApiEvent) input;
+                    if (userEV.getApiEventType() == ApiEventType.CANCEL && userEV.isFromDisk()) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+        });
+
+        if (!isCancelled) {
+            return;
+        }
+
+
+        boolean foundFirstOnDiskCancel = false;
+        final Iterator<SubscriptionBaseEvent> it =  inputEvents.iterator();
+        while(it.hasNext()) {
+            final SubscriptionBaseEvent input = it.next();
+            if (!input.isActive()) {
+                continue;
+            }
+
+            if (input.getType() == EventType.API_USER) {
+                final ApiEvent userEV = (ApiEvent) input;
+                if (userEV.getApiEventType() == ApiEventType.CANCEL) {
+                    if (userEV.isFromDisk()) {
+                        if (!foundFirstOnDiskCancel) {
+                            foundFirstOnDiskCancel = true;
+                        } else {
+                            it.remove();
+                        }
+                    } else {
+                        it.remove();
+                    }
+                }
+            }
         }
     }
 }
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/user/SubscriptionBaseTransitionDataIterator.java b/subscription/src/main/java/org/killbill/billing/subscription/api/user/SubscriptionBaseTransitionDataIterator.java
index 4871f6a..e7acad6 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/user/SubscriptionBaseTransitionDataIterator.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/user/SubscriptionBaseTransitionDataIterator.java
@@ -29,7 +29,6 @@ public class SubscriptionBaseTransitionDataIterator implements Iterator<Subscrip
     private final Iterator<SubscriptionBaseTransition> it;
     private final TimeLimit timeLimit;
     private final Visibility visibility;
-    private final Order order;
 
     private SubscriptionBaseTransition next;
 
@@ -55,7 +54,6 @@ public class SubscriptionBaseTransitionDataIterator implements Iterator<Subscrip
         this.clock = clock;
         this.timeLimit = timeLimit;
         this.visibility = visibility;
-        this.order = order;
         this.next = null;
     }
 
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/core/DefaultSubscriptionBaseService.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/core/DefaultSubscriptionBaseService.java
index 820c07f..fd0e273 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/core/DefaultSubscriptionBaseService.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/core/DefaultSubscriptionBaseService.java
@@ -153,6 +153,12 @@ public class DefaultSubscriptionBaseService implements EventListener, Subscripti
                 return;
             }
 
+            final SubscriptionBaseTransitionData transition = subscription.getTransitionFromEvent(event, seqId);
+            if (transition == null) {
+                log.warn("Skipping event ='{}', no matching transition was built", event.getType());
+                return;
+            }
+
             boolean eventSent = false;
             if (event.getType() == EventType.PHASE) {
                 eventSent = onPhaseEvent(subscription, event, context);
@@ -165,7 +171,6 @@ public class DefaultSubscriptionBaseService implements EventListener, Subscripti
 
             if (!eventSent) {
                 // Methods above invoking the DAO will send this event directly from the transaction
-                final SubscriptionBaseTransitionData transition = subscription.getTransitionFromEvent(event, seqId);
                 final BusEvent busEvent = new DefaultEffectiveSubscriptionEvent(transition,
                                                                                 subscription.getAlignStartDate(),
                                                                                 context.getUserToken(),
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java
index b39f487..75eabb5 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java
@@ -688,8 +688,14 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
     }
 
     private void cancelFutureEventsFromTransaction(final UUID subscriptionId, final DateTime effectiveDate, final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final boolean includingBCDChange, final InternalCallContext context) {
-        final List<SubscriptionEventModelDao> eventModels = entitySqlDaoWrapperFactory.become(SubscriptionEventSqlDao.class).getFutureActiveEventForSubscription(subscriptionId.toString(), effectiveDate.toDate(), context);
+        final List<SubscriptionEventModelDao> eventModels = entitySqlDaoWrapperFactory.become(SubscriptionEventSqlDao.class).getFutureOrPresentActiveEventForSubscription(subscriptionId.toString(), effectiveDate.toDate(), context);
         for (final SubscriptionEventModelDao cur : eventModels) {
+
+            // Skip CREATE event (because of date equality in the query and we don't want to invalidate CREATE event that match a CANCEL event)
+            if (cur.getEventType() == EventType.API_USER && cur.getUserType()== ApiEventType.CREATE) {
+                continue;
+            }
+
             if (includingBCDChange || cur.getEventType() != EventType.BCD_UPDATE) {
                 unactivateEventFromTransaction(cur, entitySqlDaoWrapperFactory, context);
             }
@@ -707,6 +713,7 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
 
         SubscriptionEventModelDao futureEvent = null;
         final Date now = clock.getUTCNow().toDate();
+
         final List<SubscriptionEventModelDao> eventModels = dao.become(SubscriptionEventSqlDao.class).getFutureActiveEventForSubscription(subscriptionId.toString(), now, context);
         for (final SubscriptionEventModelDao cur : eventModels) {
             if (cur.getEventType() == type &&
@@ -985,13 +992,15 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
                                                      final SubscriptionBaseEvent immediateEvent, final int seqId, final InternalCallContext context) {
         try {
             final SubscriptionBaseTransitionData transition = subscription.getTransitionFromEvent(immediateEvent, seqId);
-            final BusEvent busEvent = new DefaultEffectiveSubscriptionEvent(transition,
-                                                                            subscription.getAlignStartDate(),
-                                                                            context.getUserToken(),
-                                                                            context.getAccountRecordId(),
-                                                                            context.getTenantRecordId());
-
-            eventBus.postFromTransaction(busEvent, entitySqlDaoWrapperFactory.getHandle().getConnection());
+            if (transition != null) {
+                final BusEvent busEvent = new DefaultEffectiveSubscriptionEvent(transition,
+                                                                                subscription.getAlignStartDate(),
+                                                                                context.getUserToken(),
+                                                                                context.getAccountRecordId(),
+                                                                                context.getTenantRecordId());
+
+                eventBus.postFromTransaction(busEvent, entitySqlDaoWrapperFactory.getHandle().getConnection());
+            }
         } catch (final EventBusException e) {
             log.warn("Failed to post effective event for subscriptionId='{}'", subscription.getId(), e);
         }
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.java
index 4ead49c..0e2a8ab 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.java
@@ -50,6 +50,11 @@ public interface SubscriptionEventSqlDao extends EntitySqlDao<SubscriptionEventM
                                                                                @BindBean final InternalTenantContext context);
 
     @SqlQuery
+    public List<SubscriptionEventModelDao> getFutureOrPresentActiveEventForSubscription(@Bind("subscriptionId") String subscriptionId,
+                                                                               @Bind("now") Date now,
+                                                                               @BindBean final InternalTenantContext context);
+
+    @SqlQuery
     public List<SubscriptionEventModelDao> getEventsForSubscription(@Bind("subscriptionId") String subscriptionId,
                                                                     @BindBean final InternalTenantContext context);
 
diff --git a/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.sql.stg b/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.sql.stg
index 49cca79..4b0aa5a 100644
--- a/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.sql.stg
+++ b/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/SubscriptionEventSqlDao.sql.stg
@@ -70,6 +70,19 @@ and effective_date > :now
 ;
 >>
 
+getFutureOrPresentActiveEventForSubscription() ::= <<
+select <allTableFields()>
+, record_id as total_ordering
+from <tableName()>
+where
+subscription_id = :subscriptionId
+and is_active = true
+and effective_date >= :now
+<AND_CHECK_TENANT()>
+<defaultOrderBy()>
+;
+>>
+
 getEventsForSubscription() ::= <<
 select <allTableFields()>
 , record_id as total_ordering
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/alignment/TestPlanAligner.java b/subscription/src/test/java/org/killbill/billing/subscription/alignment/TestPlanAligner.java
index 2ed95c3..869e886 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/alignment/TestPlanAligner.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/alignment/TestPlanAligner.java
@@ -16,6 +16,7 @@
 
 package org.killbill.billing.subscription.alignment;
 
+import java.util.ArrayList;
 import java.util.List;
 
 import org.joda.time.DateTime;
@@ -38,7 +39,6 @@ import org.testng.Assert;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
-import com.google.common.collect.ImmutableList;
 
 public class TestPlanAligner extends SubscriptionTestSuiteNoDB {
 
@@ -159,7 +159,9 @@ public class TestPlanAligner extends SubscriptionTestSuiteNoDB {
                                                                     phaseType,
                                                                     ApiEventType.CREATE
                                                                    );
-        defaultSubscriptionBase.rebuildTransitions(ImmutableList.<SubscriptionBaseEvent>of(event), catalogService.getFullCatalog(true, true, internalCallContext));
+        final List<SubscriptionBaseEvent> events = new ArrayList<SubscriptionBaseEvent>();
+        events.add(event);
+        defaultSubscriptionBase.rebuildTransitions(events, catalogService.getFullCatalog(true, true, internalCallContext));
 
         Assert.assertEquals(defaultSubscriptionBase.getAllTransitions().size(), 1);
         Assert.assertNull(defaultSubscriptionBase.getAllTransitions().get(0).getPreviousPhase());
@@ -184,7 +186,11 @@ public class TestPlanAligner extends SubscriptionTestSuiteNoDB {
                                                                     ApiEventType.CHANGE
                                                                    );
 
-        defaultSubscriptionBase.rebuildTransitions(ImmutableList.<SubscriptionBaseEvent>of(previousEvent, event), catalogService.getFullCatalog(true, true, internalCallContext));
+        final List<SubscriptionBaseEvent> events = new ArrayList<SubscriptionBaseEvent>();
+        events.add(previousEvent);
+        events.add(event);
+
+        defaultSubscriptionBase.rebuildTransitions(events, catalogService.getFullCatalog(true, true, internalCallContext));
 
         final List<SubscriptionBaseTransition> newTransitions = defaultSubscriptionBase.getAllTransitions();
         Assert.assertEquals(newTransitions.size(), 2);
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java
index a3acb50..056208c 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java
@@ -16,8 +16,16 @@
 
 package org.killbill.billing.subscription.api.user;
 
+import java.util.UUID;
+
 import org.joda.time.DateTime;
 import org.joda.time.Interval;
+import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
+import org.killbill.billing.entity.EntityPersistenceException;
+import org.killbill.billing.subscription.engine.dao.SubscriptionEventSqlDao;
+import org.killbill.billing.subscription.engine.dao.model.SubscriptionEventModelDao;
+import org.killbill.billing.subscription.events.SubscriptionBaseEvent;
+import org.skife.jdbi.v2.Handle;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
@@ -118,6 +126,10 @@ public class TestUserApiCancel extends SubscriptionTestSuiteWithEmbeddedDB {
         subscription.cancel(callContext);
         assertListenerStatus();
 
+        // CANCEL a second time (first pending CANCEL should be made inactive)
+        subscription.cancel(callContext);
+        assertListenerStatus();
+
         assertEquals(subscription.getLastActiveProduct().getName(), prod);
         assertEquals(subscription.getLastActivePriceList().getName(), planSet);
         assertEquals(subscription.getLastActiveBillingPeriod(), term);
@@ -127,10 +139,9 @@ public class TestUserApiCancel extends SubscriptionTestSuiteWithEmbeddedDB {
         Assert.assertNotNull(futureEndDate);
 
         // MOVE TO EOT + RECHECK
-        testListener.pushExpectedEvent(NextEvent.CANCEL);
+        testListener.pushExpectedEvents(NextEvent.CANCEL);
         it = new Interval(clock.getUTCNow(), clock.getUTCNow().plusMonths(1));
         clock.addDeltaFromReality(it.toDurationMillis());
-        final DateTime future = clock.getUTCNow();
         assertListenerStatus();
 
         assertTrue(futureEndDate.compareTo(subscription.getEndDate()) == 0);
@@ -258,4 +269,81 @@ public class TestUserApiCancel extends SubscriptionTestSuiteWithEmbeddedDB {
         // CANCEL in EVERGREEN period with an invalid Date (prior to the Creation Date)
         subscription.cancelWithDate(invalidDate, callContext);
     }
+
+
+
+    @Test(groups = "slow")
+    public void testWithMultipleCancellationEvent() throws SubscriptionBillingApiException, SubscriptionBaseApiException {
+        final String prod = "Shotgun";
+        final BillingPeriod term = BillingPeriod.MONTHLY;
+        final String planSet = PriceListSet.DEFAULT_PRICELIST_NAME;
+
+        // CREATE
+        DefaultSubscriptionBase subscription = testUtil.createSubscription(bundle, prod, term, planSet);
+        PlanPhase trialPhase = subscription.getCurrentPhase();
+        assertEquals(trialPhase.getPhaseType(), PhaseType.TRIAL);
+
+        // NEXT PHASE
+        final DateTime expectedPhaseTrialChange = TestSubscriptionHelper.addDuration(subscription.getStartDate(), trialPhase.getDuration());
+        testUtil.checkNextPhaseChange(subscription, 1, expectedPhaseTrialChange);
+
+        // MOVE TO NEXT PHASE
+        testListener.pushExpectedEvent(NextEvent.PHASE);
+        Interval it = new Interval(clock.getUTCNow(), clock.getUTCNow().plusDays(31));
+        clock.addDeltaFromReality(it.toDurationMillis());
+
+        assertListenerStatus();
+        trialPhase = subscription.getCurrentPhase();
+        assertEquals(trialPhase.getPhaseType(), PhaseType.EVERGREEN);
+
+        // SET CTD + RE READ SUBSCRIPTION + CHANGE PLAN
+        final Duration ctd = testUtil.getDurationMonth(1);
+        final DateTime newChargedThroughDate = TestSubscriptionHelper.addDuration(expectedPhaseTrialChange, ctd);
+        subscriptionInternalApi.setChargedThroughDate(subscription.getId(), newChargedThroughDate, internalCallContext);
+        subscription = (DefaultSubscriptionBase) subscriptionInternalApi.getSubscriptionFromId(subscription.getId(), internalCallContext);
+
+        assertEquals(subscription.getLastActiveProduct().getName(), prod);
+        assertEquals(subscription.getLastActivePriceList().getName(), planSet);
+        assertEquals(subscription.getLastActiveBillingPeriod(), term);
+        assertEquals(subscription.getLastActiveCategory(), ProductCategory.BASE);
+
+        // CANCEL
+        subscription.cancel(callContext);
+        assertListenerStatus();
+
+        subscription = (DefaultSubscriptionBase) subscriptionInternalApi.getSubscriptionFromId(subscription.getId(), internalCallContext);
+        Assert.assertEquals(subscription.getAllTransitions().size(), 3);
+
+
+        // Manually add a CANCEL event on the same EOT date as the previous one to verify the code is resilient enough to ignore it
+        final SubscriptionBaseEvent cancelEvent = subscription.getEvents().get(subscription.getEvents().size() - 1);
+        final SubscriptionEventModelDao newCancelEvent = new SubscriptionEventModelDao(cancelEvent);
+        newCancelEvent.setId(UUID.randomUUID());
+
+        final Handle handle = dbi.open();
+        final SubscriptionEventSqlDao sqlDao = handle.attach(SubscriptionEventSqlDao.class);
+        try {
+            sqlDao.create(newCancelEvent, internalCallContext);
+        } catch (EntityPersistenceException e) {
+            Assert.fail(e.getMessage());
+        }
+
+        subscription = (DefaultSubscriptionBase) subscriptionInternalApi.getSubscriptionFromId(subscription.getId(), internalCallContext);
+        // The extra cancel event is being ignored
+        Assert.assertEquals(subscription.getEvents().size(), 3);
+        Assert.assertEquals(subscription.getAllTransitions().size(), 3);
+
+
+        // We expect only one CANCEL event, this other one is skipped
+        testListener.pushExpectedEvents(NextEvent.CANCEL);
+        it = new Interval(clock.getUTCNow(), clock.getUTCNow().plusMonths(1));
+        clock.addDeltaFromReality(it.toDurationMillis());
+        assertListenerStatus();
+
+        // Our previous transition should be a CANCEL with a valid previous plan
+        final SubscriptionBaseTransition previousTransition = subscription.getPreviousTransition();
+        Assert.assertEquals(previousTransition.getPreviousState(), EntitlementState.ACTIVE);
+        Assert.assertNotNull(previousTransition.getPreviousPlan());
+
+    }
 }
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiChangePlan.java b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiChangePlan.java
index 26eb6ff..58fe498 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiChangePlan.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiChangePlan.java
@@ -350,7 +350,7 @@ public class TestUserApiChangePlan extends SubscriptionTestSuiteWithEmbeddedDB {
         assertEquals(currentPhase.getPhaseType(), PhaseType.DISCOUNT);
 
         // ACTIVATE CHANGE BY MOVING AFTER CTD
-        testListener.pushExpectedEvents(NextEvent.CHANGE, NextEvent.CHANGE);
+        testListener.pushExpectedEvents(NextEvent.CHANGE);
         it = new Interval(clock.getUTCNow(), clock.getUTCNow().plusMonths(1));
         clock.addDeltaFromReality(it.toDurationMillis());
         assertListenerStatus();