/* * Copyright 2014-2016 Groupon, Inc * Copyright 2014-2016 The Billing Project, LLC * * The Billing Project licenses this file to you under the Apache License, version 2.0 * (the "License"); you may not use this file except in compliance with the * License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package org.killbill.billing.invoice.generator; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; import javax.annotation.Nullable; import org.joda.time.LocalDate; import org.killbill.billing.ErrorCode; import org.killbill.billing.account.api.ImmutableAccountData; 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.BillingPeriod; import org.killbill.billing.catalog.api.CatalogApiException; import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.catalog.api.PhaseType; 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.invoice.api.InvoiceItemType; import org.killbill.billing.invoice.generator.InvoiceWithMetadata.SubscriptionFutureNotificationDates; import org.killbill.billing.invoice.model.FixedPriceInvoiceItem; import org.killbill.billing.invoice.model.InvalidDateSequenceException; import org.killbill.billing.invoice.model.RecurringInvoiceItem; import org.killbill.billing.invoice.model.RecurringInvoiceItemData; import org.killbill.billing.invoice.model.RecurringInvoiceItemDataWithNextBillingCycleDate; import org.killbill.billing.invoice.tree.AccountItemTree; import org.killbill.billing.junction.BillingEvent; import org.killbill.billing.junction.BillingEventSet; import org.killbill.billing.util.config.definition.InvoiceConfig; import org.killbill.billing.util.currency.KillBillMoney; import org.killbill.clock.Clock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Multimap; import com.google.common.collect.Range; import com.google.inject.Inject; import static org.killbill.billing.invoice.generator.InvoiceDateUtils.calculateNumberOfWholeBillingPeriods; import static org.killbill.billing.invoice.generator.InvoiceDateUtils.calculateProRationAfterLastBillingCycleDate; import static org.killbill.billing.invoice.generator.InvoiceDateUtils.calculateProRationBeforeFirstBillingPeriod; public class FixedAndRecurringInvoiceItemGenerator extends InvoiceItemGenerator { private static final Logger log = LoggerFactory.getLogger(FixedAndRecurringInvoiceItemGenerator.class); private final InvoiceConfig config; private final Clock clock; @Inject public FixedAndRecurringInvoiceItemGenerator(final InvoiceConfig config, final Clock clock) { this.config = config; this.clock = clock; } public List<InvoiceItem> generateItems(final ImmutableAccountData account, final UUID invoiceId, final BillingEventSet eventSet, @Nullable final List<Invoice> existingInvoices, final LocalDate targetDate, final Currency targetCurrency, final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDate, final InternalCallContext internalCallContext) throws InvoiceApiException { final Multimap<UUID, LocalDate> createdItemsPerDayPerSubscription = LinkedListMultimap.<UUID, LocalDate>create(); final AccountItemTree accountItemTree = new AccountItemTree(account.getId(), invoiceId); if (existingInvoices != null) { for (final Invoice invoice : existingInvoices) { for (final InvoiceItem item : invoice.getInvoiceItems()) { if (item.getSubscriptionId() == null || // Always include migration invoices, credits, external charges etc. !eventSet.getSubscriptionIdsWithAutoInvoiceOff() .contains(item.getSubscriptionId())) { //don't add items with auto_invoice_off tag accountItemTree.addExistingItem(item); trackInvoiceItemCreatedDay(item, createdItemsPerDayPerSubscription, internalCallContext); } } } } // Generate list of proposed invoice items based on billing events from junction-- proposed items are ALL items since beginning of time final List<InvoiceItem> proposedItems = new ArrayList<InvoiceItem>(); processRecurringBillingEvents(invoiceId, account.getId(), eventSet, targetDate, targetCurrency, proposedItems, perSubscriptionFutureNotificationDate, existingInvoices, internalCallContext); processFixedBillingEvents(invoiceId, account.getId(), eventSet, targetDate, targetCurrency, proposedItems, internalCallContext); try { accountItemTree.mergeWithProposedItems(proposedItems); } catch (final IllegalStateException e) { // Proposed items have already been logged throw new InvoiceApiException(e, ErrorCode.UNEXPECTED_ERROR, String.format("ILLEGAL INVOICING STATE accountItemTree=%s", accountItemTree.toString())); } final List<InvoiceItem> resultingItems = accountItemTree.getResultingItemList(); safetyBounds(resultingItems, createdItemsPerDayPerSubscription, internalCallContext); return resultingItems; } private void processRecurringBillingEvents(final UUID invoiceId, final UUID accountId, final BillingEventSet events, final LocalDate targetDate, final Currency currency, final List<InvoiceItem> proposedItems, final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDate, @Nullable final List<Invoice> existingInvoices, final InternalCallContext internalCallContext) throws InvoiceApiException { if (events.isEmpty()) { return; } // Pretty-print the generated invoice items from the junction events final InvoiceItemGeneratorLogger invoiceItemGeneratorLogger = new InvoiceItemGeneratorLogger(invoiceId, accountId, "recurring", log); final Iterator<BillingEvent> eventIt = events.iterator(); BillingEvent nextEvent = eventIt.next(); while (eventIt.hasNext()) { final BillingEvent thisEvent = nextEvent; nextEvent = eventIt.next(); if (!events.getSubscriptionIdsWithAutoInvoiceOff(). contains(thisEvent.getSubscription().getId())) { // don't consider events for subscriptions that have auto_invoice_off final BillingEvent adjustedNextEvent = (thisEvent.getSubscription().getId() == nextEvent.getSubscription().getId()) ? nextEvent : null; final List<InvoiceItem> newProposedItems = processRecurringEvent(invoiceId, accountId, thisEvent, adjustedNextEvent, targetDate, currency, invoiceItemGeneratorLogger, events.getRecurringBillingMode(), perSubscriptionFutureNotificationDate, internalCallContext); proposedItems.addAll(newProposedItems); } } final List<InvoiceItem> newProposedItems = processRecurringEvent(invoiceId, accountId, nextEvent, null, targetDate, currency, invoiceItemGeneratorLogger, events.getRecurringBillingMode(), perSubscriptionFutureNotificationDate, internalCallContext); proposedItems.addAll(newProposedItems); invoiceItemGeneratorLogger.logItems(); } @VisibleForTesting void processFixedBillingEvents(final UUID invoiceId, final UUID accountId, final BillingEventSet events, final LocalDate targetDate, final Currency currency, final List<InvoiceItem> proposedItems, final InternalCallContext internalCallContext) throws InvoiceApiException { if (events.isEmpty()) { return; } InvoiceItem prevItem = null; // Pretty-print the generated invoice items from the junction events final InvoiceItemGeneratorLogger invoiceItemGeneratorLogger = new InvoiceItemGeneratorLogger(invoiceId, accountId, "fixed", log); final Iterator<BillingEvent> eventIt = events.iterator(); while (eventIt.hasNext()) { final BillingEvent thisEvent = eventIt.next(); final InvoiceItem currentFixedPriceItem = generateFixedPriceItem(invoiceId, accountId, thisEvent, targetDate, currency, invoiceItemGeneratorLogger, internalCallContext); if (!isSameDayAndSameSubscription(prevItem, thisEvent, internalCallContext) && prevItem != null) { proposedItems.add(prevItem); } prevItem = currentFixedPriceItem; } // The last one if not null can always be inserted as there is nothing after to cancel it off. if (prevItem != null) { proposedItems.add(prevItem); } invoiceItemGeneratorLogger.logItems(); } @VisibleForTesting boolean isSameDayAndSameSubscription(final InvoiceItem prevComputedFixedItem, final BillingEvent currentBillingEvent, final InternalCallContext internalCallContext) { final LocalDate curLocalEffectiveDate = internalCallContext.toLocalDate(currentBillingEvent.getEffectiveDate()); if (prevComputedFixedItem != null && /* If we have computed a previous item */ prevComputedFixedItem.getStartDate().compareTo(curLocalEffectiveDate) == 0 && /* The current billing event happens at the same date */ prevComputedFixedItem.getSubscriptionId().compareTo(currentBillingEvent.getSubscription().getId()) == 0 /* The current billing event happens for the same subscription */) { return true; } else { return false; } } // Turn a set of events into a list of invoice items. Note that the dates on the invoice items will be rounded (granularity of a day) private List<InvoiceItem> processRecurringEvent(final UUID invoiceId, final UUID accountId, final BillingEvent thisEvent, @Nullable final BillingEvent nextEvent, final LocalDate targetDate, final Currency currency, final InvoiceItemGeneratorLogger invoiceItemGeneratorLogger, final BillingMode billingMode, final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDate, final InternalCallContext internalCallContext) throws InvoiceApiException { try { final List<InvoiceItem> items = new ArrayList<InvoiceItem>(); // For FIXEDTERM phases we need to stop when the specified duration has been reached final LocalDate maxEndDate = thisEvent.getPlanPhase().getPhaseType() == PhaseType.FIXEDTERM ? thisEvent.getPlanPhase().getDuration().addToLocalDate(internalCallContext.toLocalDate(thisEvent.getEffectiveDate())) : null; // Handle recurring items final BillingPeriod billingPeriod = thisEvent.getBillingPeriod(); if (billingPeriod != BillingPeriod.NO_BILLING_PERIOD) { final LocalDate startDate = internalCallContext.toLocalDate(thisEvent.getEffectiveDate()); if (!startDate.isAfter(targetDate)) { final LocalDate endDate = (nextEvent == null) ? null : internalCallContext.toLocalDate(nextEvent.getEffectiveDate()); final int billCycleDayLocal = thisEvent.getBillCycleDayLocal(); final RecurringInvoiceItemDataWithNextBillingCycleDate itemDataWithNextBillingCycleDate; try { itemDataWithNextBillingCycleDate = generateInvoiceItemData(startDate, endDate, targetDate, billCycleDayLocal, billingPeriod, billingMode); } catch (final InvalidDateSequenceException e) { throw new InvoiceApiException(ErrorCode.INVOICE_INVALID_DATE_SEQUENCE, startDate, endDate, targetDate); } for (final RecurringInvoiceItemData itemDatum : itemDataWithNextBillingCycleDate.getItemData()) { // Stop if there a maxEndDate and we have reached it if (maxEndDate != null && maxEndDate.compareTo(itemDatum.getEndDate()) < 0) { break; } final BigDecimal rate = thisEvent.getRecurringPrice(internalCallContext.toUTCDateTime(itemDatum.getStartDate())); if (rate != null) { final BigDecimal amount = KillBillMoney.of(itemDatum.getNumberOfCycles().multiply(rate), currency); final RecurringInvoiceItem recurringItem = new RecurringInvoiceItem(invoiceId, accountId, thisEvent.getSubscription().getBundleId(), thisEvent.getSubscription().getId(), thisEvent.getPlan().getName(), thisEvent.getPlanPhase().getName(), itemDatum.getStartDate(), itemDatum.getEndDate(), amount, rate, currency); items.add(recurringItem); } } updatePerSubscriptionNextNotificationDate(thisEvent.getSubscription().getId(), itemDataWithNextBillingCycleDate.getNextBillingCycleDate(), items, billingMode, perSubscriptionFutureNotificationDate); } } // For debugging purposes invoiceItemGeneratorLogger.append(thisEvent, items); return items; } catch (final CatalogApiException e) { throw new InvoiceApiException(e); } } private void updatePerSubscriptionNextNotificationDate(final UUID subscriptionId, final LocalDate nextBillingCycleDate, final List<InvoiceItem> newProposedItems, final BillingMode billingMode, final Map<UUID, SubscriptionFutureNotificationDates> perSubscriptionFutureNotificationDates) { LocalDate nextNotificationDate = null; switch (billingMode) { case IN_ADVANCE: for (final InvoiceItem item : newProposedItems) { if ((item.getEndDate() != null) && (item.getAmount() == null || item.getAmount().compareTo(BigDecimal.ZERO) >= 0)) { if (nextNotificationDate == null) { nextNotificationDate = item.getEndDate(); } else { nextNotificationDate = nextNotificationDate.compareTo(item.getEndDate()) > 0 ? nextNotificationDate : item.getEndDate(); } } } break; case IN_ARREAR: nextNotificationDate = nextBillingCycleDate; break; default: throw new IllegalStateException("Unrecognized billing mode " + billingMode); } if (nextNotificationDate != null) { SubscriptionFutureNotificationDates subscriptionFutureNotificationDates = perSubscriptionFutureNotificationDates.get(subscriptionId); if (subscriptionFutureNotificationDates == null) { subscriptionFutureNotificationDates = new SubscriptionFutureNotificationDates(billingMode); perSubscriptionFutureNotificationDates.put(subscriptionId, subscriptionFutureNotificationDates); } subscriptionFutureNotificationDates.updateNextRecurringDateIfRequired(nextNotificationDate); } } public RecurringInvoiceItemDataWithNextBillingCycleDate generateInvoiceItemData(final LocalDate startDate, @Nullable final LocalDate endDate, final LocalDate targetDate, final int billingCycleDayLocal, final BillingPeriod billingPeriod, final BillingMode billingMode) throws InvalidDateSequenceException { if (endDate != null && endDate.isBefore(startDate)) { throw new InvalidDateSequenceException(); } if (targetDate.isBefore(startDate)) { throw new InvalidDateSequenceException(); } final List<RecurringInvoiceItemData> results = new ArrayList<RecurringInvoiceItemData>(); final BillingIntervalDetail billingIntervalDetail = new BillingIntervalDetail(startDate, endDate, targetDate, billingCycleDayLocal, billingPeriod, billingMode); // We are not billing for less than a day if (!billingIntervalDetail.hasSomethingToBill()) { return new RecurringInvoiceItemDataWithNextBillingCycleDate(results, billingIntervalDetail); } // // If there is an endDate and that endDate is before our first coming firstBillingCycleDate, all we have to do // is to charge for that period // if (endDate != null && !endDate.isAfter(billingIntervalDetail.getFirstBillingCycleDate())) { final BigDecimal leadingProRationPeriods = calculateProRationBeforeFirstBillingPeriod(startDate, endDate, billingPeriod); final RecurringInvoiceItemData itemData = new RecurringInvoiceItemData(startDate, endDate, leadingProRationPeriods); results.add(itemData); return new RecurringInvoiceItemDataWithNextBillingCycleDate(results, billingIntervalDetail); } // // Leading proration if // i) The first firstBillingCycleDate is strictly after our start date AND // ii) The endDate is is not null and is strictly after our firstBillingCycleDate (previous check) // if (billingIntervalDetail.getFirstBillingCycleDate().isAfter(startDate)) { final BigDecimal leadingProRationPeriods = calculateProRationBeforeFirstBillingPeriod(startDate, billingIntervalDetail.getFirstBillingCycleDate(), billingPeriod); if (leadingProRationPeriods != null && leadingProRationPeriods.compareTo(BigDecimal.ZERO) > 0) { // Not common - add info in the logs for debugging purposes final RecurringInvoiceItemData itemData = new RecurringInvoiceItemData(startDate, billingIntervalDetail.getFirstBillingCycleDate(), leadingProRationPeriods); log.info("Adding pro-ration: {}", itemData); results.add(itemData); } } // // Calculate the effectiveEndDate from the firstBillingCycleDate: // - If endDate != null and targetDate is after endDate => this is the endDate and will lead to a trailing pro-ration // - If not, this is the last billingCycleDate calculation right after the targetDate // final LocalDate effectiveEndDate = billingIntervalDetail.getEffectiveEndDate(); // // Based on what we calculated previously, code recompute one more time the numberOfWholeBillingPeriods // final LocalDate lastBillingCycleDate = billingIntervalDetail.getLastBillingCycleDate(); final int numberOfWholeBillingPeriods = calculateNumberOfWholeBillingPeriods(billingIntervalDetail.getFirstBillingCycleDate(), lastBillingCycleDate, billingPeriod); for (int i = 0; i < numberOfWholeBillingPeriods; i++) { final LocalDate servicePeriodStartDate; if (!results.isEmpty()) { // Make sure the periods align, especially with the pro-ration calculations above servicePeriodStartDate = results.get(results.size() - 1).getEndDate(); } else if (i == 0) { // Use the specified start date servicePeriodStartDate = startDate; } else { throw new IllegalStateException("We should at least have one invoice item!"); } // Make sure to align the end date with the BCD final LocalDate servicePeriodEndDate = billingIntervalDetail.getFutureBillingDateFor(i + 1); results.add(new RecurringInvoiceItemData(servicePeriodStartDate, servicePeriodEndDate, BigDecimal.ONE)); } // // Now we check if indeed we need a trailing proration and add that incomplete item // if (effectiveEndDate.isAfter(lastBillingCycleDate)) { final BigDecimal trailingProRationPeriods = calculateProRationAfterLastBillingCycleDate(effectiveEndDate, lastBillingCycleDate, billingPeriod); if (trailingProRationPeriods.compareTo(BigDecimal.ZERO) > 0) { // Not common - add info in the logs for debugging purposes final RecurringInvoiceItemData itemData = new RecurringInvoiceItemData(lastBillingCycleDate, effectiveEndDate, trailingProRationPeriods); log.info("Adding trailing pro-ration: {}", itemData); results.add(itemData); } } return new RecurringInvoiceItemDataWithNextBillingCycleDate(results, billingIntervalDetail); } private InvoiceItem generateFixedPriceItem(final UUID invoiceId, final UUID accountId, final BillingEvent thisEvent, final LocalDate targetDate, final Currency currency, final InvoiceItemGeneratorLogger invoiceItemGeneratorLogger, final InternalCallContext internalCallContext) throws InvoiceApiException { final LocalDate roundedStartDate = internalCallContext.toLocalDate(thisEvent.getEffectiveDate()); if (roundedStartDate.isAfter(targetDate)) { return null; } else { final BigDecimal fixedPrice = thisEvent.getFixedPrice(); if (fixedPrice != null) { final FixedPriceInvoiceItem fixedPriceInvoiceItem = new FixedPriceInvoiceItem(invoiceId, accountId, thisEvent.getSubscription().getBundleId(), thisEvent.getSubscription().getId(), thisEvent.getPlan().getName(), thisEvent.getPlanPhase().getName(), roundedStartDate, fixedPrice, currency); // For debugging purposes invoiceItemGeneratorLogger.append(thisEvent, fixedPriceInvoiceItem); return fixedPriceInvoiceItem; } else { return null; } } } @VisibleForTesting void safetyBounds(final Iterable<InvoiceItem> resultingItems, final Multimap<UUID, LocalDate> createdItemsPerDayPerSubscription, final InternalTenantContext internalCallContext) throws InvoiceApiException { // Trigger an exception if we detect the creation of similar items for a given subscription // See https://github.com/killbill/killbill/issues/664 if (config.isSanitySafetyBoundEnabled(internalCallContext)) { final Map<UUID, Multimap<LocalDate, InvoiceItem>> fixedItemsPerDateAndSubscription = new HashMap<UUID, Multimap<LocalDate, InvoiceItem>>(); final Map<UUID, Multimap<Range<LocalDate>, InvoiceItem>> recurringItemsPerServicePeriodAndSubscription = new HashMap<UUID, Multimap<Range<LocalDate>, InvoiceItem>>(); for (final InvoiceItem resultingItem : resultingItems) { if (resultingItem.getInvoiceItemType() == InvoiceItemType.FIXED) { if (fixedItemsPerDateAndSubscription.get(resultingItem.getSubscriptionId()) == null) { fixedItemsPerDateAndSubscription.put(resultingItem.getSubscriptionId(), LinkedListMultimap.<LocalDate, InvoiceItem>create()); } fixedItemsPerDateAndSubscription.get(resultingItem.getSubscriptionId()).put(resultingItem.getStartDate(), resultingItem); final Collection<InvoiceItem> resultingInvoiceItems = fixedItemsPerDateAndSubscription.get(resultingItem.getSubscriptionId()).get(resultingItem.getStartDate()); if (resultingInvoiceItems.size() > 1) { throw new InvoiceApiException(ErrorCode.UNEXPECTED_ERROR, String.format("SAFETY BOUND TRIGGERED Multiple FIXED items for subscriptionId='%s', startDate='%s', resultingItems=%s", resultingItem.getSubscriptionId(), resultingItem.getStartDate(), resultingInvoiceItems)); } } else if (resultingItem.getInvoiceItemType() == InvoiceItemType.RECURRING) { if (recurringItemsPerServicePeriodAndSubscription.get(resultingItem.getSubscriptionId()) == null) { recurringItemsPerServicePeriodAndSubscription.put(resultingItem.getSubscriptionId(), LinkedListMultimap.<Range<LocalDate>, InvoiceItem>create()); } final Range<LocalDate> interval = Range.<LocalDate>closedOpen(resultingItem.getStartDate(), resultingItem.getEndDate()); recurringItemsPerServicePeriodAndSubscription.get(resultingItem.getSubscriptionId()).put(interval, resultingItem); final Collection<InvoiceItem> resultingInvoiceItems = recurringItemsPerServicePeriodAndSubscription.get(resultingItem.getSubscriptionId()).get(interval); if (resultingInvoiceItems.size() > 1) { throw new InvoiceApiException(ErrorCode.UNEXPECTED_ERROR, String.format("SAFETY BOUND TRIGGERED Multiple RECURRING items for subscriptionId='%s', startDate='%s', endDate='%s', resultingItems=%s", resultingItem.getSubscriptionId(), resultingItem.getStartDate(), resultingItem.getEndDate(), resultingInvoiceItems)); } } } } // Trigger an exception if we create too many invoice items for a subscription on a given day if (config.getMaxDailyNumberOfItemsSafetyBound(internalCallContext) == -1) { // Safety bound disabled return; } for (final InvoiceItem invoiceItem : resultingItems) { if (invoiceItem.getSubscriptionId() != null) { final LocalDate resultingItemCreationDay = trackInvoiceItemCreatedDay(invoiceItem, createdItemsPerDayPerSubscription, internalCallContext); final Collection<LocalDate> creationDaysForSubscription = createdItemsPerDayPerSubscription.get(invoiceItem.getSubscriptionId()); int i = 0; for (final LocalDate creationDayForSubscription : creationDaysForSubscription) { if (creationDayForSubscription.compareTo(resultingItemCreationDay) == 0) { i++; if (i > config.getMaxDailyNumberOfItemsSafetyBound(internalCallContext)) { // Proposed items have already been logged throw new InvoiceApiException(ErrorCode.UNEXPECTED_ERROR, String.format("SAFETY BOUND TRIGGERED subscriptionId='%s', resultingItem=%s", invoiceItem.getSubscriptionId(), invoiceItem)); } } } } } } private LocalDate trackInvoiceItemCreatedDay(final InvoiceItem invoiceItem, final Multimap<UUID, LocalDate> createdItemsPerDayPerSubscription, final InternalTenantContext internalCallContext) { final UUID subscriptionId = invoiceItem.getSubscriptionId(); if (subscriptionId == null) { return null; } final LocalDate createdDay = internalCallContext.toLocalDate(MoreObjects.firstNonNull(invoiceItem.getCreatedDate(), clock.getUTCNow())); createdItemsPerDayPerSubscription.put(subscriptionId, createdDay); return createdDay; } }