
org.killbill.billing.invoice.usage.ContiguousIntervalConsumableInArrear Maven / Gradle / Ivy
/*
* Copyright 2014 The Billing Project, LLC
*
* 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.usage;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import org.joda.time.DateTimeZone;
import org.joda.time.LocalDate;
import org.killbill.billing.catalog.api.BillingMode;
import org.killbill.billing.catalog.api.CatalogApiException;
import org.killbill.billing.catalog.api.Currency;
import org.killbill.billing.catalog.api.TieredBlock;
import org.killbill.billing.catalog.api.Usage;
import org.killbill.billing.invoice.api.InvoiceItem;
import org.killbill.billing.invoice.api.InvoiceItemType;
import org.killbill.billing.invoice.generator.BillingIntervalDetail;
import org.killbill.billing.invoice.model.UsageInvoiceItem;
import org.killbill.billing.junction.BillingEvent;
import org.killbill.billing.usage.RawUsage;
import org.killbill.billing.usage.api.RolledUpUnit;
import org.killbill.billing.usage.api.RolledUpUsage;
import org.killbill.billing.util.AccountDateAndTimeZoneContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import static org.killbill.billing.invoice.usage.UsageUtils.getConsumableInArrearTieredBlocks;
import static org.killbill.billing.invoice.usage.UsageUtils.getConsumableInArrearUnitTypes;
/**
* There is one such class per subscriptionId, matching a given in arrear/consumable usage section and
* referenced through a contiguous list of billing events.
*/
public class ContiguousIntervalConsumableInArrear {
private static final Logger log = LoggerFactory.getLogger(ContiguousIntervalConsumableInArrear.class);
private final List transitionTimes;
private final List billingEvents;
private final Usage usage;
private final Set unitTypes;
private final List rawSubscriptionUsage;
private final LocalDate targetDate;
private final UUID accountId;
private final UUID invoiceId;
private final AtomicBoolean isBuilt;
private final LocalDate rawUsageStartDate;
private final AccountDateAndTimeZoneContext dateAndTimeZoneContext;
public ContiguousIntervalConsumableInArrear(final Usage usage,
final UUID accountId,
final UUID invoiceId,
final List rawSubscriptionUsage,
final LocalDate targetDate,
final LocalDate rawUsageStartDate,
final AccountDateAndTimeZoneContext dateAndTimeZoneContext) {
this.usage = usage;
this.accountId = accountId;
this.invoiceId = invoiceId;
this.unitTypes = getConsumableInArrearUnitTypes(usage);
this.rawSubscriptionUsage = rawSubscriptionUsage;
this.targetDate = targetDate;
this.rawUsageStartDate = rawUsageStartDate;
this.dateAndTimeZoneContext = dateAndTimeZoneContext;
this.billingEvents = Lists.newLinkedList();
this.transitionTimes = Lists.newLinkedList();
this.isBuilt = new AtomicBoolean(false);
}
/**
* Builds the transitionTimes associated to that usage section. Those are determined based on billing events for when to start and when to stop,
* the per usage billingPeriod and finally the targetDate.
*
* Those transition dates define the well defined billing granularity periods that should be billed for that specific usage section.
*
* @param closedInterval whether there was a last billing event referencing the usage section or whether this is ongoing and
* then targetDate will define the endDate.
* @return
*/
public ContiguousIntervalConsumableInArrear build(final boolean closedInterval) {
Preconditions.checkState(!isBuilt.get());
Preconditions.checkState((!closedInterval && billingEvents.size() >= 1) ||
(closedInterval && billingEvents.size() >= 2));
final LocalDate startDate = dateAndTimeZoneContext.computeLocalDateFromFixedAccountOffset(billingEvents.get(0).getEffectiveDate());
if (targetDate.isBefore(startDate)) {
return this;
}
final LocalDate endDate = closedInterval ? dateAndTimeZoneContext.computeLocalDateFromFixedAccountOffset(billingEvents.get(billingEvents.size() - 1).getEffectiveDate()) : targetDate;
final BillingIntervalDetail bid = new BillingIntervalDetail(startDate, endDate, targetDate, getBCD(), usage.getBillingPeriod(), usage.getBillingMode());
int numberOfPeriod = 0;
// First billingCycleDate prior startDate
LocalDate nextBillCycleDate = bid.getFutureBillingDateFor(numberOfPeriod);
if (startDate.compareTo(rawUsageStartDate) >= 0) {
transitionTimes.add(startDate);
}
while (!nextBillCycleDate.isAfter(endDate)) {
if (nextBillCycleDate.isAfter(startDate)) {
if (nextBillCycleDate.compareTo(rawUsageStartDate) >= 0) {
transitionTimes.add(nextBillCycleDate);
}
}
numberOfPeriod++;
nextBillCycleDate = bid.getFutureBillingDateFor(numberOfPeriod);
}
if (closedInterval && endDate.isAfter(transitionTimes.get(transitionTimes.size() - 1))) {
transitionTimes.add(endDate);
}
isBuilt.set(true);
return this;
}
public class ConsumableInArrearItemsAndNextNotificationDate {
private final List invoiceItems;
private final LocalDate nextNotificationDate;
public ConsumableInArrearItemsAndNextNotificationDate(final List invoiceItems, final LocalDate nextNotificationDate) {
this.invoiceItems = invoiceItems;
this.nextNotificationDate = nextNotificationDate;
}
public List getInvoiceItems() {
return invoiceItems;
}
public LocalDate getNextNotificationDate() {
return nextNotificationDate;
}
}
/**
* Compute the missing usage invoice items based on what should be billed and what has been billed ($ amount comparison).
*
* @param existingUsage existing on disk usage items for the subscription
* @return
* @throws CatalogApiException
*/
public ConsumableInArrearItemsAndNextNotificationDate computeMissingItemsAndNextNotificationDate(final List existingUsage) throws CatalogApiException {
Preconditions.checkState(isBuilt.get());
if (transitionTimes.size() < 2) {
return new ConsumableInArrearItemsAndNextNotificationDate(ImmutableList.of(), computeNextNotificationDate());
}
final List result = Lists.newLinkedList();
final List allUsage = getRolledUpUsage();
// We start by generating 'marker' USAGE items with $0 that will allow to correctly insert the next notification for when there is no USAGE to bill.
// Those will be removed by the invoicing code later so as to not end up with superfluous $0 items
LocalDate prevDate = null;
for (LocalDate curDate : transitionTimes) {
if (prevDate != null) {
InvoiceItem item = new UsageInvoiceItem(invoiceId, accountId, getBundleId(), getSubscriptionId(), getPlanName(),
getPhaseName(), usage.getName(), prevDate, curDate, BigDecimal.ZERO, getCurrency());
result.add(item);
}
prevDate = curDate;
}
for (final RolledUpUsage ru : allUsage) {
// Compute total price amount that should be billed for that period of time (and usage section) across unitTypes.
BigDecimal toBeBilledUsage = BigDecimal.ZERO;
for (final RolledUpUnit cur : ru.getRolledUpUnits()) {
if (!unitTypes.contains(cur.getUnitType())) {
log.warn("ContiguousIntervalConsumableInArrear is skipping unitType " + cur.getUnitType());
continue;
}
final BigDecimal toBeBilledForUnit = computeToBeBilledUsage(cur.getAmount(), cur.getUnitType());
toBeBilledUsage = toBeBilledUsage.add(toBeBilledForUnit);
}
// Retrieves current price amount billed for that period of time (and usage section)
final Iterable billedItems = getBilledItems(ru.getStart(), ru.getEnd(), existingUsage);
final BigDecimal billedUsage = computeBilledUsage(billedItems);
// Compare the two and add the missing piece if required.
if (!billedItems.iterator().hasNext() || billedUsage.compareTo(toBeBilledUsage) < 0) {
final BigDecimal amountToBill = toBeBilledUsage.subtract(billedUsage);
if (amountToBill.compareTo(BigDecimal.ZERO) > 0) {
InvoiceItem item = new UsageInvoiceItem(invoiceId, accountId, getBundleId(), getSubscriptionId(), getPlanName(),
getPhaseName(), usage.getName(), ru.getStart(), ru.getEnd(), amountToBill, getCurrency());
result.add(item);
}
}
}
final LocalDate nextNotificationdate = computeNextNotificationDate();
return new ConsumableInArrearItemsAndNextNotificationDate(result, nextNotificationdate);
}
private LocalDate computeNextNotificationDate() {
LocalDate result = null;
final Iterator eventIt = billingEvents.iterator();
BillingEvent nextEvent = eventIt.next();
while (eventIt.hasNext()) {
final BillingEvent thisEvent = nextEvent;
nextEvent = eventIt.next();
final LocalDate startDate = dateAndTimeZoneContext.computeLocalDateFromFixedAccountOffset(thisEvent.getEffectiveDate());
final LocalDate endDate = dateAndTimeZoneContext.computeLocalDateFromFixedAccountOffset(nextEvent.getEffectiveDate());
final BillingIntervalDetail bid = new BillingIntervalDetail(startDate, endDate, targetDate, thisEvent.getBillCycleDayLocal(), usage.getBillingPeriod(), BillingMode.IN_ARREAR);
final LocalDate nextBillingCycleDate = bid.getNextBillingCycleDate();
result = (result == null || result.compareTo(nextBillingCycleDate) < 0) ? nextBillingCycleDate : result;
}
final LocalDate startDate = dateAndTimeZoneContext.computeLocalDateFromFixedAccountOffset(nextEvent.getEffectiveDate());
final BillingIntervalDetail bid = new BillingIntervalDetail(startDate, null, targetDate, nextEvent.getBillCycleDayLocal(), usage.getBillingPeriod(), BillingMode.IN_ARREAR);
final LocalDate nextBillingCycleDate = bid.getNextBillingCycleDate();
result = (result == null || result.compareTo(nextBillingCycleDate) < 0) ? nextBillingCycleDate : result;
return result;
}
@VisibleForTesting
List getRolledUpUsage() {
final Iterator rawUsageIterator = rawSubscriptionUsage.iterator();
if (!rawUsageIterator.hasNext()) {
return ImmutableList.of();
}
final List result = new ArrayList();
//
// Skip all items before our first transition date
//
// prevRawUsage keeps track of first unconsumed raw usage element
RawUsage prevRawUsage = null;
while (rawUsageIterator.hasNext()) {
final RawUsage curRawUsage = rawUsageIterator.next();
if (curRawUsage.getDate().compareTo(transitionTimes.get(0)) >= 0) {
prevRawUsage = curRawUsage;
break;
}
}
// Optimize path where all raw usage items are outside or our transitionTimes range
if (prevRawUsage.getDate().compareTo(transitionTimes.get(transitionTimes.size() - 1)) >= 0) {
return ImmutableList.of();
}
//
// Loop through each interval [prevDate, curDate) and consume as many rawSubscriptionUsage elements within that range
// to create one RolledUpUsage per interval. If an interval does not have any rawSubscriptionUsage element, there will be no
// matching RolledUpUsage for that interval, and we'll detect that in the 'computeMissingItems' logic
//
LocalDate prevDate = null;
for (LocalDate curDate : transitionTimes) {
if (prevDate != null) {
// Allocate new perRangeUnitToAmount for this interval and populate with rawSubscriptionUsage items
final Map perRangeUnitToAmount = new HashMap();
// Start consuming prevRawUsage element if it exists and falls into the range
if (prevRawUsage != null) {
if (prevRawUsage.getDate().compareTo(prevDate) >= 0 && prevRawUsage.getDate().compareTo(curDate) < 0) {
Long currentAmount = perRangeUnitToAmount.get(prevRawUsage.getUnitType());
Long updatedAmount = (currentAmount != null) ? currentAmount + prevRawUsage.getAmount() : prevRawUsage.getAmount();
perRangeUnitToAmount.put(prevRawUsage.getUnitType(), updatedAmount);
prevRawUsage = null;
}
}
//
// If prevRawUsage != null it means that our first and current rawSubscriptionUsage does not fall into that interval; we can't
// just 'continue' as we need to correctly set next 'prevDate'
// If prevRawUsage == null, then consume as much as we can for that interval. Note that the stop condition requires consuming
// one additional element which will become the prevRawUsage for the next interval.
//
if (prevRawUsage == null) {
while (rawUsageIterator.hasNext()) {
final RawUsage curRawUsage = rawUsageIterator.next();
if (curRawUsage.getDate().compareTo(curDate) >= 0) {
prevRawUsage = curRawUsage;
break;
}
Long currentAmount = perRangeUnitToAmount.get(curRawUsage.getUnitType());
Long updatedAmount = (currentAmount != null) ? currentAmount + curRawUsage.getAmount() : curRawUsage.getAmount();
perRangeUnitToAmount.put(curRawUsage.getUnitType(), updatedAmount);
}
}
// If we did find some usage for that date range, let's populate the result
if (!perRangeUnitToAmount.isEmpty()) {
final List rolledUpUnits = new ArrayList(perRangeUnitToAmount.size());
for (final String unitType : perRangeUnitToAmount.keySet()) {
rolledUpUnits.add(new DefaultRolledUpUnit(unitType, perRangeUnitToAmount.get(unitType)));
}
result.add(new DefaultRolledUpUsage(getSubscriptionId(), prevDate, curDate, rolledUpUnits));
}
}
prevDate = curDate;
}
return result;
}
/**
* @param nbUnits the number of used units for a given period
* @param unitType the type of unit
* @return the price amount that should be billed for that period/unitType
* @throws CatalogApiException
*/
@VisibleForTesting
BigDecimal computeToBeBilledUsage(final Long nbUnits, final String unitType) throws CatalogApiException {
Preconditions.checkState(isBuilt.get());
BigDecimal result = BigDecimal.ZERO;
final List tieredBlocks = getConsumableInArrearTieredBlocks(usage, unitType);
int remainingUnits = nbUnits.intValue();
for (TieredBlock tieredBlock : tieredBlocks) {
final int blockTierSize = tieredBlock.getSize().intValue();
final int tmp = remainingUnits / blockTierSize + (remainingUnits % blockTierSize == 0 ? 0 : 1);
final int nbUsedTierBlocks;
if (tmp > tieredBlock.getMax()) {
nbUsedTierBlocks = tieredBlock.getMax().intValue();
remainingUnits -= tieredBlock.getMax() * blockTierSize;
} else {
nbUsedTierBlocks = tmp;
remainingUnits = 0;
}
result = result.add(tieredBlock.getPrice().getPrice(getCurrency()).multiply(new BigDecimal(nbUsedTierBlocks)));
}
return result;
}
/**
* @param filteredUsageForInterval the list of invoiceItem to consider
* @return the price amount that was already billed for that period and usage section (across unitTypes)
*/
@VisibleForTesting
BigDecimal computeBilledUsage(final Iterable filteredUsageForInterval) {
Preconditions.checkState(isBuilt.get());
BigDecimal billedAmount = BigDecimal.ZERO;
for (InvoiceItem ii : filteredUsageForInterval) {
billedAmount = billedAmount.add(ii.getAmount());
}
// Return the billed $ amount (not the # of units)
return billedAmount;
}
Iterable getBilledItems(final LocalDate startDate, final LocalDate endDate, final List existingUsage) {
Preconditions.checkState(isBuilt.get());
return Iterables.filter(existingUsage, new Predicate() {
@Override
public boolean apply(final InvoiceItem input) {
if (input.getInvoiceItemType() != InvoiceItemType.USAGE) {
return false;
}
// STEPH what happens if we discover usage period that overlap (one side or both side) the [startDate, endDate] interval
final UsageInvoiceItem usageInput = (UsageInvoiceItem) input;
return usageInput.getUsageName().equals(usage.getName()) &&
usageInput.getStartDate().compareTo(startDate) >= 0 &&
usageInput.getEndDate().compareTo(endDate) <= 0;
}
});
}
@VisibleForTesting
List getTransitionTimes() {
return transitionTimes;
}
public void addBillingEvent(final BillingEvent event) {
Preconditions.checkState(!isBuilt.get());
billingEvents.add(event);
}
public Usage getUsage() {
return usage;
}
public int getBCD() {
return billingEvents.get(0).getBillCycleDayLocal();
}
public UUID getBundleId() {
return billingEvents.get(0).getSubscription().getBundleId();
}
public UUID getSubscriptionId() {
return billingEvents.get(0).getSubscription().getId();
}
// STEPH_USAGE planName/phaseName,BCD,... might not be correct if we changed plan but Usage section was exactly similar
public String getPlanName() {
return billingEvents.get(0).getPlan().getName();
}
public String getPhaseName() {
return billingEvents.get(0).getPlanPhase().getName();
}
public Currency getCurrency() {
return billingEvents.get(0).getCurrency();
}
public DateTimeZone getAccountTimeZone() {
return billingEvents.get(0).getTimeZone();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy