org.killbill.billing.plugin.simpletax.SimpleTaxPlugin.java Source code

Java tutorial

Introduction

Here is the source code for org.killbill.billing.plugin.simpletax.SimpleTaxPlugin.java

Source

/*
 * Copyright 2015 Benjamin Gandon
 *
 * Licensed 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.plugin.simpletax;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableSetMultimap.builder;
import static com.google.common.collect.Iterables.filter;
import static com.google.common.collect.Iterables.transform;
import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.collect.Maps.newHashMap;
import static com.google.common.collect.Ordering.natural;
import static java.math.BigDecimal.ZERO;
import static java.math.RoundingMode.HALF_UP;
import static org.killbill.billing.ObjectType.INVOICE;
import static org.killbill.billing.ObjectType.INVOICE_ITEM;
import static org.killbill.billing.notification.plugin.api.ExtBusEventType.INVOICE_CREATION;
import static org.killbill.billing.plugin.api.invoice.PluginInvoiceItem.createAdjustmentItem;
import static org.killbill.billing.plugin.api.invoice.PluginInvoiceItem.createTaxItem;
import static org.killbill.billing.plugin.simpletax.config.SimpleTaxConfig.DEFAULT_TAX_ITEM_DESC;
import static org.killbill.billing.plugin.simpletax.config.http.CustomFieldService.TAX_COUNTRY_CUSTOM_FIELD_NAME;
import static org.killbill.billing.plugin.simpletax.internal.TaxCodeService.TAX_CODES_FIELD_NAME;
import static org.killbill.billing.plugin.simpletax.plumbing.SimpleTaxActivator.PLUGIN_NAME;
import static org.killbill.billing.plugin.simpletax.util.InvoiceHelpers.amountWithAdjustments;
import static org.killbill.billing.plugin.simpletax.util.InvoiceHelpers.sumAmounts;
import static org.osgi.service.log.LogService.LOG_DEBUG;
import static org.osgi.service.log.LogService.LOG_ERROR;
import static org.osgi.service.log.LogService.LOG_INFO;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.joda.time.DateTime;
import org.joda.time.LocalDate;
import org.killbill.billing.account.api.Account;
import org.killbill.billing.catalog.api.CatalogApiException;
import org.killbill.billing.catalog.api.StaticCatalog;
import org.killbill.billing.invoice.api.Invoice;
import org.killbill.billing.invoice.api.InvoiceApiException;
import org.killbill.billing.invoice.api.InvoiceItem;
import org.killbill.billing.notification.plugin.api.ExtBusEvent;
import org.killbill.billing.payment.api.PluginProperty;
import org.killbill.billing.plugin.api.PluginCallContext;
import org.killbill.billing.plugin.api.PluginTenantContext;
import org.killbill.billing.plugin.api.invoice.PluginInvoicePluginApi;
import org.killbill.billing.plugin.simpletax.config.SimpleTaxConfig;
import org.killbill.billing.plugin.simpletax.config.http.CustomFieldService;
import org.killbill.billing.plugin.simpletax.internal.Country;
import org.killbill.billing.plugin.simpletax.internal.TaxCode;
import org.killbill.billing.plugin.simpletax.internal.TaxCodeService;
import org.killbill.billing.plugin.simpletax.plumbing.SimpleTaxConfigurationHandler;
import org.killbill.billing.plugin.simpletax.resolving.NullTaxResolver;
import org.killbill.billing.plugin.simpletax.resolving.TaxResolver;
import org.killbill.billing.plugin.simpletax.util.CheckedLazyValue;
import org.killbill.billing.plugin.simpletax.util.CheckedSupplier;
import org.killbill.billing.plugin.simpletax.util.ImmutableCustomField;
import org.killbill.billing.util.api.CustomFieldApiException;
import org.killbill.billing.util.api.CustomFieldUserApi;
import org.killbill.billing.util.callcontext.CallContext;
import org.killbill.billing.util.callcontext.TenantContext;
import org.killbill.billing.util.customfield.CustomField;
import org.killbill.clock.Clock;
import org.killbill.killbill.osgi.libs.killbill.OSGIConfigPropertiesService;
import org.killbill.killbill.osgi.libs.killbill.OSGIKillbillAPI;
import org.killbill.killbill.osgi.libs.killbill.OSGIKillbillEventDispatcher.OSGIKillbillEventHandler;
import org.killbill.killbill.osgi.libs.killbill.OSGIKillbillLogService;
import org.killbill.killbill.osgi.libs.killbill.OSGIServiceNotAvailable;

import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Ordering;
import com.google.common.collect.SetMultimap;

/**
 * Main class for the Kill Bill Simple Tax Plugin.
 * <p>
 * This plugin adds tax items to each new invoice that is being created, based
 * on the tax codes that {@linkplain SimpleTaxConfig have been configured} and
 * then {@linkplain TaxResolver resolved}.
 * <p>
 * Tax codes that are directly set on invoice items, before this plugin run,
 * take precedence and won't be overridden.
 * <p>
 * Tax codes can also be added, removed or changed on historical invoices. The
 * plugin adds missing taxes or adjusts existing tax items accordingly.
 * <p>
 * Country-specific rules can be implemented by configuring custom
 * {@link TaxResolver} implementations.
 * <p>
 * The implementation is idempotent. Subsequent calls with the same inputs and
 * server state will results in no new item being created.
 *
 * @author Benjamin Gandon
 * @see SimpleTaxConfig
 * @see TaxResolver
 */
public class SimpleTaxPlugin extends PluginInvoicePluginApi implements OSGIKillbillEventHandler {

    private SimpleTaxConfigurationHandler configHandler;
    private CustomFieldService customFieldService;

    /**
     * Creates a new simple-tax plugin.
     *
     * @param configHandler
     *            The configuration handler to use for this plugin instance.
     * @param customFieldService
     *            The service to use when accessing custom fields.
     * @param metaApi
     *            The Kill Bill meta-API.
     * @param configService
     *            The service to use for accessing the plugin configuration
     *            properties.
     * @param logService
     *            The service to use when logging events.
     * @param clockService
     *            The clock service to use when accessing the current time.
     */
    public SimpleTaxPlugin(SimpleTaxConfigurationHandler configHandler, CustomFieldService customFieldService,
            OSGIKillbillAPI metaApi, OSGIConfigPropertiesService configService, OSGIKillbillLogService logService,
            Clock clockService) {
        super(metaApi, configService, logService, clockService);
        this.configHandler = configHandler;
        this.customFieldService = customFieldService;
    }

    /**
     * @return The Kill Bill services that constitute the API.
     */
    protected OSGIKillbillAPI services() {
        return killbillAPI;
    }

    /**
     * Returns additional invoice items to be added to the invoice upon
     * creation, based on the tax codes that have been configured, or directly
     * set on invoice items.
     * <p>
     * This method produces two types of additional invoice items.
     * <p>
     * First, this method lists any missing tax items in the new invoice.
     * <p>
     * Then, adjustments might have been created on any historical invoice of
     * the account. Thus, this method also lists any necessary adjustments to
     * any tax items in any historical invoices.
     * <p>
     * Plus, tax codes can be added, changed or removed on historical invoices.
     * The affected tax amounts will be adjusted accordingly.
     *
     * @param newInvoice
     *            The invoice that is being created.
     * @param properties
     *            Any user-specified plugin properties, coming straight out of
     *            the API request that has triggered this code to run. See their
     *            <a href=
     *            "http://docs.killbill.io/0.15/userguide_payment.html#_plugin_properties"
     *            >documentation for payment plugins</a>.
     * @param callCtx
     *            The context in which this code is running.
     * @return A new immutable list of new tax items, or adjustments on existing
     *         tax items. Never {@code null}, and guaranteed not having any
     *         {@code null} elements.
     */
    @Override
    public List<InvoiceItem> getAdditionalInvoiceItems(Invoice newInvoice, Iterable<PluginProperty> properties,
            CallContext callCtx) {

        TaxComputationContext taxCtx = createTaxComputationContext(newInvoice, callCtx);
        TaxResolver taxResolver = instanciateTaxResolver(taxCtx);
        Map<UUID, TaxCode> newTaxCodes = addMissingTaxCodes(newInvoice, taxResolver, taxCtx, callCtx);

        ImmutableList.Builder<InvoiceItem> additionalItems = ImmutableList.builder();
        for (Invoice invoice : taxCtx.getAllInvoices()) {

            List<InvoiceItem> newItems;
            if (invoice.equals(newInvoice)) {
                newItems = computeTaxOrAdjustmentItemsForNewInvoice(invoice, taxCtx, newTaxCodes);
            } else {
                newItems = computeTaxOrAdjustmentItemsForHistoricalInvoice(invoice, taxCtx);
            }
            additionalItems.addAll(newItems);
        }
        return additionalItems.build();
    }

    @Override
    public void handleKillbillEvent(ExtBusEvent event) {
        logService.log(LOG_DEBUG,
                "Received event [" + event.getEventType() + "] for object [" + event.getObjectId() + "] of type ["
                        + event.getObjectType() + "] belonging to account [" + event.getAccountId()
                        + "] in tenant [" + event.getTenantId() + "]");

        if (!INVOICE_CREATION.equals(event.getEventType())) {
            return;
        }
        if (!INVOICE.equals(event.getObjectType())) {
            return;
        }
        UUID invoiceId = event.getObjectId();
        UUID tenantId = event.getTenantId();
        logService.log(LOG_INFO, "Adding tax codes to invoice [" + invoiceId
                + "] as post-creation treatment for tenant [" + tenantId + "]");

        Invoice newInvoice;
        try {
            newInvoice = getInvoiceUserApi().getInvoice(invoiceId, new PluginTenantContext(tenantId));
        } catch (OSGIServiceNotAvailable exc) {
            logService.log(LOG_ERROR,
                    "before post-treating taxes on invoice [" + invoiceId + "]: invoice user API is not available",
                    exc);
            throw exc;
        } catch (InvoiceApiException exc) {
            logService.log(LOG_ERROR,
                    "before post-treating taxes on invoice [" + invoiceId + "]: invoice cannot be fetched", exc);
            throw new RuntimeException("unexpected error before post-treating taxes on invoice [" + invoiceId + "]",
                    exc);
        }

        CallContext callCtx = new PluginCallContext(PLUGIN_NAME, DateTime.now(), tenantId);

        TaxComputationContext taxCtx = createTaxComputationContext(newInvoice, callCtx);
        TaxResolver taxResolver = instanciateTaxResolver(taxCtx);
        Map<UUID, TaxCode> newTaxCodes = addMissingTaxCodes(newInvoice, taxResolver, taxCtx, callCtx);

        for (Entry<UUID, TaxCode> entry : newTaxCodes.entrySet()) {
            UUID invoiceItemId = entry.getKey();
            TaxCode taxCode = entry.getValue();
            persistTaxCode(taxCode, invoiceItemId, newInvoice, callCtx);
        }
    }

    /**
     * Pre-compute data that will be useful to computing tax items and tax
     * adjustment items.
     *
     * @param newInvoice
     *            The invoice that is being created.
     * @param tenantCtx
     *            The context in which this code is running.
     * @return An immutable holder for helpful pre-computed data when adding or
     *         adjusting taxes in the account invoices. Never {@code null}.
     */
    private TaxComputationContext createTaxComputationContext(Invoice newInvoice, TenantContext tenantCtx) {

        SimpleTaxConfig cfg = configHandler.getConfigurable(tenantCtx.getTenantId());

        UUID accountId = newInvoice.getAccountId();
        Account account = getAccount(accountId, tenantCtx);
        CustomField taxCountryField = customFieldService
                .findFieldByNameAndAccountAndTenant(TAX_COUNTRY_CUSTOM_FIELD_NAME, accountId, tenantCtx);
        Country accountTaxCountry = null;
        if (taxCountryField != null) {
            try {
                accountTaxCountry = new Country(taxCountryField.getFieldValue());
            } catch (IllegalArgumentException exc) {
                logService.log(LOG_ERROR, "Illegal value of [" + taxCountryField.getFieldValue() + "] in field '"
                        + TAX_COUNTRY_CUSTOM_FIELD_NAME + "' for account " + accountId, exc);
            }
        }

        Set<Invoice> allInvoices = allInvoicesOfAccount(account, newInvoice, tenantCtx);

        Function<InvoiceItem, BigDecimal> toAdjustedAmount = toAdjustedAmount(allInvoices);
        Ordering<InvoiceItem> byAdjustedAmount = natural().onResultOf(toAdjustedAmount);

        TaxCodeService taxCodeService = taxCodeService(account, allInvoices, cfg, tenantCtx);

        return new TaxComputationContext(cfg, account, accountTaxCountry, allInvoices, toAdjustedAmount,
                byAdjustedAmount, taxCodeService);
    }

    /**
     * Lists all invoice of account as {@linkplain ImmutableSet immutable set},
     * including the passed {@code newInvoice} that is the new invoice being
     * currently created.
     * <p>
     * This implementation is indifferent to the persistence status of the
     * passed {@code newInvoice}. Persisted and not persisted invoices are
     * supported. This is a consequence of the workaround implemented to solve
     * the <a href="https://github.com/killbill/killbill/issues/265">issue
     * #265</a>.
     *
     * @param newInvoice
     *            The new invoice that is being created, which might have
     *            already been saved or not.
     * @param tenantCtx
     *            The context in which this code is running.
     * @return A new immutable set of all invoices for the account, including
     *         the new one being created. Never {@code null}, and guaranteed not
     *         having any {@code null} elements.
     */
    private Set<Invoice> allInvoicesOfAccount(Account account, Invoice newInvoice, TenantContext tenantCtx) {
        ImmutableSet.Builder<Invoice> builder = ImmutableSet.builder();
        builder.addAll(getInvoicesByAccountId(account.getId(), tenantCtx));

        // Workaround for https://github.com/killbill/killbill/issues/265
        builder.add(newInvoice);

        return builder.build();
    }

    /**
     * Creates the {@linkplain Function function} that returns the adjusted
     * amount out of a given {@linkplain InvoiceItem invoice item}.
     *
     * @param allInvoices
     *            The collection of all invoices for a given account.
     * @return The function that returns the adjusted amount of an invoice item.
     *         Never {@code null}.
     */
    private Function<InvoiceItem, BigDecimal> toAdjustedAmount(Set<Invoice> allInvoices) {
        final Multimap<UUID, InvoiceItem> allAdjustments = allAjdustmentsGroupedByAdjustedItem(allInvoices);

        return new Function<InvoiceItem, BigDecimal>() {
            @Override
            public BigDecimal apply(InvoiceItem item) {
                return amountWithAdjustments(item, allAdjustments);
            }
        };
    }

    /**
     * Groups the {@linkplain #isAdjustmentItem adjustment items} (found in a
     * given set of invoices) by the identifier of their
     * {@linkplain InvoiceItem#getLinkedItemId related} adjusted? items.
     * <p>
     * The resulting collection is typically computed on all invoices of a given
     * account.
     *
     * @param allInvoices
     *            A list of invoices.
     * @return A new immutable multi-map of the adjustment items, grouped by the
     *         items they relate to. Never {@code null}, and guaranteed not
     *         having any {@code null} elements.
     */
    private Multimap<UUID, InvoiceItem> allAjdustmentsGroupedByAdjustedItem(Set<Invoice> allInvoices) {
        ImmutableSetMultimap.Builder<UUID, InvoiceItem> builder = builder();
        for (Invoice invoice : allInvoices) {
            for (InvoiceItem item : invoice.getInvoiceItems()) {
                if (isAdjustmentItem(item)) {
                    builder.put(item.getLinkedItemId(), item);
                }
            }
        }
        return builder.build();
    }

    /**
     * Creates an instance of a tax code service.
     *
     * @param account
     *            The account to consider.
     * @param allInvoices
     *            The collection of all invoices for the given account.
     * @param cfg
     *            The plugin configuration.
     * @param tenantCtx
     *            The context in which this code is running.
     * @return A new tax codes service.
     */
    private TaxCodeService taxCodeService(Account account, Set<Invoice> allInvoices, SimpleTaxConfig cfg,
            final TenantContext tenantCtx) {
        CheckedSupplier<StaticCatalog, CatalogApiException> catalog = new CheckedLazyValue<StaticCatalog, CatalogApiException>() {
            @Override
            protected StaticCatalog initialize() throws CatalogApiException {
                return services().getCatalogUserApi().getCurrentCatalog(null, tenantCtx);
            }
        };
        SetMultimap<UUID, CustomField> taxFieldsOfAllInvoices = taxFieldsOfInvoices(account, allInvoices,
                tenantCtx);
        return new TaxCodeService(catalog, cfg, taxFieldsOfAllInvoices);
    }

    /**
     * Groups the {@linkplain CustomField custom fields} on
     * {@linkplain #INVOICE_ITEM invoice items} by the
     * {@linkplain Invoice#getId() identifier} of their related
     * {@linkplain Invoice invoices}.
     *
     * @param account
     *            The account to consider
     * @param allInvoices
     *            The collection of all invoices for the given account.
     * @param tenantCtx
     *            The context in which this code is running.
     * @return A new immutable multi-map containing the custom fields on all
     *         invoice items of the given account, grouped by the identifier of
     *         their relate invoice. Never {@code null}, and guaranteed not
     *         having any {@code null} elements.
     */
    private SetMultimap<UUID, CustomField> taxFieldsOfInvoices(Account account, Set<Invoice> allInvoices,
            TenantContext tenantCtx) {
        CustomFieldUserApi customFieldsService = services().getCustomFieldUserApi();
        List<CustomField> allCustomFields = customFieldsService.getCustomFieldsForAccountType(account.getId(),
                INVOICE_ITEM, tenantCtx);
        if ((allCustomFields == null) || allCustomFields.isEmpty()) {
            return ImmutableSetMultimap.of();
        }

        Map<UUID, Invoice> invoiceOfItem = newHashMap();
        for (Invoice invoice : allInvoices) {
            for (InvoiceItem item : invoice.getInvoiceItems()) {
                invoiceOfItem.put(item.getId(), invoice);
            }
        }

        ImmutableSetMultimap.Builder<UUID, CustomField> taxFieldsOfInvoice = ImmutableSetMultimap.builder();
        for (CustomField field : allCustomFields) {
            if (TAX_CODES_FIELD_NAME.equals(field.getFieldName())) {
                Invoice invoice = invoiceOfItem.get(field.getObjectId());
                taxFieldsOfInvoice.put(invoice.getId(), field);
            }
        }
        return taxFieldsOfInvoice.build();
    }

    /**
     * Instantiates the configured {@link TaxResolver} implementation. When
     * instantiation fails, a fail-safe {@link NullTaxResolver} is returned.
     *
     * @param taxCtx
     *            The context data to use when resolving tax codes.
     * @return A new instance of the configured {@link TaxResolver}, or an
     *         instance of {@link NullTaxResolver} if none was configured. Never
     *         {@code null}.
     */
    private TaxResolver instanciateTaxResolver(TaxComputationContext taxCtx) {
        Constructor<? extends TaxResolver> constructor = taxCtx.getConfig().getTaxResolverConstructor();
        Throwable issue;
        try {
            return constructor.newInstance(taxCtx);
        } catch (IllegalAccessException shouldNeverHappen) {
            // This should not happen because we are supposed to deal with a
            // public constructor by SimpleTaxConfig contract. Let it crash.
            throw new RuntimeException(shouldNeverHappen);
        } catch (IllegalArgumentException shouldNeverHappen) {
            // This should not happen because by SimpleTaxConfig contract, we
            // are supposed to deal with a constructor that accepts the expected
            // arguments types. Let it crash.
            throw shouldNeverHappen;
        } catch (InstantiationException exc) {
            issue = exc;
        } catch (InvocationTargetException exc) {
            issue = exc;
        } catch (ExceptionInInitializerError err) {
            issue = err;
        }
        logService.log(LOG_ERROR,
                "Cannot instanciate tax resolver. Defaulting to [" + NullTaxResolver.class.getName() + "].", issue);
        return new NullTaxResolver(taxCtx);
    }

    /**
     * Creates an lists the tax codes that are missing to the new invoice being
     * created.
     *
     * @param newInvoice
     *            The new invoice that is being created.
     * @param resolver
     *            The tax resolver to use.
     * @param taxCtx
     *            The context data to use when computing taxes.
     * @param callCtx
     *            The context in which this code is running.
     * @return A new immutable map of the tax codes to add, mapped from their
     *         related invoice item identifier. Never {@code null}, and
     *         guaranteed not having any {@code null} elements.
     */
    private Map<UUID, TaxCode> addMissingTaxCodes(Invoice newInvoice, TaxResolver resolver,
            final TaxComputationContext taxCtx, CallContext callCtx) {
        // Obtain tax codes from products of invoice items
        TaxCodeService taxCodesService = taxCtx.getTaxCodeService();
        SetMultimap<UUID, TaxCode> configuredTaxCodesForInvoiceItems = taxCodesService
                .resolveTaxCodesFromConfig(newInvoice);

        SetMultimap<UUID, TaxCode> existingTaxCodesForInvoiceItems = taxCodesService
                .findExistingTaxCodes(newInvoice);

        ImmutableMap.Builder<UUID, TaxCode> newTaxCodes = ImmutableMap.builder();
        // Add product tax codes to custom field if null or empty
        for (InvoiceItem item : newInvoice.getInvoiceItems()) {
            if (!isTaxableItem(item)) {
                continue;
            }
            Set<TaxCode> expectedTaxCodes = configuredTaxCodesForInvoiceItems.get(item.getId());
            // Note: expectedTaxCodes != null as per the Multimap contract
            if (expectedTaxCodes.isEmpty()) {
                continue;
            }
            Set<TaxCode> existingTaxCodes = existingTaxCodesForInvoiceItems.get(item.getId());
            // Note: existingTaxCodes != null as per the Multimap contract
            if (!existingTaxCodes.isEmpty()) {
                // Don't override existing tax codes
                continue;
            }

            final String accountTaxCountry = taxCtx.getAccountTaxCountry() == null ? null
                    : taxCtx.getAccountTaxCountry().getCode();
            Iterable<TaxCode> expectedInAccountCountry = filter(expectedTaxCodes, new Predicate<TaxCode>() {
                @Override
                public boolean apply(TaxCode taxCode) {
                    Country restrict = taxCode.getCountry();
                    return (restrict == null) || restrict.getCode().equals(accountTaxCountry);
                }
            });
            // resolve tax codes using regulation-specific logic
            TaxCode applicableCode = resolver.applicableCodeForItem(expectedInAccountCountry, item);
            if (applicableCode == null) {
                continue;
            }

            newTaxCodes.put(item.getId(), applicableCode);
        }
        return newTaxCodes.build();
    }

    private void persistTaxCode(TaxCode applicableCode, UUID invoiceItemId, Invoice newInvoice,
            CallContext callCtx) {
        CustomFieldUserApi customFieldsService = services().getCustomFieldUserApi();
        ImmutableCustomField.Builder taxCodesField = ImmutableCustomField.builder()//
                .withFieldName(TAX_CODES_FIELD_NAME)//
                .withFieldValue(applicableCode.getName())//
                .withObjectType(INVOICE_ITEM)//
                .withObjectId(invoiceItemId);
        CustomField field = taxCodesField.build();
        try {
            customFieldsService.addCustomFields(newArrayList(field), callCtx);
        } catch (CustomFieldApiException exc) {
            logService.log(LOG_ERROR,
                    "Cannot add custom field [" + field.getFieldName() + "] with value [" + field.getFieldValue()
                            + "] to invoice item [" + invoiceItemId + "] of invoice [" + newInvoice.getId()
                            + "] for tenant [" + callCtx.getTenantId() + "]",
                    exc);
            throw new RuntimeException("unexpected error while adding custom field [" + field.getFieldName()
                    + "] with value [" + field.getFieldValue() + "] to invoice item [" + invoiceItemId
                    + "] of invoice [" + newInvoice.getId() + "] for tenant [" + callCtx.getTenantId() + "]", exc);
        } catch (IllegalStateException exc) {
            if (!"org.killbill.billing.util.callcontext.InternalCallContextFactory$ObjectDoesNotExist"
                    .equals(exc.getClass().getName())) {
                throw exc;
            }
            logService.log(LOG_ERROR,
                    "Cannot add custom field [" + field.getFieldName() + "] with value [" + field.getFieldValue()
                            + "] to *non-existing* invoice item [" + invoiceItemId + "] of invoice ["
                            + newInvoice.getId() + "] for tenant [" + callCtx.getTenantId() + "]",
                    exc);
        }
    }

    /**
     * Compute tax items against taxable items, in a <em>newly created</em>
     * invoice, or adjust existing tax items that don't match the expected tax
     * amount, taking any adjustments into consideration.
     *
     * @param newInvoice
     *            The new invoice being created.
     * @param ctx
     *            The context data to use.
     * @param newTaxCodes
     *            The map of new tax code that have just been created for the
     *            given invoice.
     * @return A new immutable list of new tax items, or new adjustment items to
     *         add to the invoice. Never {@code null}, and guaranteed not having
     *         any {@code null} elements.
     */
    private List<InvoiceItem> computeTaxOrAdjustmentItemsForNewInvoice(Invoice newInvoice,
            TaxComputationContext ctx, Map<UUID, TaxCode> newTaxCodes) {

        SetMultimap<UUID, InvoiceItem> currentTaxItems = taxItemsGroupedByRelatedTaxedItems(newInvoice);

        SetMultimap<UUID, TaxCode> existingTaxCodes = ctx.getTaxCodeService().findExistingTaxCodes(newInvoice);

        ImmutableList.Builder<InvoiceItem> newItems = ImmutableList.builder();
        for (InvoiceItem item : newInvoice.getInvoiceItems()) {
            if (!isTaxableItem(item)) {
                continue;
            }

            TaxCode tax = null;
            Set<TaxCode> taxes = existingTaxCodes.get(item.getId());
            // Note: taxes != null as per the Multimap contract
            if (!taxes.isEmpty()) {
                tax = taxes.iterator().next();
            }
            if (tax == null) {
                tax = newTaxCodes.get(item.getId());
            }

            BigDecimal adjustedAmount = ctx.toAdjustedAmount().apply(item);
            BigDecimal expectedTaxAmount = computeTaxAmount(item, adjustedAmount, tax, ctx.getConfig());

            Set<InvoiceItem> relatedTaxItems = currentTaxItems.get(item.getId());
            BigDecimal currentTaxAmount = sumAmounts(transform(relatedTaxItems, ctx.toAdjustedAmount()));

            String taxItemDescription = tax == null ? DEFAULT_TAX_ITEM_DESC : tax.getTaxItemDescription();

            if (currentTaxAmount.compareTo(expectedTaxAmount) < 0) {
                BigDecimal missingTaxAmount = expectedTaxAmount.subtract(currentTaxAmount);
                if (relatedTaxItems.size() <= 0) {
                    // In case a taxable item has never been taxed yet, we are
                    // allowed to add tax to it since it belongs to a newly
                    // created invoice.
                    InvoiceItem newTaxItem = buildTaxItem(item, newInvoice.getInvoiceDate(), missingTaxAmount,
                            taxItemDescription);
                    newItems.add(newTaxItem);
                } else {
                    // Here we know that 'relatedTaxItems' is not empty so we
                    // have some tax items and thus 'largestTaxItem' not to be
                    // null.
                    InvoiceItem largestTaxItem = ctx.byAdjustedAmount().max(relatedTaxItems);

                    InvoiceItem positiveAdjItem = buildAdjustmentForTaxItem(largestTaxItem,
                            newInvoice.getInvoiceDate(), missingTaxAmount, taxItemDescription);
                    newItems.add(positiveAdjItem);
                }
            } else if (currentTaxAmount.compareTo(expectedTaxAmount) > 0) {
                BigDecimal negativeAdjAmount = expectedTaxAmount.subtract(currentTaxAmount);

                // Here 'currentTaxAmount' should be > 0 (if 'expectedTaxAmount'
                // properly is > 0), so we expect the item to have some tax
                // items and thus 'largestTaxItem' not to be null.
                InvoiceItem largestTaxItem = ctx.byAdjustedAmount().max(relatedTaxItems);

                InvoiceItem negativeAdjItem = buildAdjustmentForTaxItem(largestTaxItem, newInvoice.getInvoiceDate(),
                        negativeAdjAmount, taxItemDescription);
                newItems.add(negativeAdjItem);
            }
        }
        return newItems.build();
    }

    /**
     * Groups the {@linkplain #isTaxItem tax items} (found in a given invoice)
     * by the identifier of their {@linkplain InvoiceItem#getLinkedItemId
     * related} taxable? items.
     *
     * @param invoice
     *            An invoice.
     * @return An immutable multi-map of the tax items for the given invoice,
     *         grouped by the identifier of their related (taxed) item. Never
     *         {@code null}, and guaranteed not having any {@code null}
     *         elements.
     */
    private SetMultimap<UUID, InvoiceItem> taxItemsGroupedByRelatedTaxedItems(Invoice invoice) {
        ImmutableSetMultimap.Builder<UUID, InvoiceItem> currentTaxItemsBuilder = builder();
        for (InvoiceItem item : invoice.getInvoiceItems()) {
            if (isTaxItem(item)) {
                currentTaxItemsBuilder.put(item.getLinkedItemId(), item);
            }
        }
        return currentTaxItemsBuilder.build();
    }

    /**
     * Computes the amount of tax for a given amount, in the context of a given
     * invoice item, invoice, and account.
     *
     * @param item
     *            The invoice item to tax.
     * @param amount
     *            The adjusted amount of the item to tax.
     * @param tax
     *            The definition of the tax code to apply.
     * @param cfg
     *            The plugin configuration.
     * @return The amount of tax that should be paid by the account.
     */
    private BigDecimal computeTaxAmount(InvoiceItem item, BigDecimal amount, @Nullable TaxCode tax,
            SimpleTaxConfig cfg) {
        if (tax == null) {
            return ZERO;
        }
        return amount.multiply(tax.getRate()).setScale(cfg.getTaxAmountPrecision(), HALF_UP);
    }

    /**
     * Creates a tax item for a given taxable item, or returns {@code null} if
     * the amount of tax is {@code null} or zero.
     * <p>
     * The created tax item will be created in the same invoice as the taxable
     * item it relates to. And its date should be the one of the invoice that
     * the taxable item belongs to.
     * <p>
     * If a {@code null} description is passed, then a default description is
     * used instead.
     *
     * @param taxableItem
     *            the taxable item to tax, which <em>must</em> be
     *            {@linkplain #isTaxableItem of a taxable type}.
     * @param date
     *            the date at which the taxable item above was invoiced. This
     *            typically is the date of the invoice that the taxable item
     *            belongs to.
     * @param taxAmount
     *            the amount (of tax) for the new tax item to create
     * @param description
     *            an optional description for the tax item to create, or
     *            {@code null} if a default description is fine
     * @return A new tax item, or {@code null}.
     * @throws IllegalArgumentException
     *             if {@code taxableItem} is not {@linkplain #isTaxableItem of a
     *             taxable type}.
     * @see org.killbill.billing.plugin.api.invoice.PluginTaxCalculator#buildTaxItem
     */
    private InvoiceItem buildTaxItem(InvoiceItem taxableItem, LocalDate date, BigDecimal taxAmount,
            @Nonnull String description) {
        checkArgument(isTaxableItem(taxableItem), "not of a taxable type: %s", taxableItem.getInvoiceItemType());
        if ((taxAmount == null) || (ZERO.compareTo(taxAmount) == 0)) {
            // This can actually not happen in our current code. We just keep it
            // because it was taken from the API helper methods.
            return null;
        }
        return createTaxItem(taxableItem, taxableItem.getInvoiceId(), date, null, taxAmount, description);
    }

    /**
     * Creates an adjustment for a tax item, or returns {@code null} if the
     * amount is {@code null} or zero.
     * <p>
     * The created adjustment item will be created in the same invoice as the
     * taxable item it relates to. But its date should be the one of the new
     * invoice, the creation of which has triggered the adjustment.
     * <p>
     * If a {@code null} description is passed, then a default description is
     * used instead.
     *
     * @param taxItemToAdjust
     *            The tax item to be adjusted, which <em>must</em> be
     *            {@linkplain #isTaxItem of a tax type}.
     * @param date
     *            The date at which the tax item above is adjusted. This
     *            typically is the date of the new invoice, the creation of
     *            which has triggered the adjustment.
     * @param adjustmentAmount
     *            The (optional) adjustment amount
     * @param description
     *            An optional description for the adjustment item to create, or
     *            {@code null} if a default description is fine.
     * @return An new adjustment item, or {@code null}.
     * @throws IllegalArgumentException
     *             if {@code taxItemToAdjust} is not {@linkplain #isTaxItem of a
     *             tax type}.
     */
    private InvoiceItem buildAdjustmentForTaxItem(InvoiceItem taxItemToAdjust, LocalDate date,
            @Nullable BigDecimal adjustmentAmount, @Nonnull String description) {
        checkArgument(isTaxItem(taxItemToAdjust), "not a tax type: %s", taxItemToAdjust.getInvoiceItemType());
        if ((adjustmentAmount == null) || (ZERO.compareTo(adjustmentAmount) == 0)) {
            // This can actually not happen in our current code. We just keep it
            // because it was taken from the API helper methods.
            return null;
        }
        return createAdjustmentItem(taxItemToAdjust, taxItemToAdjust.getInvoiceId(), date, null, adjustmentAmount,
                description);
    }

    /**
     * Compute adjustment items on existing tax items in a <em>historical</em>
     * invoice.
     * <p>
     * Tax codes are allowed to change on historical invoice. They can be
     * removed, changed or added. Then taxes are adjusted or added accordingly.
     *
     * @param oldInvoice
     *            An historical invoice.
     * @param ctx
     *            The context data to use.
     * @return A new immutable list of new adjustment items to add to the
     *         invoice. Never {@code null}, and guaranteed not having any
     *         {@code null} elements.
     */
    private List<InvoiceItem> computeTaxOrAdjustmentItemsForHistoricalInvoice(Invoice oldInvoice,
            TaxComputationContext ctx) {

        SetMultimap<UUID, InvoiceItem> currentTaxItems = taxItemsGroupedByRelatedTaxedItems(oldInvoice);

        SetMultimap<UUID, TaxCode> existingTaxCodes = ctx.getTaxCodeService().findExistingTaxCodes(oldInvoice);

        ImmutableList.Builder<InvoiceItem> newItems = ImmutableList.builder();
        for (InvoiceItem item : oldInvoice.getInvoiceItems()) {
            if (!isTaxableItem(item)) {
                continue;
            }

            Set<InvoiceItem> relatedTaxItems = currentTaxItems.get(item.getId());

            TaxCode tax = null;
            Set<TaxCode> taxes = existingTaxCodes.get(item.getId());
            // Note: taxes != null as per the Multimap contract
            if (!taxes.isEmpty()) {
                tax = taxes.iterator().next();
            }

            BigDecimal adjustedAmount = ctx.toAdjustedAmount().apply(item);
            BigDecimal expectedTaxAmount = computeTaxAmount(item, adjustedAmount, tax, ctx.getConfig());
            BigDecimal currentTaxAmount = sumAmounts(transform(relatedTaxItems, ctx.toAdjustedAmount()));

            if (currentTaxAmount.compareTo(expectedTaxAmount) != 0) {
                BigDecimal adjustmentAmount = expectedTaxAmount.subtract(currentTaxAmount);

                if (relatedTaxItems.isEmpty()) {
                    // Here tax != null because with relatedTaxItem == null we
                    // necessarily have a zero currentTaxAmount, so
                    // expectedTaxAmount is not zero and necessarily results
                    // from a tax code
                    InvoiceItem taxItem = buildTaxItem(item, oldInvoice.getInvoiceDate(), adjustmentAmount,
                            tax.getTaxItemDescription());
                    newItems.add(taxItem);
                } else {
                    // here we have a tax item but the tax code might have been
                    // removed, so it could be null
                    InvoiceItem largestTaxItem = ctx.byAdjustedAmount().max(relatedTaxItems);
                    String taxItemDescription = tax != null ? tax.getTaxItemDescription()
                            : largestTaxItem.getDescription();
                    InvoiceItem adjItem = buildAdjustmentForTaxItem(largestTaxItem, oldInvoice.getInvoiceDate(),
                            adjustmentAmount, taxItemDescription);
                    newItems.add(adjItem);
                }
            }
        }
        return newItems.build();
    }
}