killbill-memoizeit

Implement invoice config support and new bus events to notify

4/2/2015 12:01:03 AM

Changes

pom.xml 2(+1 -1)

Details

diff --git a/api/src/main/java/org/killbill/billing/events/BusInternalEvent.java b/api/src/main/java/org/killbill/billing/events/BusInternalEvent.java
index 66f849e..b756b7a 100644
--- a/api/src/main/java/org/killbill/billing/events/BusInternalEvent.java
+++ b/api/src/main/java/org/killbill/billing/events/BusInternalEvent.java
@@ -35,6 +35,7 @@ public interface BusInternalEvent extends BusEvent {
         ENTITLEMENT_TRANSITION,
         INVOICE_ADJUSTMENT,
         INVOICE_CREATION,
+        INVOICE_NOTIFICATION,
         INVOICE_EMPTY,
         OVERDUE_CHANGE,
         PAYMENT_ERROR,
diff --git a/api/src/main/java/org/killbill/billing/events/InvoiceNotificationInternalEvent.java b/api/src/main/java/org/killbill/billing/events/InvoiceNotificationInternalEvent.java
new file mode 100644
index 0000000..6f2b7a1
--- /dev/null
+++ b/api/src/main/java/org/killbill/billing/events/InvoiceNotificationInternalEvent.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2014-2015 Groupon, Inc
+ * Copyright 2014-2015 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.events;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import org.joda.time.DateTime;
+import org.killbill.billing.catalog.api.Currency;
+
+public interface InvoiceNotificationInternalEvent extends InvoiceInternalEvent {
+
+    public BigDecimal getAmountOwed();
+
+    public Currency getCurrency();
+
+    public DateTime getTargetDate();
+
+}
diff --git a/beatrix/src/main/java/org/killbill/billing/beatrix/extbus/BeatrixListener.java b/beatrix/src/main/java/org/killbill/billing/beatrix/extbus/BeatrixListener.java
index 73123dd..7e914ce 100644
--- a/beatrix/src/main/java/org/killbill/billing/beatrix/extbus/BeatrixListener.java
+++ b/beatrix/src/main/java/org/killbill/billing/beatrix/extbus/BeatrixListener.java
@@ -38,6 +38,7 @@ import org.killbill.billing.events.CustomFieldDeletionEvent;
 import org.killbill.billing.events.EntitlementInternalEvent;
 import org.killbill.billing.events.InvoiceAdjustmentInternalEvent;
 import org.killbill.billing.events.InvoiceCreationInternalEvent;
+import org.killbill.billing.events.InvoiceNotificationInternalEvent;
 import org.killbill.billing.events.OverdueChangeInternalEvent;
 import org.killbill.billing.events.PaymentErrorInternalEvent;
 import org.killbill.billing.events.PaymentInfoInternalEvent;
@@ -159,6 +160,15 @@ public class BeatrixListener {
                 eventBusType = ExtBusEventType.INVOICE_CREATION;
                 break;
 
+            case INVOICE_NOTIFICATION:
+                final InvoiceNotificationInternalEvent realEventInvNotification = (InvoiceNotificationInternalEvent) event;
+                objectType = ObjectType.INVOICE;
+                objectId = null;
+                accountId = realEventInvNotification.getAccountId(); // has to be set here because objectId is null with a dryRun Invoice
+                eventBusType = ExtBusEventType.INVOICE_NOTIFICATION;
+                break;
+
+
             case INVOICE_ADJUSTMENT:
                 final InvoiceAdjustmentInternalEvent realEventInvAdj = (InvoiceAdjustmentInternalEvent) event;
                 objectType = ObjectType.INVOICE;
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceNotifications.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceNotifications.java
new file mode 100644
index 0000000..613ce72
--- /dev/null
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceNotifications.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2014-2015 Groupon, Inc
+ * Copyright 2014-2015 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;
+
+import org.joda.time.LocalDate;
+import org.killbill.billing.account.api.Account;
+import org.killbill.billing.account.api.AccountData;
+import org.killbill.billing.api.TestApiListener.NextEvent;
+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.platform.api.KillbillConfigSource;
+import org.testng.annotations.Test;
+
+import com.google.common.collect.ImmutableMap;
+
+public class TestInvoiceNotifications extends TestIntegrationBase {
+
+    @Override
+    protected KillbillConfigSource getConfigSource() {
+        ImmutableMap additionalProperties = new ImmutableMap.Builder()
+                .put("org.killbill.invoice.dryRunNotificationSchedule", "7d")
+                .build();
+        return getConfigSource("/beatrix.properties", additionalProperties);
+    }
+
+    @Test(groups = "slow")
+    public void testInvoiceNotificationBasic() throws Exception {
+
+        final AccountData accountData = getAccountData(1);
+        final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
+        accountChecker.checkAccount(account.getId(), accountData, callContext);
+
+        // 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 DefaultEntitlement bpSubscription = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.INVOICE);
+
+        // Move to end of trial =>  2012, 5, 1
+        addDaysAndCheckForCompletion(30, NextEvent.PHASE, NextEvent.INVOICE, NextEvent.PAYMENT);
+
+        // Next invoice is scheduled for 2012, 6, 1 so we should have a NOTIFICATION event 7 days before, on 2012, 5, 25
+        addDaysAndCheckForCompletion(24, NextEvent.INVOICE_NOTIFICATION);
+
+        // And then verify the invoice is correctly generated
+        addDaysAndCheckForCompletion(7, NextEvent.INVOICE, NextEvent.PAYMENT);
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceNotificationInternalEvent.java b/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceNotificationInternalEvent.java
new file mode 100644
index 0000000..e969fa9
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceNotificationInternalEvent.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2014-2015 Groupon, Inc
+ * Copyright 2014-2015 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.invoice.api.user;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import org.joda.time.DateTime;
+import org.killbill.billing.catalog.api.Currency;
+import org.killbill.billing.events.BusEventBase;
+import org.killbill.billing.events.InvoiceNotificationInternalEvent;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class DefaultInvoiceNotificationInternalEvent extends BusEventBase implements InvoiceNotificationInternalEvent {
+
+    private final DateTime targetDate;
+    private final UUID accountId;
+    private final BigDecimal amountOwed;
+    private final Currency currency;
+
+    @JsonCreator
+    public DefaultInvoiceNotificationInternalEvent(@JsonProperty("accountId") final UUID accountId,
+                                                   @JsonProperty("amountOwed") final BigDecimal amountOwed,
+                                                   @JsonProperty("currency") final Currency currency,
+                                                   @JsonProperty("targetDate") final DateTime targetDate,
+                                                   @JsonProperty("searchKey1") final Long searchKey1,
+                                                   @JsonProperty("searchKey2") final Long searchKey2,
+                                                   @JsonProperty("userToken") final UUID userToken) {
+        super(searchKey1, searchKey2, userToken);
+        this.targetDate = targetDate;
+        this.accountId = accountId;
+        this.amountOwed = amountOwed;
+        this.currency = currency;
+    }
+
+    @JsonIgnore
+    @Override
+    public BusInternalEventType getBusEventType() {
+        return BusInternalEventType.INVOICE_NOTIFICATION;
+    }
+
+    @Override
+    public BigDecimal getAmountOwed() {
+        return amountOwed;
+    }
+
+    @Override
+    public Currency getCurrency() {
+        return currency;
+    }
+
+    @Override
+    public DateTime getTargetDate() {
+        return targetDate;
+    }
+
+    @Override
+    public UUID getAccountId() {
+        return accountId;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof DefaultInvoiceNotificationInternalEvent)) {
+            return false;
+        }
+
+        final DefaultInvoiceNotificationInternalEvent that = (DefaultInvoiceNotificationInternalEvent) o;
+
+        if (accountId != null ? !accountId.equals(that.accountId) : that.accountId != null) {
+            return false;
+        }
+        if (amountOwed != null ? !amountOwed.equals(that.amountOwed) : that.amountOwed != null) {
+            return false;
+        }
+        if (currency != that.currency) {
+            return false;
+        }
+        if (targetDate != null ? targetDate.compareTo(that.targetDate) != 0 : that.targetDate != null) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = targetDate != null ? targetDate.hashCode() : 0;
+        result = 31 * result + (accountId != null ? accountId.hashCode() : 0);
+        result = 31 * result + (amountOwed != null ? amountOwed.hashCode() : 0);
+        result = 31 * result + (currency != null ? currency.hashCode() : 0);
+        return result;
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/dao/DefaultInvoiceDao.java b/invoice/src/main/java/org/killbill/billing/invoice/dao/DefaultInvoiceDao.java
index 5add0ff..0b3f65f 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/dao/DefaultInvoiceDao.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/dao/DefaultInvoiceDao.java
@@ -43,6 +43,7 @@ import org.killbill.billing.invoice.api.user.DefaultInvoiceAdjustmentEvent;
 import org.killbill.billing.invoice.notification.NextBillingDatePoster;
 import org.killbill.billing.util.cache.CacheControllerDispatcher;
 import org.killbill.billing.util.callcontext.InternalCallContextFactory;
+import org.killbill.billing.util.config.InvoiceConfig;
 import org.killbill.billing.util.dao.NonEntityDao;
 import org.killbill.billing.util.entity.Pagination;
 import org.killbill.billing.util.entity.dao.DefaultPaginationSqlDaoHelper.PaginationIteratorBuilder;
@@ -89,6 +90,8 @@ public class DefaultInvoiceDao extends EntityDaoBase<InvoiceModelDao, Invoice, I
     private final InternalCallContextFactory internalCallContextFactory;
     private final InvoiceDaoHelper invoiceDaoHelper;
     private final CBADao cbaDao;
+    private final InvoiceConfig invoiceConfig;
+    private final Clock clock;
 
     @Inject
     public DefaultInvoiceDao(final IDBI dbi,
@@ -97,13 +100,16 @@ public class DefaultInvoiceDao extends EntityDaoBase<InvoiceModelDao, Invoice, I
                              final Clock clock,
                              final CacheControllerDispatcher cacheControllerDispatcher,
                              final NonEntityDao nonEntityDao,
+                             final InvoiceConfig invoiceConfig,
                              final InternalCallContextFactory internalCallContextFactory) {
         super(new EntitySqlDaoTransactionalJdbiWrapper(dbi, clock, cacheControllerDispatcher, nonEntityDao), InvoiceSqlDao.class);
         this.nextBillingDatePoster = nextBillingDatePoster;
         this.eventBus = eventBus;
+        this.invoiceConfig = invoiceConfig;
         this.internalCallContextFactory = internalCallContextFactory;
         this.invoiceDaoHelper = new InvoiceDaoHelper();
         this.cbaDao = new CBADao();
+        this.clock = clock;
     }
 
     @Override
@@ -770,9 +776,20 @@ public class DefaultInvoiceDao extends EntityDaoBase<InvoiceModelDao, Invoice, I
 
     private void notifyOfFutureBillingEvents(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final UUID accountId,
                                              final Map<UUID, List<DateTime>> callbackDateTimePerSubscriptions, final InternalCallContext internalCallContext) {
+
+
+        final long dryRunNotificationTime = invoiceConfig.getDryRunNotificationSchedule().getMillis();
+        final boolean isInvoiceNotificationEnabled =  dryRunNotificationTime > 0;
         for (final UUID subscriptionId : callbackDateTimePerSubscriptions.keySet()) {
             final List<DateTime> callbackDateTimeUTC = callbackDateTimePerSubscriptions.get(subscriptionId);
             for (final DateTime cur : callbackDateTimeUTC) {
+                if (isInvoiceNotificationEnabled) {
+                    final DateTime curDryRunNotificationTime = cur.minus(dryRunNotificationTime);
+                    // Only schedule if the date is in the future
+                    if (curDryRunNotificationTime.isAfter(clock.getUTCNow())) {
+                        nextBillingDatePoster.insertNextBillingDryRunNotificationFromTransaction(entitySqlDaoWrapperFactory, accountId, subscriptionId, curDryRunNotificationTime, cur, internalCallContext);
+                    }
+                }
                 nextBillingDatePoster.insertNextBillingNotificationFromTransaction(entitySqlDaoWrapperFactory, accountId, subscriptionId, cur, internalCallContext);
             }
         }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
index 321c00a..11a0813 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
@@ -38,14 +38,19 @@ import org.killbill.billing.account.api.AccountApiException;
 import org.killbill.billing.account.api.AccountInternalApi;
 import org.killbill.billing.callcontext.InternalCallContext;
 import org.killbill.billing.callcontext.InternalTenantContext;
+import org.killbill.billing.catalog.api.BillingActionPolicy;
 import org.killbill.billing.catalog.api.BillingMode;
 import org.killbill.billing.catalog.api.CatalogApiException;
 import org.killbill.billing.catalog.api.Currency;
+import org.killbill.billing.catalog.api.PlanPhasePriceOverride;
+import org.killbill.billing.catalog.api.PlanPhaseSpecifier;
 import org.killbill.billing.catalog.api.Usage;
+import org.killbill.billing.entitlement.api.SubscriptionEventType;
 import org.killbill.billing.events.BusInternalEvent;
 import org.killbill.billing.events.EffectiveSubscriptionInternalEvent;
 import org.killbill.billing.events.InvoiceAdjustmentInternalEvent;
 import org.killbill.billing.events.InvoiceInternalEvent;
+import org.killbill.billing.events.InvoiceNotificationInternalEvent;
 import org.killbill.billing.invoice.api.DryRunArguments;
 import org.killbill.billing.invoice.api.Invoice;
 import org.killbill.billing.invoice.api.InvoiceApiException;
@@ -54,6 +59,7 @@ import org.killbill.billing.invoice.api.InvoiceItemType;
 import org.killbill.billing.invoice.api.InvoiceNotifier;
 import org.killbill.billing.invoice.api.user.DefaultInvoiceAdjustmentEvent;
 import org.killbill.billing.invoice.api.user.DefaultInvoiceCreationEvent;
+import org.killbill.billing.invoice.api.user.DefaultInvoiceNotificationInternalEvent;
 import org.killbill.billing.invoice.api.user.DefaultNullInvoiceEvent;
 import org.killbill.billing.invoice.dao.InvoiceDao;
 import org.killbill.billing.invoice.dao.InvoiceItemModelDao;
@@ -93,6 +99,8 @@ public class InvoiceDispatcher {
     private static final Logger log = LoggerFactory.getLogger(InvoiceDispatcher.class);
     private static final int NB_LOCK_TRY = 5;
 
+    private static final NullDryRunArguments NULL_DRY_RUN_ARGUMENTS = new NullDryRunArguments();
+
     private final InvoiceGenerator generator;
     private final BillingInternalApi billingApi;
     private final AccountInternalApi accountApi;
@@ -130,27 +138,49 @@ public class InvoiceDispatcher {
         this.clock = clock;
     }
 
-    public void processSubscription(final EffectiveSubscriptionInternalEvent transition,
-                                    final InternalCallContext context) throws InvoiceApiException {
+    public void processSubscriptionForInvoiceGeneration(final EffectiveSubscriptionInternalEvent transition,
+                                                        final InternalCallContext context) throws InvoiceApiException {
         final UUID subscriptionId = transition.getSubscriptionId();
         final DateTime targetDate = transition.getEffectiveTransitionTime();
-        processSubscription(subscriptionId, targetDate, context);
+        processSubscriptionForInvoiceGeneration(subscriptionId, targetDate, context);
+    }
+
+    public void processSubscriptionForInvoiceGeneration(final UUID subscriptionId, final DateTime targetDate, final InternalCallContext context) throws InvoiceApiException {
+        processSubscriptionInternal(subscriptionId, targetDate, false, context);
+    }
+
+    public void processSubscriptionForInvoiceNotification(final UUID subscriptionId, final DateTime targetDate, final InternalCallContext context) throws InvoiceApiException {
+        final Invoice dryRunInvoice = processSubscriptionInternal(subscriptionId, targetDate, true, context);
+        if (dryRunInvoice != null && dryRunInvoice.getBalance().compareTo(BigDecimal.ZERO) > 0) {
+            final InvoiceNotificationInternalEvent event = new DefaultInvoiceNotificationInternalEvent(dryRunInvoice.getAccountId(), dryRunInvoice.getBalance(), dryRunInvoice.getCurrency(),
+                                                                                                       targetDate, context.getAccountRecordId(), context.getTenantRecordId(), context.getUserToken());
+            try {
+                eventBus.post(event);
+            } catch (EventBusException e) {
+                log.error("Failed to post event " + event, e);
+            }
+        }
     }
 
-    public void processSubscription(final UUID subscriptionId, final DateTime targetDate, final InternalCallContext context) throws InvoiceApiException {
+
+    private Invoice processSubscriptionInternal(final UUID subscriptionId, final DateTime targetDate, final boolean dryRunForNotification, final InternalCallContext context) throws InvoiceApiException {
         try {
             if (subscriptionId == null) {
                 log.error("Failed handling SubscriptionBase change.", new InvoiceApiException(ErrorCode.INVOICE_INVALID_TRANSITION));
-                return;
+                return null;
             }
             final UUID accountId = subscriptionApi.getAccountIdFromSubscriptionId(subscriptionId, context);
-            processAccount(accountId, targetDate, null, context);
+            final DryRunArguments dryRunArguments = dryRunForNotification ? NULL_DRY_RUN_ARGUMENTS : null;
+
+            return processAccount(accountId, targetDate, dryRunArguments, context);
         } catch (final SubscriptionBaseApiException e) {
             log.error("Failed handling SubscriptionBase change.",
                       new InvoiceApiException(ErrorCode.INVOICE_NO_ACCOUNT_ID_FOR_SUBSCRIPTION_ID, subscriptionId.toString()));
+            return null;
         }
     }
 
+
     public Invoice processAccount(final UUID accountId, final DateTime targetDate,
                                   @Nullable final DryRunArguments dryRunArguments, final InternalCallContext context) throws InvoiceApiException {
         GlobalLock lock = null;
@@ -426,4 +456,35 @@ public class InvoiceDispatcher {
             }
         }
     }
+
+    private final static class NullDryRunArguments implements DryRunArguments {
+        @Override
+        public PlanPhaseSpecifier getPlanPhaseSpecifier() {
+            return null;
+        }
+        @Override
+        public SubscriptionEventType getAction() {
+            return null;
+        }
+        @Override
+        public UUID getSubscriptionId() {
+            return null;
+        }
+        @Override
+        public DateTime getEffectiveDate() {
+            return null;
+        }
+        @Override
+        public UUID getBundleId() {
+            return null;
+        }
+        @Override
+        public BillingActionPolicy getBillingActionPolicy() {
+            return null;
+        }
+        @Override
+        public List<PlanPhasePriceOverride> getPlanPhasePriceoverrides() {
+            return null;
+        }
+    }
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceListener.java b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceListener.java
index 74ef87f..86d77d9 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceListener.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceListener.java
@@ -83,7 +83,7 @@ public class InvoiceListener {
                 return;
             }
             final InternalCallContext context = internalCallContextFactory.createInternalCallContext(event.getSearchKey2(), event.getSearchKey1(), "SubscriptionBaseTransition", CallOrigin.INTERNAL, UserType.SYSTEM, event.getUserToken());
-            dispatcher.processSubscription(event, context);
+            dispatcher.processSubscriptionForInvoiceGeneration(event, context);
         } catch (InvoiceApiException e) {
             log.error(e.getMessage());
         }
@@ -122,7 +122,16 @@ public class InvoiceListener {
     public void handleNextBillingDateEvent(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
         try {
             final InternalCallContext context = internalCallContextFactory.createInternalCallContext(tenantRecordId, accountRecordId, "Next Billing Date", CallOrigin.INTERNAL, UserType.SYSTEM, userToken);
-            dispatcher.processSubscription(subscriptionId, eventDateTime, context);
+            dispatcher.processSubscriptionForInvoiceGeneration(subscriptionId, eventDateTime, context);
+        } catch (InvoiceApiException e) {
+            log.error(e.getMessage());
+        }
+    }
+
+    public void handleEventForInvoiceNotification(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
+        try {
+            final InternalCallContext context = internalCallContextFactory.createInternalCallContext(tenantRecordId, accountRecordId, "Next Billing Date", CallOrigin.INTERNAL, UserType.SYSTEM, userToken);
+            dispatcher.processSubscriptionForInvoiceNotification(subscriptionId, eventDateTime, context);
         } catch (InvoiceApiException e) {
             log.error(e.getMessage());
         }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDateNotifier.java b/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDateNotifier.java
index 27db49b..e5ec2ae 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDateNotifier.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDateNotifier.java
@@ -77,12 +77,20 @@ public class DefaultNextBillingDateNotifier implements NextBillingDateNotifier {
                     }
 
                     final NextBillingDateNotificationKey key = (NextBillingDateNotificationKey) notificationKey;
+
+                    // Just to ensure compatibility with json that might not have that targetDate field (old versions < 0.13.6)
+                    final DateTime targetDate = key.getTargetDate() != null ? key.getTargetDate() : eventDate;
                     try {
                         final SubscriptionBase subscription = subscriptionApi.getSubscriptionFromId(key.getUuidKey(), callContextFactory.createInternalTenantContext(tenantRecordId, accountRecordId));
                         if (subscription == null) {
                             log.warn("Next Billing Date Notification Queue handled spurious notification (key: " + key + ")");
+                            return;
+                        }
+                        if (key.isDryRunForInvoiceNotification() != null && // Just to ensure compatibility with json that might not have that field (old versions < 0.13.6)
+                            key.isDryRunForInvoiceNotification()) {
+                            processEventForInvoiceNotification(key.getUuidKey(), targetDate, userToken, accountRecordId, tenantRecordId);
                         } else {
-                            processEvent(key.getUuidKey(), eventDate, userToken, accountRecordId, tenantRecordId);
+                            processEventForInvoiceGeneration(key.getUuidKey(), targetDate, userToken, accountRecordId, tenantRecordId);
                         }
                     } catch (SubscriptionBaseApiException e) {
                         log.warn("Next Billing Date Notification Queue handled spurious notification (key: " + key + ")", e);
@@ -111,7 +119,11 @@ public class DefaultNextBillingDateNotifier implements NextBillingDateNotifier {
         }
     }
 
-    private void processEvent(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
+    private void processEventForInvoiceGeneration(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
         listener.handleNextBillingDateEvent(subscriptionId, eventDateTime, userToken, accountRecordId, tenantRecordId);
     }
+
+    private void processEventForInvoiceNotification(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
+        listener.handleEventForInvoiceNotification(subscriptionId, eventDateTime, userToken, accountRecordId, tenantRecordId);
+    }
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDatePoster.java b/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDatePoster.java
index d490d0d..e76a1f5 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDatePoster.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/notification/DefaultNextBillingDatePoster.java
@@ -47,6 +47,19 @@ public class DefaultNextBillingDatePoster implements NextBillingDatePoster {
     @Override
     public void insertNextBillingNotificationFromTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final UUID accountId,
                                                              final UUID subscriptionId, final DateTime futureNotificationTime, final InternalCallContext internalCallContext) {
+        insertNextBillingFromTransactionInternal(entitySqlDaoWrapperFactory, accountId, subscriptionId, Boolean.FALSE, futureNotificationTime, futureNotificationTime, internalCallContext);
+    }
+
+
+    @Override
+    public void insertNextBillingDryRunNotificationFromTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final UUID accountId,
+                                                                   final UUID subscriptionId, final DateTime futureNotificationTime, final DateTime targetDate, final InternalCallContext internalCallContext) {
+        insertNextBillingFromTransactionInternal(entitySqlDaoWrapperFactory, accountId, subscriptionId, Boolean.TRUE, futureNotificationTime, targetDate, internalCallContext);
+    }
+
+
+    private void insertNextBillingFromTransactionInternal(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final UUID accountId,
+                                                                   final UUID subscriptionId, final Boolean isDryRunForInvoiceNotification,  final DateTime futureNotificationTime, final DateTime targetDate, final InternalCallContext internalCallContext) {
         final NotificationQueue nextBillingQueue;
         try {
             nextBillingQueue = notificationQueueService.getNotificationQueue(DefaultInvoiceService.INVOICE_SERVICE_NAME,
@@ -54,7 +67,7 @@ public class DefaultNextBillingDatePoster implements NextBillingDatePoster {
             log.info("Queuing next billing date notification at {} for subscriptionId {}", futureNotificationTime.toString(), subscriptionId.toString());
 
             nextBillingQueue.recordFutureNotificationFromTransaction(entitySqlDaoWrapperFactory.getHandle().getConnection(), futureNotificationTime,
-                                                                     new NextBillingDateNotificationKey(subscriptionId), internalCallContext.getUserToken(),
+                                                                     new NextBillingDateNotificationKey(subscriptionId, targetDate, isDryRunForInvoiceNotification), internalCallContext.getUserToken(),
                                                                      internalCallContext.getAccountRecordId(), internalCallContext.getTenantRecordId());
         } catch (final NoSuchNotificationQueue e) {
             log.error("Attempting to put items on a non-existent queue (NextBillingDateNotifier).", e);
@@ -62,4 +75,5 @@ public class DefaultNextBillingDatePoster implements NextBillingDatePoster {
             log.error("Failed to serialize notificationKey for subscriptionId {}", subscriptionId);
         }
     }
+
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDateNotificationKey.java b/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDateNotificationKey.java
index b8349c0..664d415 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDateNotificationKey.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDateNotificationKey.java
@@ -18,6 +18,7 @@ package org.killbill.billing.invoice.notification;
 
 import java.util.UUID;
 
+import org.joda.time.DateTime;
 import org.killbill.notificationq.DefaultUUIDNotificationKey;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
@@ -25,8 +26,24 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 
 public class NextBillingDateNotificationKey extends DefaultUUIDNotificationKey {
 
+    private Boolean isDryRunForInvoiceNotification;
+    private DateTime targetDate;
+
     @JsonCreator
-    public NextBillingDateNotificationKey(@JsonProperty("uuidKey") final UUID uuidKey) {
+    public NextBillingDateNotificationKey(@JsonProperty("uuidKey") final UUID uuidKey,
+                                          @JsonProperty("targetDate") final DateTime targetDate,
+                                          @JsonProperty("isDryRunForInvoiceNotification") final Boolean isDryRunForInvoiceNotification) {
         super(uuidKey);
+        this.targetDate = targetDate;
+        this.isDryRunForInvoiceNotification = isDryRunForInvoiceNotification;
+    }
+
+    @JsonProperty("isDryRunForInvoiceNotification")
+    public Boolean isDryRunForInvoiceNotification() {
+        return isDryRunForInvoiceNotification;
+    }
+
+    public DateTime getTargetDate() {
+        return targetDate;
     }
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDatePoster.java b/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDatePoster.java
index 9e6312c..b702434 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDatePoster.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/notification/NextBillingDatePoster.java
@@ -28,4 +28,8 @@ public interface NextBillingDatePoster {
 
     void insertNextBillingNotificationFromTransaction(EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, UUID accountId,
                                                       UUID subscriptionId, DateTime futureNotificationTime, InternalCallContext internalCallContext);
+
+    void insertNextBillingDryRunNotificationFromTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final UUID accountId,
+                                                                   final UUID subscriptionId, final DateTime futureNotificationTime, final DateTime targetDate, final InternalCallContext internalCallContext);
+
 }
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/api/user/TestEventJson.java b/invoice/src/test/java/org/killbill/billing/invoice/api/user/TestEventJson.java
index 6fa5710..1d44414 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/api/user/TestEventJson.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/api/user/TestEventJson.java
@@ -19,7 +19,9 @@ package org.killbill.billing.invoice.api.user;
 import java.math.BigDecimal;
 import java.util.UUID;
 
+import org.joda.time.DateTime;
 import org.joda.time.LocalDate;
+import org.killbill.billing.events.InvoiceNotificationInternalEvent;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
@@ -42,6 +44,16 @@ public class TestEventJson extends InvoiceTestSuiteNoDB {
         Assert.assertEquals(obj, e);
     }
 
+
+    @Test(groups = "fast")
+    public void testInvoiceNotificationEvent() throws Exception {
+        final InvoiceNotificationInternalEvent e = new DefaultInvoiceNotificationInternalEvent(UUID.randomUUID(),  new BigDecimal(12.0), Currency.USD, new DateTime(), 1L, 2L, null);
+        final String json = mapper.writeValueAsString(e);
+
+        final Object obj = mapper.readValue(json, DefaultInvoiceNotificationInternalEvent.class);
+        Assert.assertEquals(obj, e);
+    }
+
     @Test(groups = "fast")
     public void testEmptyInvoiceEvent() throws Exception {
         final NullInvoiceInternalEvent e = new DefaultNullInvoiceEvent(UUID.randomUUID(), new LocalDate(), 1L, 2L, null);
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java b/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java
index 0343fbc..bf63597 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java
@@ -65,6 +65,7 @@ import org.killbill.billing.util.currency.KillBillMoney;
 import org.killbill.clock.Clock;
 import org.killbill.clock.DefaultClock;
 import org.mockito.Mockito;
+import org.skife.config.TimeSpan;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testng.annotations.Test;
@@ -113,6 +114,11 @@ public class TestDefaultInvoiceGenerator extends InvoiceTestSuiteNoDB {
             public boolean isInsertZeroUsageItems() {
                 return true;
             }
+
+            @Override
+            public TimeSpan getDryRunNotificationSchedule() {
+                return new TimeSpan("0s");
+            }
         };
         this.generator = new DefaultInvoiceGenerator(clock, null, invoiceConfig, internalCallContextFactory);
     }
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotificationKey.java b/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotificationKey.java
new file mode 100644
index 0000000..d3bf3ed
--- /dev/null
+++ b/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotificationKey.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2014-2015 Groupon, Inc
+ * Copyright 2014-2015 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.invoice.notification;
+
+import java.util.UUID;
+
+import org.joda.time.DateTime;
+import org.killbill.billing.util.jackson.ObjectMapper;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+public class TestNextBillingDateNotificationKey {
+
+    private static final ObjectMapper mapper = new ObjectMapper();
+
+    @Test(groups = "fast")
+    public void testBasic() throws Exception {
+
+        final UUID uuidKey = UUID.randomUUID();
+        final DateTime targetDate = new DateTime();
+        final Boolean isDryRunForInvoiceNotification = Boolean.FALSE;
+
+        final NextBillingDateNotificationKey key = new NextBillingDateNotificationKey(uuidKey, targetDate, isDryRunForInvoiceNotification);
+        final String json = mapper.writeValueAsString(key);
+
+        final NextBillingDateNotificationKey result = mapper.readValue(json, NextBillingDateNotificationKey.class);
+        Assert.assertEquals(result.getUuidKey(), uuidKey);
+        Assert.assertEquals(result.getTargetDate().compareTo(targetDate), 0);
+        Assert.assertEquals(result.isDryRunForInvoiceNotification(), isDryRunForInvoiceNotification);
+    }
+
+    @Test(groups = "fast")
+    public void testWithMissingFields() throws Exception {
+        final String json = "{\"uuidKey\":\"a38c363f-b25b-4287-8ebc-55964e116d2f\"}";
+        final NextBillingDateNotificationKey result = mapper.readValue(json, NextBillingDateNotificationKey.class);
+        Assert.assertEquals(result.getUuidKey().toString(), "a38c363f-b25b-4287-8ebc-55964e116d2f");
+        Assert.assertNull(result.getTargetDate());
+        Assert.assertNull(result.isDryRunForInvoiceNotification());
+
+    }
+}
\ No newline at end of file
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotifier.java b/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotifier.java
index cb6c553..6d083d1 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotifier.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/notification/TestNextBillingDateNotifier.java
@@ -37,7 +37,6 @@ public class TestNextBillingDateNotifier extends InvoiceTestSuiteWithEmbeddedDB 
     @Test(groups = "slow")
     public void testInvoiceNotifier() throws Exception {
 
-        final UUID accountId = UUID.randomUUID();
         final SubscriptionBase subscription = invoiceUtil.createSubscription();
         final UUID subscriptionId = subscription.getId();
         final DateTime now = clock.getUTCNow();
@@ -46,7 +45,7 @@ public class TestNextBillingDateNotifier extends InvoiceTestSuiteWithEmbeddedDB 
         final NotificationQueue nextBillingQueue = notificationQueueService.getNotificationQueue(DefaultInvoiceService.INVOICE_SERVICE_NAME, DefaultNextBillingDateNotifier.NEXT_BILLING_DATE_NOTIFIER_QUEUE);
 
 
-        nextBillingQueue.recordFutureNotification(now, new NextBillingDateNotificationKey(subscriptionId), internalCallContext.getUserToken(), internalCallContext.getAccountRecordId(), internalCallContext.getTenantRecordId());
+        nextBillingQueue.recordFutureNotification(now, new NextBillingDateNotificationKey(subscriptionId, now, Boolean.FALSE), internalCallContext.getUserToken(), internalCallContext.getAccountRecordId(), internalCallContext.getTenantRecordId());
 
         // Move time in the future after the notification effectiveDate
         ((ClockMock) clock).setDeltaFromReality(3000);

pom.xml 2(+1 -1)

diff --git a/pom.xml b/pom.xml
index b2c9cef..96c2026 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
     <parent>
         <artifactId>killbill-oss-parent</artifactId>
         <groupId>org.kill-bill.billing</groupId>
-        <version>0.10.1</version>
+        <version>0.10.2</version>
     </parent>
     <artifactId>killbill</artifactId>
     <version>0.13.6-SNAPSHOT</version>
diff --git a/util/src/main/java/org/killbill/billing/util/config/InvoiceConfig.java b/util/src/main/java/org/killbill/billing/util/config/InvoiceConfig.java
index c32ec52..6d5a205 100644
--- a/util/src/main/java/org/killbill/billing/util/config/InvoiceConfig.java
+++ b/util/src/main/java/org/killbill/billing/util/config/InvoiceConfig.java
@@ -19,6 +19,7 @@ package org.killbill.billing.util.config;
 import org.skife.config.Config;
 import org.skife.config.Default;
 import org.skife.config.Description;
+import org.skife.config.TimeSpan;
 
 public interface InvoiceConfig extends KillbillConfig {
 
@@ -37,4 +38,8 @@ public interface InvoiceConfig extends KillbillConfig {
     @Description("Whether to insert usage items with a zero amount")
     public boolean isInsertZeroUsageItems();
 
+    @Config("org.killbill.invoice.dryRunNotificationSchedule")
+    @Default("0s")
+    @Description("DryRun invoice notification time before targetDate (ignored if set to 0s)")
+    public TimeSpan getDryRunNotificationSchedule();
 }
diff --git a/util/src/test/java/org/killbill/billing/api/TestApiListener.java b/util/src/test/java/org/killbill/billing/api/TestApiListener.java
index a1d7fbd..0f92505 100644
--- a/util/src/test/java/org/killbill/billing/api/TestApiListener.java
+++ b/util/src/test/java/org/killbill/billing/api/TestApiListener.java
@@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit;
 import javax.inject.Inject;
 
 import org.joda.time.DateTime;
+import org.killbill.billing.events.InvoiceNotificationInternalEvent;
 import org.skife.jdbi.v2.Handle;
 import org.skife.jdbi.v2.IDBI;
 import org.skife.jdbi.v2.tweak.HandleCallback;
@@ -102,6 +103,7 @@ public class TestApiListener {
         PHASE,
         BLOCK,
         INVOICE,
+        INVOICE_NOTIFICATION,
         INVOICE_ADJUSTMENT,
         PAYMENT,
         PAYMENT_ERROR,
@@ -208,6 +210,13 @@ public class TestApiListener {
     }
 
     @Subscribe
+    public void handleInvoiceNotificationEvents(final InvoiceNotificationInternalEvent event) {
+        log.info(String.format("Got Invoice notification event %s", event.toString()));
+        assertEqualsNicely(NextEvent.INVOICE_NOTIFICATION);
+        notifyIfStackEmpty();
+    }
+
+    @Subscribe
     public void handleInvoiceEvents(final InvoiceCreationInternalEvent event) {
         log.info(String.format("Got Invoice event %s", event.toString()));
         assertEqualsNicely(NextEvent.INVOICE);