killbill-memoizeit

Merge branch 'master' of github.com:iKentoo/killbill into

6/22/2014 9:45:14 AM

Changes

api/pom.xml 4(+4 -0)

invoice/pom.xml 8(+8 -0)

pom.xml 3(+2 -1)

util/pom.xml 4(+4 -0)

Details

api/pom.xml 4(+4 -0)

diff --git a/api/pom.xml b/api/pom.xml
index 07ab93a..0cdefb1 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -50,6 +50,10 @@
         </dependency>
         <dependency>
             <groupId>org.kill-bill.billing.plugin</groupId>
+            <artifactId>killbill-plugin-api-invoice</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.kill-bill.billing.plugin</groupId>
             <artifactId>killbill-plugin-api-payment</artifactId>
         </dependency>
         <dependency>
diff --git a/api/src/main/java/org/killbill/billing/invoice/plugin/api/NoOpInvoicePluginApi.java b/api/src/main/java/org/killbill/billing/invoice/plugin/api/NoOpInvoicePluginApi.java
new file mode 100644
index 0000000..be59960
--- /dev/null
+++ b/api/src/main/java/org/killbill/billing/invoice/plugin/api/NoOpInvoicePluginApi.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.plugin.api;
+
+public interface NoOpInvoicePluginApi extends InvoicePluginApi {
+}

invoice/pom.xml 8(+8 -0)

diff --git a/invoice/pom.xml b/invoice/pom.xml
index 5be2c58..d95947b 100644
--- a/invoice/pom.xml
+++ b/invoice/pom.xml
@@ -112,6 +112,10 @@
         </dependency>
         <dependency>
             <groupId>org.kill-bill.billing</groupId>
+            <artifactId>killbill-platform-osgi-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.kill-bill.billing</groupId>
             <artifactId>killbill-platform-test</artifactId>
             <scope>test</scope>
         </dependency>
@@ -131,6 +135,10 @@
             <scope>test</scope>
         </dependency>
         <dependency>
+            <groupId>org.kill-bill.billing.plugin</groupId>
+            <artifactId>killbill-plugin-api-invoice</artifactId>
+        </dependency>
+        <dependency>
             <groupId>org.kill-bill.commons</groupId>
             <artifactId>killbill-clock</artifactId>
         </dependency>
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java b/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java
index 5d10162..5675180 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java
@@ -45,6 +45,7 @@ import org.killbill.billing.invoice.model.CreditAdjInvoiceItem;
 import org.killbill.billing.invoice.model.DefaultInvoice;
 import org.killbill.billing.invoice.model.ExternalChargeInvoiceItem;
 import org.killbill.billing.invoice.model.InvoiceItemFactory;
+import org.killbill.billing.invoice.template.HtmlInvoice;
 import org.killbill.billing.invoice.template.HtmlInvoiceGenerator;
 import org.killbill.billing.tag.TagInternalApi;
 import org.killbill.billing.util.api.TagApiException;
@@ -345,7 +346,8 @@ public class DefaultInvoiceUserApi implements InvoiceUserApi {
             }
         }
 
-        return generator.generateInvoice(account, invoice, manualPay);
+        HtmlInvoice htmlInvoice = generator.generateInvoice(account, invoice, manualPay);
+        return htmlInvoice.getBody();
     }
 
     @Override
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/calculator/InvoiceCalculatorUtils.java b/invoice/src/main/java/org/killbill/billing/invoice/calculator/InvoiceCalculatorUtils.java
index 472b796..e815d1f 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/calculator/InvoiceCalculatorUtils.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/calculator/InvoiceCalculatorUtils.java
@@ -58,7 +58,8 @@ public abstract class InvoiceCalculatorUtils {
 
     // Regular line item (charges)
     public static boolean isCharge(final InvoiceItem invoiceItem) {
-        return InvoiceItemType.EXTERNAL_CHARGE.equals(invoiceItem.getInvoiceItemType()) ||
+        return InvoiceItemType.TAX.equals(invoiceItem.getInvoiceItemType()) ||
+               InvoiceItemType.EXTERNAL_CHARGE.equals(invoiceItem.getInvoiceItemType()) ||
                InvoiceItemType.FIXED.equals(invoiceItem.getInvoiceItemType()) ||
                InvoiceItemType.USAGE.equals(invoiceItem.getInvoiceItemType()) ||
                InvoiceItemType.RECURRING.equals(invoiceItem.getInvoiceItemType());
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
index 0bef768..268c9dc 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
@@ -43,12 +43,16 @@ import org.killbill.billing.invoice.notification.EmailInvoiceNotifier;
 import org.killbill.billing.invoice.notification.NextBillingDateNotifier;
 import org.killbill.billing.invoice.notification.NextBillingDatePoster;
 import org.killbill.billing.invoice.notification.NullInvoiceNotifier;
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
 import org.killbill.billing.platform.api.KillbillConfigSource;
 import org.killbill.billing.util.config.InvoiceConfig;
 import org.killbill.billing.util.glue.KillBillModule;
 import org.killbill.billing.util.template.translation.TranslatorConfig;
 import org.skife.config.ConfigurationObjectFactory;
 
+import com.google.inject.TypeLiteral;
+
 public class DefaultInvoiceModule extends KillBillModule implements InvoiceModule {
 
     InvoiceConfig config;
@@ -118,10 +122,15 @@ public class DefaultInvoiceModule extends KillBillModule implements InvoiceModul
         bind(InvoiceGenerator.class).to(DefaultInvoiceGenerator.class).asEagerSingleton();
     }
 
+    protected void installInvoicePluginApi() {
+        bind(new TypeLiteral<OSGIServiceRegistration<InvoicePluginApi>>() {}).toProvider(DefaultInvoiceProviderPluginRegistryProvider.class).asEagerSingleton();
+    }
+
     @Override
     protected void configure() {
         installConfig();
 
+        installInvoicePluginApi();
         installInvoiceService();
         installInvoiceNotifier();
         installNotifiers();
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceProviderPluginRegistryProvider.java b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceProviderPluginRegistryProvider.java
new file mode 100644
index 0000000..4ef1264
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceProviderPluginRegistryProvider.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.glue;
+
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
+import org.killbill.billing.invoice.provider.DefaultInvoiceProviderPluginRegistry;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class DefaultInvoiceProviderPluginRegistryProvider implements Provider<OSGIServiceRegistration<InvoicePluginApi>> {
+
+    @Inject
+    public DefaultInvoiceProviderPluginRegistryProvider() {
+    }
+
+    @Override
+    public OSGIServiceRegistration<InvoicePluginApi> get() {
+        final DefaultInvoiceProviderPluginRegistry pluginRegistry = new DefaultInvoiceProviderPluginRegistry();
+
+        return pluginRegistry;
+    }
+}
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 3b74980..4cb1371 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
@@ -1,7 +1,9 @@
 /*
  * Copyright 2010-2013 Ning, Inc.
+ * Copyright 2014 Groupon, Inc
+ * Copyright 2014 The Billing Project, LLC
  *
- * Ning licenses this file to you under the Apache License, version 2.0
+ * 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:
  *
@@ -30,27 +32,16 @@ import javax.annotation.Nullable;
 
 import org.joda.time.DateTime;
 import org.joda.time.LocalDate;
-import org.killbill.billing.catalog.api.BillingMode;
-import org.killbill.billing.catalog.api.Usage;
-import org.killbill.billing.invoice.usage.UsageUtils;
-import org.killbill.billing.junction.BillingEvent;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import org.killbill.billing.ErrorCode;
 import org.killbill.billing.ObjectType;
 import org.killbill.billing.account.api.Account;
 import org.killbill.billing.account.api.AccountApiException;
 import org.killbill.billing.account.api.AccountInternalApi;
-import org.killbill.bus.api.PersistentBus;
-import org.killbill.bus.api.PersistentBus.EventBusException;
 import org.killbill.billing.callcontext.InternalCallContext;
 import org.killbill.billing.callcontext.InternalTenantContext;
+import org.killbill.billing.catalog.api.BillingMode;
 import org.killbill.billing.catalog.api.Currency;
-import org.killbill.clock.Clock;
-import org.killbill.commons.locker.GlobalLock;
-import org.killbill.commons.locker.GlobalLocker;
-import org.killbill.commons.locker.LockFailedException;
+import org.killbill.billing.catalog.api.Usage;
 import org.killbill.billing.events.BusInternalEvent;
 import org.killbill.billing.events.EffectiveSubscriptionInternalEvent;
 import org.killbill.billing.events.InvoiceAdjustmentInternalEvent;
@@ -72,21 +63,32 @@ import org.killbill.billing.invoice.generator.InvoiceGenerator;
 import org.killbill.billing.invoice.model.DefaultInvoice;
 import org.killbill.billing.invoice.model.FixedPriceInvoiceItem;
 import org.killbill.billing.invoice.model.RecurringInvoiceItem;
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
 import org.killbill.billing.junction.BillingEventSet;
 import org.killbill.billing.junction.BillingInternalApi;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
+import org.killbill.billing.payment.api.PluginProperty;
 import org.killbill.billing.subscription.api.SubscriptionBaseInternalApi;
 import org.killbill.billing.subscription.api.user.SubscriptionBaseApiException;
+import org.killbill.billing.util.callcontext.CallContext;
 import org.killbill.billing.util.callcontext.TenantContext;
 import org.killbill.billing.util.dao.NonEntityDao;
 import org.killbill.billing.util.globallocker.LockerType;
 import org.killbill.billing.util.timezone.DateAndTimeZoneContext;
+import org.killbill.bus.api.PersistentBus;
+import org.killbill.bus.api.PersistentBus.EventBusException;
+import org.killbill.clock.Clock;
+import org.killbill.commons.locker.GlobalLock;
+import org.killbill.commons.locker.GlobalLocker;
+import org.killbill.commons.locker.LockFailedException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.inject.Inject;
 
 public class InvoiceDispatcher {
@@ -104,9 +106,11 @@ public class InvoiceDispatcher {
     private final GlobalLocker locker;
     private final PersistentBus eventBus;
     private final Clock clock;
+    private final OSGIServiceRegistration<InvoicePluginApi> pluginRegistry;
 
     @Inject
-    public InvoiceDispatcher(final InvoiceGenerator generator, final AccountInternalApi accountApi,
+    public InvoiceDispatcher(final OSGIServiceRegistration<InvoicePluginApi> pluginRegistry,
+                             final InvoiceGenerator generator, final AccountInternalApi accountApi,
                              final BillingInternalApi billingApi,
                              final SubscriptionBaseInternalApi SubscriptionApi,
                              final InvoiceDao invoiceDao,
@@ -115,6 +119,7 @@ public class InvoiceDispatcher {
                              final GlobalLocker locker,
                              final PersistentBus eventBus,
                              final Clock clock) {
+        this.pluginRegistry = pluginRegistry;
         this.generator = generator;
         this.billingApi = billingApi;
         this.subscriptionApi = SubscriptionApi;
@@ -142,7 +147,7 @@ public class InvoiceDispatcher {
             }
             final UUID accountId = subscriptionApi.getAccountIdFromSubscriptionId(subscriptionId, context);
             processAccount(accountId, targetDate, false, context);
-        } catch (SubscriptionBaseApiException e) {
+        } catch (final SubscriptionBaseApiException e) {
             log.error("Failed handling SubscriptionBase change.",
                       new InvoiceApiException(ErrorCode.INVOICE_NO_ACCOUNT_ID_FOR_SUBSCRIPTION_ID, subscriptionId.toString()));
         }
@@ -155,7 +160,7 @@ public class InvoiceDispatcher {
             lock = locker.lockWithNumberOfTries(LockerType.ACCOUNT_FOR_INVOICE_PAYMENTS.toString(), accountId.toString(), NB_LOCK_TRY);
 
             return processAccountWithLock(accountId, targetDate, dryRun, context);
-        } catch (LockFailedException e) {
+        } catch (final LockFailedException e) {
             // Not good!
             log.error(String.format("Failed to process invoice for account %s, targetDate %s",
                                     accountId.toString(), targetDate), e);
@@ -167,7 +172,6 @@ public class InvoiceDispatcher {
         return null;
     }
 
-
     private Invoice processAccountWithLock(final UUID accountId, final DateTime targetDateTime,
                                            final boolean dryRun, final InternalCallContext context) throws InvoiceApiException {
         try {
@@ -180,7 +184,6 @@ public class InvoiceDispatcher {
                                                                   new DateAndTimeZoneContext(billingEvents.iterator().next().getEffectiveDate(), account.getTimeZone(), clock) :
                                                                   null;
 
-
             List<Invoice> invoices = new ArrayList<Invoice>();
             if (!billingEvents.isAccountAutoInvoiceOff()) {
                 invoices = ImmutableList.<Invoice>copyOf(Collections2.transform(invoiceDao.getInvoicesByAccount(context),
@@ -206,6 +209,21 @@ public class InvoiceDispatcher {
                 }
             } else {
                 if (!dryRun) {
+                    // Ask external invoice plugins if additional items (tax, etc) shall be added to the invoice
+                    final List<InvoicePluginApi> invoicePlugins = this.getInvoicePlugins();
+                    final CallContext callContext = buildCallContext(context);
+                    for (final InvoicePluginApi invoicePlugin : invoicePlugins) {
+                        final List<InvoiceItem> items = invoicePlugin.getAdditionalInvoiceItems(invoice, ImmutableList.<PluginProperty>of(), callContext);
+                        if (items != null) {
+                            for (final InvoiceItem item : items) {
+                                if (InvoiceItemType.EXTERNAL_CHARGE.equals(item.getInvoiceItemType()) || InvoiceItemType.TAX.equals(item.getInvoiceItemType())) {
+                                    invoice.addInvoiceItem(item);
+                                } else {
+                                    log.warn("Ignoring invoice item of type {} from InvoicePluginApi {}: {}", item.getInvoiceItemType(), invoicePlugin, item);
+                                }
+                            }
+                        }
+                    }
 
                     // Extract the set of invoiceId for which we see items that don't belong to current generated invoice
                     final Set<UUID> adjustedUniqueOtherInvoiceId = new TreeSet<UUID>();
@@ -219,12 +237,12 @@ public class InvoiceDispatcher {
                     isRealInvoiceWithItems = adjustedUniqueOtherInvoiceId.remove(invoice.getId());
 
                     if (isRealInvoiceWithItems) {
-                        log.info("Generated invoice {} with {} items for accountId {} and targetDate {} (targetDateTime {})", new Object[]{invoice.getId(), invoice.getNumberOfItems(),                                                                                                                                           accountId, targetDate, targetDateTime});
+                        log.info("Generated invoice {} with {} items for accountId {} and targetDate {} (targetDateTime {})", new Object[]{invoice.getId(), invoice.getNumberOfItems(), accountId, targetDate, targetDateTime});
                     } else {
                         final Joiner joiner = Joiner.on(",");
                         final String adjustedInvoices = joiner.join(adjustedUniqueOtherInvoiceId.toArray(new UUID[adjustedUniqueOtherInvoiceId.size()]));
                         log.info("Adjusting existing invoices {} with {} items for accountId {} and targetDate {} (targetDateTime {})", new Object[]{adjustedInvoices, invoice.getNumberOfItems(),
-                                                                                                                                           accountId, targetDate, targetDateTime});
+                                                                                                                                                     accountId, targetDate, targetDateTime});
                     }
 
                     final InvoiceModelDao invoiceModelDao = new InvoiceModelDao(invoice);
@@ -251,34 +269,32 @@ public class InvoiceDispatcher {
                     final List<InvoiceItem> recurringInvoiceItems = invoice.getInvoiceItems(RecurringInvoiceItem.class);
                     setChargedThroughDates(dateAndTimeZoneContext, fixedPriceInvoiceItems, recurringInvoiceItems, context);
 
-
                     final List<InvoiceInternalEvent> events = new ArrayList<InvoiceInternalEvent>();
                     if (isRealInvoiceWithItems) {
                         events.add(new DefaultInvoiceCreationEvent(invoice.getId(), invoice.getAccountId(),
                                                                    invoice.getBalance(), invoice.getCurrency(),
                                                                    context.getAccountRecordId(), context.getTenantRecordId(), context.getUserToken()));
                     }
-                    for (UUID cur : adjustedUniqueOtherInvoiceId) {
+                    for (final UUID cur : adjustedUniqueOtherInvoiceId) {
                         final InvoiceAdjustmentInternalEvent event = new DefaultInvoiceAdjustmentEvent(cur, invoice.getAccountId(),
                                                                                                        context.getAccountRecordId(), context.getTenantRecordId(), context.getUserToken());
                         events.add(event);
                     }
 
-
-                    for (InvoiceInternalEvent event : events) {
+                    for (final InvoiceInternalEvent event : events) {
                         postEvent(event, accountId, context);
                     }
                 }
             }
 
-            if (account.isNotifiedForInvoices() && isRealInvoiceWithItems  && !dryRun) {
+            if (account.isNotifiedForInvoices() && isRealInvoiceWithItems && !dryRun) {
                 // Need to re-hydrate the invoice object to get the invoice number (record id)
                 // API_FIX InvoiceNotifier public API?
                 invoiceNotifier.notify(account, new DefaultInvoice(invoiceDao.getById(invoice.getId(), context)), buildTenantContext(context));
             }
 
             return invoice;
-        } catch (AccountApiException e) {
+        } catch (final AccountApiException e) {
             log.error("Failed handling SubscriptionBase change.", e);
             return null;
         }
@@ -288,6 +304,17 @@ public class InvoiceDispatcher {
         return context.toTenantContext(nonEntityDao.retrieveIdFromObject(context.getTenantRecordId(), ObjectType.TENANT));
     }
 
+    private CallContext buildCallContext(final InternalCallContext context) {
+        return context.toCallContext(nonEntityDao.retrieveIdFromObject(context.getTenantRecordId(), ObjectType.TENANT));
+    }
+
+    private List<InvoicePluginApi> getInvoicePlugins() {
+        final List<InvoicePluginApi> invoicePlugins = new ArrayList<InvoicePluginApi>();
+        for (final String name : this.pluginRegistry.getAllServices()) {
+            invoicePlugins.add(this.pluginRegistry.getServiceForName(name));
+        }
+        return invoicePlugins;
+    }
 
     @VisibleForTesting
     Map<UUID, List<DateTime>> createNextFutureNotificationDate(final List<InvoiceItemModelDao> invoiceItems, final Map<String, Usage> knownUsages, final DateAndTimeZoneContext dateAndTimeZoneContext) {
@@ -307,7 +334,7 @@ public class InvoiceDispatcher {
                 result.put(item.getSubscriptionId(), perSubscriptionCallback);
             }
 
-            switch(item.getType()) {
+            switch (item.getType()) {
                 case RECURRING:
                     if ((item.getEndDate() != null) &&
                         (item.getAmount() == null ||
@@ -318,7 +345,7 @@ public class InvoiceDispatcher {
 
                 case USAGE:
                     final String key = item.getSubscriptionId().toString() + ":" + item.getUsageName();
-                    final LocalDate perSubscriptionUsageRecurringDate  = perSubscriptionUsage.get(key);
+                    final LocalDate perSubscriptionUsageRecurringDate = perSubscriptionUsage.get(key);
                     if (perSubscriptionUsageRecurringDate == null || perSubscriptionUsageRecurringDate.compareTo(item.getEndDate()) < 0) {
                         perSubscriptionUsage.put(key, item.getEndDate());
                     }
@@ -330,12 +357,12 @@ public class InvoiceDispatcher {
         }
 
         for (final String key : perSubscriptionUsage.keySet()) {
-            final String [] parts = key.split(":");
+            final String[] parts = key.split(":");
             final UUID subscriptionId = UUID.fromString(parts[0]);
 
             final List<DateTime> perSubscriptionCallback = result.get(subscriptionId);
             final String usageName = parts[1];
-            final LocalDate endDate =  perSubscriptionUsage.get(key);
+            final LocalDate endDate = perSubscriptionUsage.get(key);
 
             final DateTime subscriptionUsageCallbackDate = getNextUsageBillingDate(usageName, endDate, dateAndTimeZoneContext, knownUsages);
             perSubscriptionCallback.add(subscriptionUsageCallbackDate);
@@ -369,7 +396,7 @@ public class InvoiceDispatcher {
     private void postEvent(final BusInternalEvent event, final UUID accountId, final InternalCallContext context) {
         try {
             eventBus.post(event);
-        } catch (EventBusException e) {
+        } catch (final EventBusException e) {
             log.error(String.format("Failed to post event %s for account %s", event.getBusEventType(), accountId), e);
         }
     }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/model/InvoiceItemFactory.java b/invoice/src/main/java/org/killbill/billing/invoice/model/InvoiceItemFactory.java
index 32adcfd..7eeda4b 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/model/InvoiceItemFactory.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/model/InvoiceItemFactory.java
@@ -85,6 +85,9 @@ public class InvoiceItemFactory {
             case USAGE:
                 item = new UsageInvoiceItem(id, createdDate, invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, usageName, startDate, endDate, description, amount, currency);
                 break;
+            case TAX:
+                item = new TaxInvoiceItem(id, createdDate, invoiceId, accountId, bundleId, description, startDate, amount, currency);
+                break;
             default:
                 throw new RuntimeException("Unexpected type of event item " + type);
         }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/model/TaxInvoiceItem.java b/invoice/src/main/java/org/killbill/billing/invoice/model/TaxInvoiceItem.java
new file mode 100644
index 0000000..24fb389
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/model/TaxInvoiceItem.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.model;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import javax.annotation.Nullable;
+
+import org.joda.time.DateTime;
+import org.joda.time.LocalDate;
+import org.killbill.billing.catalog.api.Currency;
+import org.killbill.billing.invoice.api.InvoiceItemType;
+
+public class TaxInvoiceItem extends InvoiceItemBase {
+
+    public TaxInvoiceItem(final UUID invoiceId, final UUID accountId, @Nullable final UUID bundleId, @Nullable final String description,
+                          final LocalDate date, final BigDecimal amount, final Currency currency) {
+        this(UUID.randomUUID(), invoiceId, accountId, bundleId, description, date, amount, currency);
+    }
+
+    public TaxInvoiceItem(final UUID id, final UUID invoiceId, final UUID accountId, @Nullable final UUID bundleId,
+                          @Nullable final String description, final LocalDate date, final BigDecimal amount, final Currency currency) {
+        this(id, null, invoiceId, accountId, bundleId, description, date, amount, currency);
+    }
+
+    public TaxInvoiceItem(final UUID id, @Nullable final DateTime createdDate, final UUID invoiceId, final UUID accountId, @Nullable final UUID bundleId,
+                          @Nullable final String description, final LocalDate date, final BigDecimal amount, final Currency currency) {
+        super(id, createdDate, invoiceId, accountId, bundleId, null, description, null, null, null, date, null, amount, currency);
+    }
+
+    @Override
+    public String getDescription() {
+        if (description != null) {
+            return description;
+        }
+
+        return "Tax";
+    }
+
+    @Override
+    public InvoiceItemType getInvoiceItemType() {
+        return InvoiceItemType.TAX;
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/notification/EmailInvoiceNotifier.java b/invoice/src/main/java/org/killbill/billing/invoice/notification/EmailInvoiceNotifier.java
index 723ca8f..ae7f044 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/notification/EmailInvoiceNotifier.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/notification/EmailInvoiceNotifier.java
@@ -27,6 +27,7 @@ import org.killbill.billing.account.api.AccountEmail;
 import org.killbill.billing.invoice.api.Invoice;
 import org.killbill.billing.invoice.api.InvoiceApiException;
 import org.killbill.billing.invoice.api.InvoiceNotifier;
+import org.killbill.billing.invoice.template.HtmlInvoice;
 import org.killbill.billing.invoice.template.HtmlInvoiceGenerator;
 import org.killbill.billing.util.callcontext.InternalCallContextFactory;
 import org.killbill.billing.callcontext.InternalTenantContext;
@@ -85,18 +86,22 @@ public class EmailInvoiceNotifier implements InvoiceNotifier {
             }
         }
 
-        final String htmlBody;
+        final HtmlInvoice htmlInvoice;
         try {
-            htmlBody = generator.generateInvoice(account, invoice, manualPay);
+            htmlInvoice = generator.generateInvoice(account, invoice, manualPay);
         } catch (IOException e) {
             throw new InvoiceApiException(e, ErrorCode.EMAIL_SENDING_FAILED);
         }
 
-        final String subject = config.getInvoiceEmailSubject();
+        // take localized subject, or the configured one if the localized one is not available
+        String subject = htmlInvoice.getSubject();
+        if (subject == null) {
+            subject = config.getInvoiceEmailSubject();
+        }
 
         final EmailSender sender = new DefaultEmailSender(config);
         try {
-            sender.sendHTMLEmail(to, cc, subject, htmlBody);
+            sender.sendHTMLEmail(to, cc, subject, htmlInvoice.getBody());
         } catch (EmailApiException e) {
             throw new InvoiceApiException(e, ErrorCode.EMAIL_SENDING_FAILED);
         } catch (IOException e) {
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/provider/DefaultInvoiceProviderPluginRegistry.java b/invoice/src/main/java/org/killbill/billing/invoice/provider/DefaultInvoiceProviderPluginRegistry.java
new file mode 100644
index 0000000..404b9fc
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/provider/DefaultInvoiceProviderPluginRegistry.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.provider;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
+import org.killbill.billing.osgi.api.OSGIServiceDescriptor;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.inject.Inject;
+
+public class DefaultInvoiceProviderPluginRegistry implements OSGIServiceRegistration<InvoicePluginApi> {
+
+    private final static Logger log = LoggerFactory.getLogger(DefaultInvoiceProviderPluginRegistry.class);
+
+    private final Map<String, InvoicePluginApi> pluginsByName = new ConcurrentHashMap<String, InvoicePluginApi>();
+
+    @Inject
+    public DefaultInvoiceProviderPluginRegistry() {
+    }
+
+
+    @Override
+    public void registerService(final OSGIServiceDescriptor desc, final InvoicePluginApi service) {
+        log.info("DefaultInvoiceProviderPluginRegistry registering service " + desc.getRegistrationName());
+        pluginsByName.put(desc.getRegistrationName(), service);
+    }
+
+    @Override
+    public void unregisterService(final String serviceName) {
+        log.info("DefaultInvoiceProviderPluginRegistry unregistering service " + serviceName);
+        pluginsByName.remove(serviceName);
+    }
+
+    @Override
+    public InvoicePluginApi getServiceForName(final String name) {
+        if (name == null) {
+            throw new IllegalArgumentException("Null invoice plugin API name");
+        }
+        final InvoicePluginApi plugin = pluginsByName.get(name);
+        return plugin;
+    }
+
+    @Override
+    public Set<String> getAllServices() {
+        return pluginsByName.keySet();
+    }
+
+    @Override
+    public Class<InvoicePluginApi> getServiceType() {
+        return InvoicePluginApi.class;
+    }}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/provider/DefaultNoOpInvoiceProviderPlugin.java b/invoice/src/main/java/org/killbill/billing/invoice/provider/DefaultNoOpInvoiceProviderPlugin.java
new file mode 100644
index 0000000..826782c
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/provider/DefaultNoOpInvoiceProviderPlugin.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.provider;
+
+import java.util.List;
+
+import org.killbill.billing.invoice.api.Invoice;
+import org.killbill.billing.invoice.api.InvoiceItem;
+import org.killbill.billing.invoice.plugin.api.NoOpInvoicePluginApi;
+import org.killbill.billing.payment.api.PluginProperty;
+import org.killbill.billing.util.callcontext.CallContext;
+import org.killbill.clock.Clock;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.Inject;
+
+public class DefaultNoOpInvoiceProviderPlugin implements NoOpInvoicePluginApi {
+
+    private final Clock clock;
+
+    @Inject
+    public DefaultNoOpInvoiceProviderPlugin(final Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public List<InvoiceItem> getAdditionalInvoiceItems(final Invoice invoice, Iterable<PluginProperty> properties, CallContext context) {
+        return ImmutableList.<InvoiceItem>of();
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/provider/NoOpInvoiceProviderPluginModule.java b/invoice/src/main/java/org/killbill/billing/invoice/provider/NoOpInvoiceProviderPluginModule.java
new file mode 100644
index 0000000..e4ced96
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/provider/NoOpInvoiceProviderPluginModule.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.provider;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.name.Names;
+
+public class NoOpInvoiceProviderPluginModule extends AbstractModule {
+    private final String instanceName;
+
+    public NoOpInvoiceProviderPluginModule(final String instanceName) {
+        this.instanceName = instanceName;
+    }
+
+    @Override
+    protected void configure() {
+        bind(DefaultNoOpInvoiceProviderPlugin.class)
+                .annotatedWith(Names.named(instanceName))
+                .toProvider(new NoOpInvoiceProviderPluginProvider(instanceName))
+                .asEagerSingleton();
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/provider/NoOpInvoiceProviderPluginProvider.java b/invoice/src/main/java/org/killbill/billing/invoice/provider/NoOpInvoiceProviderPluginProvider.java
new file mode 100644
index 0000000..0b863f4
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/provider/NoOpInvoiceProviderPluginProvider.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2014 Groupon, Inc
+ *
+ * Groupon 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.provider;
+
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
+import org.killbill.billing.osgi.api.OSGIServiceDescriptor;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
+import org.killbill.clock.Clock;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class NoOpInvoiceProviderPluginProvider implements Provider<DefaultNoOpInvoiceProviderPlugin> {
+
+    private final String instanceName;
+
+    private Clock clock;
+    private OSGIServiceRegistration<InvoicePluginApi> registry;
+
+    public NoOpInvoiceProviderPluginProvider(final String instanceName) {
+        this.instanceName = instanceName;
+
+    }
+
+    @Inject
+    public void setPaymentProviderPluginRegistry(final OSGIServiceRegistration<InvoicePluginApi> registry, final Clock clock) {
+        this.clock = clock;
+        this.registry = registry;
+    }
+
+    @Override
+    public DefaultNoOpInvoiceProviderPlugin get() {
+
+        final DefaultNoOpInvoiceProviderPlugin plugin = new DefaultNoOpInvoiceProviderPlugin(clock);
+        final OSGIServiceDescriptor desc = new OSGIServiceDescriptor() {
+            @Override
+            public String getPluginSymbolicName() {
+                return null;
+            }
+            @Override
+            public String getRegistrationName() {
+                return instanceName;
+            }
+        };
+        registry.registerService(desc, plugin);
+        return plugin;
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoice.java b/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoice.java
new file mode 100644
index 0000000..5d88db1
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoice.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning 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.template;
+
+public class HtmlInvoice {
+    private String subject;
+    private String body;
+
+    public String getSubject() {
+        return subject;
+    }
+
+    public void setSubject(final String subject) {
+        this.subject = subject;
+    }
+
+    public String getBody() {
+        return body;
+    }
+
+    public void setBody(final String body) {
+        this.body = body;
+    }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoiceGenerator.java b/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoiceGenerator.java
index cd95a16..e04a8f1 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoiceGenerator.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/template/HtmlInvoiceGenerator.java
@@ -52,12 +52,13 @@ public class HtmlInvoiceGenerator {
         this.currencyConversionApi = currencyConversionApi;
     }
 
-    public String generateInvoice(final Account account, @Nullable final Invoice invoice, final boolean manualPay) throws IOException {
+    public HtmlInvoice generateInvoice(final Account account, @Nullable final Invoice invoice, final boolean manualPay) throws IOException {
         // Don't do anything if the invoice is null
         if (invoice == null) {
             return null;
         }
 
+        HtmlInvoice invoiceData = new HtmlInvoice();
         final Map<String, Object> data = new HashMap<String, Object>();
         final DefaultInvoiceTranslator invoiceTranslator = new DefaultInvoiceTranslator(config);
         final String accountLocale = Strings.emptyToNull(account.getLocale());
@@ -70,10 +71,14 @@ public class HtmlInvoiceGenerator {
         final InvoiceFormatter formattedInvoice = factory.createInvoiceFormatter(config, invoice, locale, currencyConversionApi);
         data.put("invoice", formattedInvoice);
 
+        invoiceData.setSubject(invoiceTranslator.getInvoiceEmailSubject());
+
         if (manualPay) {
-            return templateEngine.executeTemplate(config.getManualPayTemplateName(), data);
+            invoiceData.setBody(templateEngine.executeTemplate(config.getManualPayTemplateName(), data));
         } else {
-            return templateEngine.executeTemplate(config.getTemplateName(), data);
+            invoiceData.setBody(templateEngine.executeTemplate(config.getTemplateName(), data));
         }
+
+        return invoiceData;
     }
 }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/template/translator/DefaultInvoiceTranslator.java b/invoice/src/main/java/org/killbill/billing/invoice/template/translator/DefaultInvoiceTranslator.java
index 8c644d3..4bb5ef1 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/template/translator/DefaultInvoiceTranslator.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/template/translator/DefaultInvoiceTranslator.java
@@ -47,6 +47,12 @@ public class DefaultInvoiceTranslator extends DefaultTranslatorBase implements I
     }
 
     @Override
+    public String getInvoiceEmailSubject() {
+        String subject = getTranslation(locale, "invoiceEmailSubject");
+        return (!"invoiceEmailSubject".equals(subject)) ? subject : null;
+    }
+
+    @Override
     public String getInvoiceTitle() {
         return getTranslation(locale, "invoiceTitle");
     }
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/template/translator/InvoiceStrings.java b/invoice/src/main/java/org/killbill/billing/invoice/template/translator/InvoiceStrings.java
index 624ff76..e98faf4 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/template/translator/InvoiceStrings.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/template/translator/InvoiceStrings.java
@@ -18,6 +18,8 @@ package org.killbill.billing.invoice.template.translator;
 
 public interface InvoiceStrings {
 
+    String getInvoiceEmailSubject();
+
     String getInvoiceTitle();
 
     String getInvoiceDate();
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/AccountItemTree.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/AccountItemTree.java
index be44a10..e005d56 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/AccountItemTree.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/AccountItemTree.java
@@ -94,6 +94,7 @@ public class AccountItemTree {
         Preconditions.checkState(!isBuilt);
         switch (existingItem.getInvoiceItemType()) {
             case EXTERNAL_CHARGE:
+            case TAX:
             case CBA_ADJ:
             case CREDIT_ADJ:
             case REFUND_ADJ:
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java b/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java
index 2d3e616..de92ab1 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java
@@ -32,8 +32,10 @@ import org.killbill.billing.invoice.dao.InvoiceDao;
 import org.killbill.billing.invoice.generator.InvoiceGenerator;
 import org.killbill.billing.invoice.glue.TestInvoiceModuleWithEmbeddedDb;
 import org.killbill.billing.invoice.notification.NextBillingDateNotifier;
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
 import org.killbill.billing.junction.BillingInternalApi;
 import org.killbill.billing.lifecycle.api.BusService;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
 import org.killbill.billing.platform.api.KillbillConfigSource;
 import org.killbill.billing.subscription.api.SubscriptionBaseInternalApi;
 import org.killbill.billing.util.api.TagUserApi;
@@ -106,6 +108,8 @@ public abstract class InvoiceTestSuiteWithEmbeddedDB extends GuicyKillbillTestSu
     protected TestInvoiceHelper invoiceUtil;
     @Inject
     protected TestInvoiceNotificationQListener testInvoiceNotificationQListener;
+    @Inject
+    protected OSGIServiceRegistration<InvoicePluginApi> pluginRegistry;
 
     @Override
     protected KillbillConfigSource getConfigSource() {
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/TestHtmlInvoiceGenerator.java b/invoice/src/test/java/org/killbill/billing/invoice/TestHtmlInvoiceGenerator.java
index 93609ff..53f096f 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/TestHtmlInvoiceGenerator.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/TestHtmlInvoiceGenerator.java
@@ -31,6 +31,7 @@ import org.killbill.billing.catalog.api.Currency;
 import org.killbill.billing.invoice.api.Invoice;
 import org.killbill.billing.invoice.api.InvoiceItem;
 import org.killbill.billing.invoice.api.formatters.InvoiceFormatterFactory;
+import org.killbill.billing.invoice.template.HtmlInvoice;
 import org.killbill.billing.invoice.template.HtmlInvoiceGenerator;
 import org.killbill.billing.invoice.template.formatters.DefaultInvoiceFormatterFactory;
 import org.killbill.billing.util.email.templates.MustacheTemplateEngine;
@@ -58,20 +59,24 @@ public class TestHtmlInvoiceGenerator extends InvoiceTestSuiteNoDB {
 
     @Test(groups = "fast")
     public void testGenerateInvoice() throws Exception {
-        final String output = g.generateInvoice(createAccount(), createInvoice(), false);
+        final HtmlInvoice output = g.generateInvoice(createAccount(), createInvoice(), false);
         Assert.assertNotNull(output);
+        Assert.assertNotNull(output.getBody());
+        Assert.assertEquals(output.getSubject(), "Your invoice");
     }
 
     @Test(groups = "fast")
     public void testGenerateEmptyInvoice() throws Exception {
         final Invoice invoice = Mockito.mock(Invoice.class);
-        final String output = g.generateInvoice(createAccount(), invoice, false);
+        final HtmlInvoice output = g.generateInvoice(createAccount(), invoice, false);
         Assert.assertNotNull(output);
+        Assert.assertNotNull(output.getBody());
+        Assert.assertEquals(output.getSubject(), "Your invoice");
     }
 
     @Test(groups = "fast")
     public void testGenerateNullInvoice() throws Exception {
-        final String output = g.generateInvoice(createAccount(), null, false);
+        final HtmlInvoice output = g.generateInvoice(createAccount(), null, false);
         Assert.assertNull(output);
     }
 
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
index 2e431bc..4c0521b 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
@@ -90,7 +90,7 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
         final DateTime target = new DateTime();
 
         final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
-        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi, invoiceDao,
+        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(pluginRegistry, generator, accountApi, billingApi, subscriptionApi, invoiceDao,
                                                                    nonEntityDao, invoiceNotifier, locker, busService.getBus(),
                                                                    clock);
 
@@ -143,7 +143,7 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
 
         Mockito.when(billingApi.getBillingEventsForAccountAndUpdateAccountBCD(Mockito.<UUID>any(), Mockito.<InternalCallContext>any())).thenReturn(events);
         final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
-        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi, invoiceDao,
+        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(pluginRegistry, generator, accountApi, billingApi, subscriptionApi, invoiceDao,
                                                                    nonEntityDao, invoiceNotifier, locker, busService.getBus(),
                                                                    clock);
 
@@ -201,7 +201,7 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
                                                                  null, "planName", "phaseName", null, startDate, endDate, new BigDecimal("23.9"), new BigDecimal("23.9"), Currency.EUR, null);
 
         final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
-        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi, invoiceDao,
+        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(pluginRegistry, generator, accountApi, billingApi, subscriptionApi, invoiceDao,
                                                                    nonEntityDao, invoiceNotifier, locker, busService.getBus(),
                                                                    clock);
 
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java
index 326a5af..b2935ff 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java
@@ -59,11 +59,13 @@ import org.killbill.billing.invoice.dao.InvoicePaymentModelDao;
 import org.killbill.billing.invoice.dao.InvoicePaymentSqlDao;
 import org.killbill.billing.invoice.generator.InvoiceGenerator;
 import org.killbill.billing.invoice.notification.NullInvoiceNotifier;
+import org.killbill.billing.invoice.plugin.api.InvoicePluginApi;
 import org.killbill.billing.junction.BillingEvent;
 import org.killbill.billing.junction.BillingEventSet;
 import org.killbill.billing.junction.BillingInternalApi;
 import org.killbill.billing.lifecycle.api.BusService;
 import org.killbill.billing.mock.MockAccountBuilder;
+import org.killbill.billing.osgi.api.OSGIServiceRegistration;
 import org.killbill.billing.subscription.api.SubscriptionBase;
 import org.killbill.billing.subscription.api.SubscriptionBaseInternalApi;
 import org.killbill.billing.subscription.api.SubscriptionBaseTransitionType;
@@ -138,6 +140,7 @@ public class TestInvoiceHelper {
     private final InvoiceGenerator generator;
     private final BillingInternalApi billingApi;
     private final AccountInternalApi accountApi;
+    private final OSGIServiceRegistration<InvoicePluginApi> pluginRegistry;
     private final AccountUserApi accountUserApi;
     private final SubscriptionBaseInternalApi subscriptionApi;
     private final BusService busService;
@@ -153,10 +156,11 @@ public class TestInvoiceHelper {
     private final InvoiceItemSqlDao invoiceItemSqlDao;
 
     @Inject
-    public TestInvoiceHelper(final InvoiceGenerator generator, final IDBI dbi,
+    public TestInvoiceHelper(final OSGIServiceRegistration<InvoicePluginApi> pluginRegistry, final InvoiceGenerator generator, final IDBI dbi,
                              final BillingInternalApi billingApi, final AccountInternalApi accountApi, final AccountUserApi accountUserApi, final SubscriptionBaseInternalApi subscriptionApi, final BusService busService,
                              final InvoiceDao invoiceDao, final GlobalLocker locker, final Clock clock, final NonEntityDao nonEntityDao, final InternalCallContext internalCallContext,
                              final InternalCallContextFactory internalCallContextFactory) {
+        this.pluginRegistry = pluginRegistry;
         this.generator = generator;
         this.billingApi = billingApi;
         this.accountApi = accountApi;
@@ -190,7 +194,7 @@ public class TestInvoiceHelper {
         Mockito.when(billingApi.getBillingEventsForAccountAndUpdateAccountBCD(Mockito.<UUID>any(), Mockito.<InternalCallContext>any())).thenReturn(events);
 
         final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
-        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi,
+        final InvoiceDispatcher dispatcher = new InvoiceDispatcher(pluginRegistry, generator, accountApi, billingApi, subscriptionApi,
                                                                    invoiceDao, nonEntityDao, invoiceNotifier, locker, busService.getBus(),
                                                                    clock);
 
diff --git a/invoice/src/test/resources/org/killbill/billing/util/template/translation/InvoiceTranslation_en_US.properties b/invoice/src/test/resources/org/killbill/billing/util/template/translation/InvoiceTranslation_en_US.properties
index 02d074a..823dc54 100644
--- a/invoice/src/test/resources/org/killbill/billing/util/template/translation/InvoiceTranslation_en_US.properties
+++ b/invoice/src/test/resources/org/killbill/billing/util/template/translation/InvoiceTranslation_en_US.properties
@@ -1,3 +1,4 @@
+invoiceEmailSubject=Your invoice
 invoiceTitle=INVOICE
 invoiceDate=Date:
 invoiceNumber=Invoice #

pom.xml 3(+2 -1)

diff --git a/pom.xml b/pom.xml
index 72c6f5b..b67e0cc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,8 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!--
   ~ Copyright 2010-2013 Ning, Inc.
+  ~ Copyright 2014 Groupon, Inc
   ~
-  ~ Ning licenses this file to you under the Apache License, version 2.0
+  ~ Groupon 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:
   ~

util/pom.xml 4(+4 -0)

diff --git a/util/pom.xml b/util/pom.xml
index b91d532..a8911e1 100644
--- a/util/pom.xml
+++ b/util/pom.xml
@@ -144,6 +144,10 @@
         </dependency>
         <dependency>
             <groupId>org.kill-bill.billing.plugin</groupId>
+            <artifactId>killbill-plugin-api-invoice</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.kill-bill.billing.plugin</groupId>
             <artifactId>killbill-plugin-api-notification</artifactId>
         </dependency>
         <dependency>