killbill-uncached

entitlement, overdue: Fix NPE when cancelling EOT all subscription

1/12/2017 4:43:25 PM

Details

diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/overdue/TestOverdueWithSubscriptionEOTCancellation.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/overdue/TestOverdueWithSubscriptionEOTCancellation.java
new file mode 100644
index 0000000..a91f3b0
--- /dev/null
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/overdue/TestOverdueWithSubscriptionEOTCancellation.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2014-2017 Groupon, Inc
+ * Copyright 2014-2017 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.beatrix.integration.overdue;
+
+import java.math.BigDecimal;
+
+import org.joda.time.DateTime;
+import org.joda.time.LocalDate;
+import org.killbill.billing.api.TestApiListener.NextEvent;
+import org.killbill.billing.beatrix.util.InvoiceChecker.ExpectedInvoiceItemCheck;
+import org.killbill.billing.catalog.api.BillingPeriod;
+import org.killbill.billing.catalog.api.ProductCategory;
+import org.killbill.billing.entitlement.api.DefaultEntitlement;
+import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
+import org.killbill.billing.entitlement.api.Subscription;
+import org.killbill.billing.invoice.api.InvoiceItemType;
+import org.killbill.billing.overdue.wrapper.OverdueWrapper;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+@Test(groups = "slow")
+public class TestOverdueWithSubscriptionEOTCancellation extends TestOverdueBase {
+
+    @Override
+    public String getOverdueConfig() {
+        final String configXml = "<overdueConfig>" +
+                                 "   <accountOverdueStates>" +
+                                 "       <initialReevaluationInterval>" +
+                                 "           <unit>DAYS</unit><number>5</number>" +
+                                 "       </initialReevaluationInterval>" +
+                                 "       <state name=\"OD1\">" +
+                                 "           <condition>" +
+                                 "               <timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>" +
+                                 "                   <unit>DAYS</unit><number>5</number>" +
+                                 "               </timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>" +
+                                 "           </condition>" +
+                                 "           <externalMessage>Reached OD1</externalMessage>" +
+                                 "           <blockChanges>true</blockChanges>" +
+                                 "           <disableEntitlementAndChangesBlocked>false</disableEntitlementAndChangesBlocked>" +
+                                 "           <subscriptionCancellationPolicy>END_OF_TERM</subscriptionCancellationPolicy>" +
+                                 "           <autoReevaluationInterval>" +
+                                 "               <unit>DAYS</unit><number>5</number>" +
+                                 "           </autoReevaluationInterval>" +
+                                 "       </state>" +
+                                 "   </accountOverdueStates>" +
+                                 "</overdueConfig>";
+        return configXml;
+    }
+
+    @Test(groups = "slow")
+    public void testCheckSubscriptionEOTCancellation() throws Exception {
+        clock.setTime(new DateTime(2012, 5, 1, 0, 3, 42, 0));
+
+        setupAccount();
+
+        // Set next invoice to fail and create subscription
+        paymentPlugin.makeAllInvoicesFailWithError(true);
+        final DefaultEntitlement baseEntitlement = createBaseEntitlementAndCheckForCompletion(account.getId(), "externalKey", productName, ProductCategory.BASE, term, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+        bundle = subscriptionApi.getSubscriptionBundle(baseEntitlement.getBundleId(), callContext);
+
+        invoiceChecker.checkInvoice(account.getId(), 1, callContext, new ExpectedInvoiceItemCheck(new LocalDate(2012, 5, 1), null, InvoiceItemType.FIXED, new BigDecimal("0")));
+        invoiceChecker.checkChargedThroughDate(baseEntitlement.getId(), new LocalDate(2012, 5, 1), callContext);
+
+        final DefaultEntitlement addOn1 = addAOEntitlementAndCheckForCompletion(baseEntitlement.getBundleId(), "Holster", ProductCategory.ADD_ON, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+
+        // DAY 30 have to get out of trial before first payment
+        addDaysAndCheckForCompletion(30, NextEvent.PHASE, NextEvent.PHASE, NextEvent.NULL_INVOICE, NextEvent.INVOICE, NextEvent.PAYMENT_ERROR, NextEvent.INVOICE_PAYMENT_ERROR);
+
+        invoiceChecker.checkChargedThroughDate(baseEntitlement.getId(), new LocalDate(2012, 6, 30), callContext);
+        invoiceChecker.checkChargedThroughDate(addOn1.getId(), new LocalDate(2012, 6, 30), callContext);
+
+        // Should still be in clear state
+        checkODState(OverdueWrapper.CLEAR_STATE_NAME);
+
+        // We expect one event for OD1 transition and for for each entitlement cancellation (entitlement cancellation is immediate)
+        addDaysAndCheckForCompletion(6, NextEvent.BLOCK, NextEvent.BLOCK, NextEvent.BLOCK);
+
+        // Should be in OD1
+        checkODState("OD1");
+
+        final Subscription cancelledBaseSubscription = subscriptionApi.getSubscriptionForEntitlementId(baseEntitlement.getId(), callContext);
+        assertTrue(cancelledBaseSubscription.getState() == EntitlementState.CANCELLED);
+        assertEquals(cancelledBaseSubscription.getEffectiveEndDate(), new LocalDate(2012, 6, 05));
+        assertEquals(cancelledBaseSubscription.getBillingEndDate(), new LocalDate(2012, 6, 30));
+
+        final Subscription cancelledAddon1 = subscriptionApi.getSubscriptionForEntitlementId(addOn1.getId(), callContext);
+        assertTrue(cancelledAddon1.getState() == EntitlementState.CANCELLED);
+        assertEquals(cancelledAddon1.getEffectiveEndDate(), new LocalDate(2012, 6, 05));
+        assertEquals(cancelledAddon1.getBillingEndDate(), new LocalDate(2012, 6, 30));
+
+        // Payment Retry on the 2012-6-8
+        addDaysAndCheckForCompletion(2, NextEvent.PAYMENT_ERROR, NextEvent.INVOICE_PAYMENT_ERROR);
+        assertListenerStatus();
+
+        // Payment Retry on the 2012-6-16
+        addDaysAndCheckForCompletion(8, NextEvent.PAYMENT_ERROR, NextEvent.INVOICE_PAYMENT_ERROR);
+        assertListenerStatus();
+
+        // Payment Retry on the 2012-6-24
+        addDaysAndCheckForCompletion(8, NextEvent.PAYMENT_ERROR, NextEvent.INVOICE_PAYMENT_ERROR);
+        assertListenerStatus();
+
+        // 2012-6-30
+        addDaysAndCheckForCompletion(6, NextEvent.NULL_INVOICE, NextEvent.NULL_INVOICE, NextEvent.CANCEL, NextEvent.CANCEL);
+        assertListenerStatus();
+    }
+
+}
diff --git a/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java b/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java
index 982b932..a5f4fc5 100644
--- a/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java
+++ b/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java
@@ -254,10 +254,19 @@ public class DefaultEntitlementInternalApi extends DefaultEntitlementApiBase imp
         @Override
         public Entitlement doCall(final EntitlementApi entitlementApi, final EntitlementContext updatedPluginContext) throws EntitlementApiException {
             DateTime effectiveDate = dateHelper.fromLocalDateAndReferenceTime(updatedPluginContext.getBaseEntitlementWithAddOnsSpecifiers().iterator().next().getEntitlementEffectiveDate(), internalCallContext);
-            // Avoid timing issues for IMM cancellations (we don't want an entitlement cancel date one second or so after the subscription cancel date or
-            // add-ons cancellations computations won't work).
-            if (effectiveDate.compareTo(entitlement.getSubscriptionBase().getEndDate()) > 0) {
-                effectiveDate = entitlement.getSubscriptionBase().getEndDate();
+
+            //
+            // If the entitlementDate provided is ahead we default to the effective subscriptionBase cancellationDate to avoid weird timing issues.
+            //
+            // (Note that entitlement.getSubscriptionBase() returns the right state (although we did not refresh context) because the DefaultSubscriptionBaseApiService#doCancelPlan
+            //  rebuild transitions on that same  DefaultSubscriptionBase object)
+            //
+            final DateTime subscriptionBaseCancellationDate = entitlement.getSubscriptionBase().getEndDate() != null ?
+                                                              entitlement.getSubscriptionBase().getEndDate() :
+                                                              entitlement.getSubscriptionBase().getFutureEndDate();
+
+            if (effectiveDate.compareTo(subscriptionBaseCancellationDate) > 0) {
+                effectiveDate = subscriptionBaseCancellationDate;
             }
 
             final BlockingState newBlockingState = new DefaultBlockingState(entitlement.getId(), BlockingStateType.SUBSCRIPTION, DefaultEntitlementApi.ENT_STATE_CANCELLED, EntitlementService.ENTITLEMENT_SERVICE_NAME, true, true, false, effectiveDate);