/*
This file is part of Cyclos (www.cyclos.org).
A project of the Social Trade Organisation (www.socialtrade.org).
Cyclos is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
Cyclos is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Cyclos; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package nl.strohalm.cyclos.services.accountfees;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import nl.strohalm.cyclos.dao.accounts.AccountDAO;
import nl.strohalm.cyclos.dao.accounts.AccountDailyDifference;
import nl.strohalm.cyclos.dao.accounts.fee.account.AccountFeeDAO;
import nl.strohalm.cyclos.dao.accounts.fee.account.AccountFeeLogDAO;
import nl.strohalm.cyclos.dao.accounts.fee.account.MemberAccountFeeLogDAO;
import nl.strohalm.cyclos.entities.Relationship;
import nl.strohalm.cyclos.entities.accounts.AccountStatus;
import nl.strohalm.cyclos.entities.accounts.AccountType;
import nl.strohalm.cyclos.entities.accounts.MemberAccount;
import nl.strohalm.cyclos.entities.accounts.MemberAccountType;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFee;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFee.ChargeMode;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFee.InvoiceMode;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFee.PaymentDirection;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFee.RunMode;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFeeLog;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFeeLogDetailsDTO;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFeeLogQuery;
import nl.strohalm.cyclos.entities.accounts.fees.account.AccountFeeQuery;
import nl.strohalm.cyclos.entities.accounts.fees.account.MemberAccountFeeLog;
import nl.strohalm.cyclos.entities.accounts.fees.account.MemberAccountFeeLogQuery;
import nl.strohalm.cyclos.entities.accounts.transactions.Invoice;
import nl.strohalm.cyclos.entities.accounts.transactions.Transfer;
import nl.strohalm.cyclos.entities.exceptions.UnexpectedEntityException;
import nl.strohalm.cyclos.entities.groups.MemberGroup;
import nl.strohalm.cyclos.entities.members.Member;
import nl.strohalm.cyclos.entities.settings.LocalSettings;
import nl.strohalm.cyclos.scheduling.polling.ChargeAccountFeePollingTask;
import nl.strohalm.cyclos.services.InitializingService;
import nl.strohalm.cyclos.services.accounts.AccountDTO;
import nl.strohalm.cyclos.services.accounts.AccountDateDTO;
import nl.strohalm.cyclos.services.accounts.AccountServiceLocal;
import nl.strohalm.cyclos.services.application.ApplicationServiceLocal;
import nl.strohalm.cyclos.services.fetch.FetchServiceLocal;
import nl.strohalm.cyclos.services.settings.SettingsServiceLocal;
import nl.strohalm.cyclos.services.transactions.PaymentServiceLocal;
import nl.strohalm.cyclos.services.transactions.TransactionSummaryVO;
import nl.strohalm.cyclos.utils.BigDecimalHelper;
import nl.strohalm.cyclos.utils.DataIteratorHelper;
import nl.strohalm.cyclos.utils.DateHelper;
import nl.strohalm.cyclos.utils.FormatObject;
import nl.strohalm.cyclos.utils.Pair;
import nl.strohalm.cyclos.utils.Period;
import nl.strohalm.cyclos.utils.RelationshipHelper;
import nl.strohalm.cyclos.utils.TimePeriod;
import nl.strohalm.cyclos.utils.TimePeriod.Field;
import nl.strohalm.cyclos.utils.cache.Cache;
import nl.strohalm.cyclos.utils.cache.CacheCallback;
import nl.strohalm.cyclos.utils.cache.CacheManager;
import nl.strohalm.cyclos.utils.query.IteratorList;
import nl.strohalm.cyclos.utils.query.PageHelper;
import nl.strohalm.cyclos.utils.query.QueryParameters.ResultType;
import nl.strohalm.cyclos.utils.validation.ValidationException;
import nl.strohalm.cyclos.utils.validation.Validator;
import nl.strohalm.cyclos.utils.validation.Validator.Property;
import org.apache.commons.lang.time.DateUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* Implementation class for account fee service
* @author rafael
* @author luis
*/
public class AccountFeeServiceImpl implements AccountFeeServiceLocal, InitializingService {
private static int ACCOUNT_FEE_CHARGE_BATCH_SIZE = 20;
private static final Log LOG = LogFactory.getLog(AccountFeeServiceImpl.class);
private AccountFeeDAO accountFeeDao;
private AccountFeeLogDAO accountFeeLogDao;
private AccountDAO accountDao;
private FetchServiceLocal fetchService;
private AccountServiceLocal accountService;
private MemberAccountFeeLogDAO memberAccountFeeLogDao;
private CacheManager cacheManager;
private SettingsServiceLocal settingsService;
private PaymentServiceLocal paymentService;
private ApplicationServiceLocal applicationService;
@Override
public BigDecimal calculateAmount(final AccountFeeLog feeLog, final Member member) {
AccountFee fee = feeLog.getAccountFee();
if (!fee.getGroups().contains(member.getGroup())) {
// The member is not affected by this fee log
return null;
}
final Period period = feeLog.getPeriod();
final MemberAccountType accountType = fee.getAccountType();
final ChargeMode chargeMode = fee.getChargeMode();
final BigDecimal freeBase = fee.getFreeBase();
// Calculate the charge amount
BigDecimal chargedAmount = BigDecimal.ZERO;
BigDecimal amount = BigDecimal.ZERO;
Calendar endDate = (period != null) ? period.getEnd() : null;
final AccountDateDTO balanceParams = new AccountDateDTO(member, accountType, endDate);
if (chargeMode.isFixed()) {
boolean charge = true;
if (freeBase != null) {
final BigDecimal balance = accountService.getBalance(balanceParams);
if (balance.compareTo(freeBase) <= 0) {
charge = false;
}
}
// Fixed fee amount
if (charge) {
amount = feeLog.getAmount();
}
} else if (chargeMode.isBalance()) {
// Percentage over balance
final boolean positiveBalance = !chargeMode.isNegative();
BigDecimal balance = accountService.getBalance(balanceParams);
// Skip if balance is out of range
boolean charge = true;
// Apply the free base
if (freeBase != null) {
if (positiveBalance) {
balance = balance.subtract(freeBase);
} else {
balance = balance.add(freeBase);
}
}
// Check if something will be charged
if ((positiveBalance && balance.compareTo(BigDecimal.ZERO) <= 0) || (!positiveBalance && balance.compareTo(BigDecimal.ZERO) >= 0)) {
charge = false;
}
if (charge) {
// Get the charged amount
chargedAmount = feeLog.getAmountValue().apply(balance.abs());
amount = settingsService.getLocalSettings().round(chargedAmount);
}
} else if (chargeMode.isVolume()) {
// Percentage over average transactioned volume
amount = calculateChargeOverTransactionedVolume(feeLog, member);
}
// Ensure the amount is valid
final BigDecimal minPayment = paymentService.getMinimumPayment();
if (amount.compareTo(minPayment) < 0) {
amount = BigDecimal.ZERO;
}
return amount;
}
@Override
public BigDecimal calculateReservedAmountForVolumeFee(final MemberAccount account) {
MemberGroup group = (MemberGroup) fetchService.fetch(account.getMember().getGroup());
AccountFee volumeFee = getVolumeFee(account.getType(), group);
if (volumeFee == null) {
return BigDecimal.ZERO;
}
// Get the last period which was charged for this account
AccountFeeLog lastCharged = memberAccountFeeLogDao.getLastChargedLog(account.getMember(), volumeFee);
Calendar fromDate;
if (lastCharged == null || lastCharged.getDate().before(volumeFee.getEnabledSince())) {
// Never charged, or fee re-enabled after the last charge: consider either the account creation date or the fee enabled since -
// whatever happened later
fromDate = account.getCreationDate().after(volumeFee.getEnabledSince()) ? account.getCreationDate() : volumeFee.getEnabledSince();
} else {
// Already charged - consider the first day on the next period
fromDate = lastCharged.getPeriod().getEnd();
}
// As we calculate by whole days, make sure we're on the next day, so the balance will be ok
fromDate = DateHelper.truncateNextDay(fromDate);
// As the volume is an average of days, if there are previous uncharged periods, we must compute each period separately.
// For example, if the last charge was 2 months ago (a charge failed), we cannot assume that a single charge over 2 months is the
// same as 2 charges of 1 month, as we charge over the average. So, in the example, if an account has 100 over all time, and we charge 1%,
// the average for 1 month is 100, so we charge 1. The next month, is the same, and we charge another 1. If the period would be 2 months,
// The average over those 2 months is still 100, so a single charge of 1 is done, unlike the previous 2 charges.
// In most normal cases, the loop should be executed only once. Only when an account fee log has failed, it should be executed more than once.
TimePeriod recurrence = volumeFee.getRecurrence();
BigDecimal result = BigDecimal.ZERO;
Calendar now = Calendar.getInstance();
boolean done = false;
if (LOG.isDebugEnabled()) {
LOG.debug("Getting current status for " + account);
}
while (!done) {
Period period = recurrence.currentPeriod(fromDate);
if (!period.getEnd().after(now)) {
// Still a past uncharged period
period.setBegin(fromDate);
fromDate = DateHelper.truncateNextDay(period.getEnd());
} else {
period = Period.between(fromDate, DateHelper.truncate(now));
done = true;
}
BigDecimal chargeForPeriod = calculateVolumeCharge(account, volumeFee, period, result, done);
if (LOG.isDebugEnabled()) {
LOG.debug("Charge for period " + FormatObject.formatObject(period.getBegin()) + "\t" + FormatObject.formatObject(period.getEnd()) + "\t" + chargeForPeriod);
}
result = result.add(chargeForPeriod);
}
return result;
}
@Override
public void chargeManual(AccountFee fee) {
// Validates the fee
if (fee == null || fee.isTransient()) {
throw new UnexpectedEntityException();
}
fee = fetchService.fetch(fee, RelationshipHelper.nested(AccountFee.Relationships.ACCOUNT_TYPE, AccountType.Relationships.CURRENCY), AccountFee.Relationships.TRANSFER_TYPE);
if (fee.getRunMode() != RunMode.MANUAL) {
throw new UnexpectedEntityException();
}
// Insert the log with the RUNNING status, so it will be charged
insertNextExecution(fee);
applicationService.awakePollingTaskOnTransactionCommit(ChargeAccountFeePollingTask.class);
}
@Override
public int chargeScheduledFees(final Calendar time) {
final AccountFeeQuery query = new AccountFeeQuery();
query.setReturnDisabled(false);
query.setResultType(ResultType.LIST);
query.setHour((byte) time.get(Calendar.HOUR_OF_DAY));
query.setType(RunMode.SCHEDULED);
query.fetch(AccountFee.Relationships.LOGS);
query.setEnabledBefore(time);
final List<AccountFee> list = new ArrayList<AccountFee>();
// Get the daily fees
query.setRecurrence(TimePeriod.Field.DAYS);
list.addAll(search(query));
// Get the weekly fees
query.setRecurrence(TimePeriod.Field.WEEKS);
query.setDay((byte) time.get(Calendar.DAY_OF_WEEK));
list.addAll(search(query));
// Get the monthly fees
query.setRecurrence(TimePeriod.Field.MONTHS);
query.setDay((byte) time.get(Calendar.DAY_OF_MONTH));
list.addAll(search(query));
int count = 0;
for (final AccountFee fee : list) {
final AccountFeeLog lastExecution = fee.getLastExecution();
boolean charge;
if (lastExecution == null) {
// Was never executed. Charge now
charge = true;
} else {
final TimePeriod recurrence = fee.getRecurrence();
if (recurrence.getNumber() == 1) {
// When recurrence is every day or week or month, charge now
charge = true;
} else {
// Check the recurrence
final Calendar lastExecutionDate = lastExecution.getDate();
if (lastExecutionDate.after(time)) {
// Consistency check: don't charge if last execution was after the current time
charge = false;
}
// Find the number of elapsed periods
int number = 0;
final Calendar cal = DateHelper.truncate(lastExecutionDate);
final int calendarField = recurrence.getField().getCalendarValue();
final Calendar date = DateHelper.truncate(time);
while (cal.before(date)) {
number++;
cal.add(calendarField, 1);
}
// Charge each 'x' periods
charge = number % recurrence.getNumber() == 0;
}
}
// Charge the fee
if (charge) {
insertNextExecution(fee);
count++;
}
}
return count;
}
@Override
public AccountFeeLog getLastLog(final AccountFee fee) {
final AccountFeeLogQuery query = new AccountFeeLogQuery();
query.setAccountFee(fee);
query.setUniqueResult();
final List<AccountFeeLog> list = accountFeeLogDao.search(query);
if (list.isEmpty()) {
return null;
} else {
return list.iterator().next();
}
}
@Override
public AccountFeeLogDetailsDTO getLogDetails(final Long id) {
AccountFeeLog log = loadLog(id, AccountFeeLog.Relationships.ACCOUNT_FEE);
AccountFee fee = log.getAccountFee();
AccountFeeLogDetailsDTO dto = new AccountFeeLogDetailsDTO();
dto.setAccountFeeLog(log);
dto.setSkippedMembers(memberAccountFeeLogDao.countSkippedMembers(log));
dto.setTransfers(fee.getInvoiceMode() == InvoiceMode.ALWAYS ? new TransactionSummaryVO() : memberAccountFeeLogDao.getTransfersSummary(log));
dto.setInvoices(fee.getInvoiceMode() == InvoiceMode.NEVER ? new TransactionSummaryVO() : memberAccountFeeLogDao.getInvoicesSummary(log));
dto.setAcceptedInvoices(fee.getInvoiceMode() == InvoiceMode.NEVER ? new TransactionSummaryVO() : memberAccountFeeLogDao.getAcceptedInvoicesSummary(log));
dto.setOpenInvoices(dto.getInvoices().subtract(dto.getAcceptedInvoices()));
return dto;
}
@Override
public void initializeService() {
insertMissingLogs();
}
@Override
public AccountFee load(final Long id, final Relationship... fetch) {
return accountFeeDao.load(id, fetch);
}
@Override
public AccountFeeLog loadLog(final Long id, final Relationship... fetch) {
return accountFeeLogDao.load(id, fetch);
}
@Override
public AccountFeeLog nextLogToCharge() {
return accountFeeLogDao.nextToCharge();
}
@Override
public List<Member> nextMembersToCharge(final AccountFeeLog feeLog) {
if (feeLog.isRechargingFailed()) {
return memberAccountFeeLogDao.nextFailedToCharge(feeLog, ACCOUNT_FEE_CHARGE_BATCH_SIZE);
} else {
return memberAccountFeeLogDao.nextToCharge(feeLog, ACCOUNT_FEE_CHARGE_BATCH_SIZE);
}
}
@Override
public boolean prepareCharge(final AccountFeeLog feeLog) {
if (feeLog.getTotalMembers() != null) {
// Already prepared
return false;
}
int totalMembers = memberAccountFeeLogDao.prepareCharge(feeLog);
feeLog.setTotalMembers(totalMembers);
accountFeeLogDao.update(feeLog);
return true;
}
@Override
public void rechargeFailed(final AccountFeeLog accountFeeLog) {
AccountFeeLog log = fetchService.fetch(accountFeeLog);
if (log.isRechargingFailed()) {
// Already recharging failed
return;
}
if (log.getFailedMembers() == 0) {
// No failures
return;
}
log.setRechargingFailed(true);
log.setRechargeAttempt(log.getRechargeAttempt() + 1);
accountFeeLogDao.update(log);
applicationService.awakePollingTaskOnTransactionCommit(ChargeAccountFeePollingTask.class);
}
@Override
public int remove(final Long... ids) {
getVolumeFeeByAccountCache().clear();
return accountFeeDao.delete(ids);
}
@Override
public void removeFromPending(final AccountFeeLog feeLog, final Member member) {
if (feeLog.isRechargingFailed()) {
// Remove the MemberAccountFeeLog entirely
memberAccountFeeLogDao.remove(feeLog, member);
} else {
// Just remove the pending charge
memberAccountFeeLogDao.removePendingCharge(feeLog, member);
}
}
@Override
public AccountFee save(final AccountFee accountFee) {
validate(accountFee);
// Set some attributes to null depending on others
if (accountFee.getPaymentDirection() == PaymentDirection.TO_MEMBER) {
// A to member fee never uses invoices
accountFee.setInvoiceMode(null);
}
if (accountFee.getRunMode() == RunMode.MANUAL) {
// A manual fee does not have recurrence
accountFee.setRecurrence(null);
accountFee.setDay(null);
accountFee.setHour(null);
}
// Ensure the cache for volume fees is cleared
getVolumeFeeByAccountCache().clear();
// Persist the account fee
if (accountFee.isTransient()) {
if (accountFee.isEnabled() && accountFee.getEnabledSince() == null) {
accountFee.setEnabledSince(Calendar.getInstance());
}
return accountFeeDao.insert(accountFee);
} else {
final AccountFee current = load(accountFee.getId());
// Correctly handle the enabled since
if (accountFee.isEnabled() && current.getEnabledSince() == null) {
// When was not previously enabled, initialize the enabled since
if (accountFee.getEnabledSince() == null) {
accountFee.setEnabledSince(Calendar.getInstance());
}
} else if (!accountFee.isEnabled() && current.isEnabled()) {
// When is disabling, set the date to null
accountFee.setEnabledSince(null);
} else if (accountFee.getEnabledSince() == null) {
// Just updating other fields - keep the enabled since
accountFee.setEnabledSince(current.getEnabledSince());
}
return accountFeeDao.update(accountFee);
}
}
@Override
public AccountFeeLog save(final AccountFeeLog accountFeeLog) {
if (accountFeeLog.isTransient()) {
return accountFeeLogDao.insert(accountFeeLog);
} else {
return accountFeeLogDao.update(accountFeeLog);
}
}
@Override
public List<AccountFee> search(final AccountFeeQuery query) {
return accountFeeDao.search(query);
}
@Override
public List<AccountFeeLog> searchLogs(final AccountFeeLogQuery query) {
return accountFeeLogDao.search(query);
}
@Override
public List<MemberAccountFeeLog> searchMembers(final MemberAccountFeeLogQuery query) {
LocalSettings localSettings = settingsService.getLocalSettings();
return memberAccountFeeLogDao.search(query, localSettings.getMemberResultDisplay());
}
public void setAccountDao(final AccountDAO accountDao) {
this.accountDao = accountDao;
}
public void setAccountFeeDao(final AccountFeeDAO dao) {
accountFeeDao = dao;
}
public void setAccountFeeLogDao(final AccountFeeLogDAO accountFeeLogDao) {
this.accountFeeLogDao = accountFeeLogDao;
}
public void setAccountServiceLocal(final AccountServiceLocal accountService) {
this.accountService = accountService;
}
public void setApplicationServiceLocal(final ApplicationServiceLocal applicationService) {
this.applicationService = applicationService;
}
public void setCacheManager(final CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public MemberAccountFeeLog setChargingError(final AccountFeeLog feeLog, final Member member, final BigDecimal amount) {
removeFromPending(feeLog, member);
final MemberAccountFeeLog mafl = new MemberAccountFeeLog();
mafl.setDate(Calendar.getInstance());
mafl.setAccountFeeLog(feeLog);
mafl.setMember(member);
mafl.setAmount(amount);
mafl.setSuccess(false);
mafl.setRechargeAttempt(feeLog.getRechargeAttempt());
return memberAccountFeeLogDao.insert(mafl);
}
@Override
public MemberAccountFeeLog setChargingSuccess(final AccountFeeLog feeLog, final Member member, final BigDecimal amount, final Transfer transfer, final Invoice invoice) {
removeFromPending(feeLog, member);
MemberAccountFeeLog mafl = null;
if (feeLog.isRechargingFailed()) {
// Load the failed log
mafl = memberAccountFeeLogDao.load(feeLog, member);
if (mafl != null && mafl.isSuccess()) {
// Nothing to do with this member, as it was not a charge failure
return null;
}
}
if (mafl == null) {
mafl = new MemberAccountFeeLog();
mafl.setAccountFeeLog(feeLog);
mafl.setMember(member);
}
mafl.setDate(Calendar.getInstance());
mafl.setAmount(amount);
mafl.setSuccess(true);
mafl.setTransfer(transfer);
mafl.setInvoice(invoice);
if (mafl.isTransient()) {
return memberAccountFeeLogDao.insert(mafl);
} else {
return memberAccountFeeLogDao.update(mafl);
}
}
public void setFetchServiceLocal(final FetchServiceLocal fetchService) {
this.fetchService = fetchService;
}
public void setMemberAccountFeeLogDao(final MemberAccountFeeLogDAO memberAccountFeeLogDao) {
this.memberAccountFeeLogDao = memberAccountFeeLogDao;
}
public void setPaymentServiceLocal(final PaymentServiceLocal paymentService) {
this.paymentService = paymentService;
}
public void setSettingsServiceLocal(final SettingsServiceLocal settingsService) {
this.settingsService = settingsService;
}
@Override
public void validate(final AccountFee accountFee) {
getValidator(accountFee).validate(accountFee);
}
private BigDecimal calculateChargeOverTransactionedVolume(final AccountFeeLog feeLog, final Member member) {
AccountFee fee = feeLog.getAccountFee();
if (!fee.isEnabled()) {
return BigDecimal.ZERO;
}
MemberAccount account = (MemberAccount) accountService.getAccount(new AccountDTO(member, fee.getAccountType()));
// We want to limit for diffs within the fee log period
Period logPeriod = feeLog.getPeriod();
Calendar beginDate = logPeriod.getBegin();
if (fee.getEnabledSince().after(beginDate)) {
// However, if the fee was enabled in the middle of the period, consider this date instead
beginDate = fee.getEnabledSince();
}
if (account.getCreationDate().after(beginDate)) {
// If the account was created after, use it's creation date
beginDate = account.getCreationDate();
}
// As we calculate by whole days, make sure we're on the next day, so the balance will be ok
beginDate = DateHelper.truncateNextDay(beginDate);
Period period = Period.between(beginDate, logPeriod.getEnd());
if (period.getBegin().after(period.getEnd())) {
// In case of single days, the begin is the next day, and the end is the last second of the current day
period.setEnd(period.getBegin());
}
return calculateVolumeCharge(account, fee, period, BigDecimal.ZERO, false);
}
private BigDecimal calculateVolumeCharge(final MemberAccount account, final AccountFee volumeFee, final Period period, final BigDecimal additionalReserved, final boolean currentPeriod) {
Calendar fromDate = period.getBegin();
// Get the account status right after the last charged period
AccountStatus status = accountService.getStatus(account, fromDate);
// When there is some additional amount to consider as reserved, add it to the status
status.setReservedAmount(status.getReservedAmount().add(additionalReserved));
// Calculate the total days. We want the entire charged period. For example: if the fee was enabled in the middle of a month, it would be the
// entire month. Likewise, if no end limit was given, the current period will be used (ie, and the last day in the current month)
TimePeriod recurrence = volumeFee.getRecurrence();
Period totalPeriod = recurrence.currentPeriod(period.getBegin());
int totalDays = totalPeriod.getDays() + 1;
// Calculate each difference, with the corresponding reserved amount
Calendar lastDay = fromDate;
Calendar lastChargedDay = fromDate;
BigDecimal result = BigDecimal.ZERO;
IteratorList<AccountDailyDifference> diffs = accountDao.iterateDailyDifferences(account, period);
if (LOG.isDebugEnabled()) {
LOG.debug("********************************");
LOG.debug(FormatObject.formatObject(period.getBegin()) + "\t" + status.getBalance() + "\t" + status.getAvailableBalance());
}
try {
if (diffs.hasNext()) {
// There are differences - the lastChargedAvailable balance will be obtained within the loop
for (AccountDailyDifference diff : diffs) {
Calendar day = diff.getDay();
int days = DateHelper.daysBetween(lastDay, day);
// Get the available balance at that day
BigDecimal available = status.getAvailableBalanceWithoutCreditLimit();
if (volumeFee.getChargeMode().isNegative()) {
// If the charge is over negative amounts, consider the negated amount
available = available.negate();
}
// Take the free base into account
if (volumeFee.getFreeBase() != null) {
available = available.subtract(volumeFee.getFreeBase());
}
// If the available balance was significant, calculate the charge
if (available.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal volume = new BigDecimal(available.doubleValue() * days / totalDays);
if (LOG.isDebugEnabled()) {
LOG.debug(FormatObject.formatObject(day) + "\t" + diff.getBalance() + "\t" + status.getAvailableBalanceWithoutCreditLimit().add(diff.getBalance()) + "\t" + days + "\t" + totalDays + "\t" + volume);
}
BigDecimal toCharge = volume.multiply(volumeFee.getAmount()).divide(BigDecimalHelper.ONE_HUNDRED);
// status.setReservedAmount(status.getReservedAmount().add(toCharge));
result = result.add(toCharge);
lastChargedDay = day;
}
lastDay = day;
status.setBalance(status.getBalance().add(diff.getBalance()));
status.setReservedAmount(status.getReservedAmount().add(diff.getReserved()));
}
}
} finally {
DataIteratorHelper.close(diffs);
}
Calendar toDate = period.getEnd();
boolean lastPaymentInPeriodEnd = !toDate.before(lastChargedDay);
LocalSettings settings = settingsService.getLocalSettings();
// Only if the last payment was not today we have to take into account the results so far
if (DateHelper.daysBetween(lastChargedDay, Calendar.getInstance()) != 0) {
BigDecimal resultSoFar = settings.round(result);
status.setReservedAmount(status.getReservedAmount().add(resultSoFar));
}
// Calculate the avaliable balance after the last diff, which will remain the same until the period end
BigDecimal finalAvailableBalance = status.getAvailableBalanceWithoutCreditLimit();
if (volumeFee.getChargeMode().isNegative()) {
finalAvailableBalance = finalAvailableBalance.negate();
}
if (volumeFee.getFreeBase() != null) {
finalAvailableBalance = finalAvailableBalance.subtract(volumeFee.getFreeBase());
}
// Consider the last time slice, between the last diff and the period end, if any
if (lastPaymentInPeriodEnd && finalAvailableBalance.compareTo(BigDecimal.ZERO) > 0) {
int days = DateHelper.daysBetween(lastChargedDay, toDate) + (currentPeriod ? 0 : 1);
// Here, the lastChargedAvailableBalance is already subtracted from the free base (if any)
BigDecimal volume = new BigDecimal(finalAvailableBalance.doubleValue() * days / totalDays);
BigDecimal toCharge = volume.multiply(volumeFee.getAmount()).divide(BigDecimalHelper.ONE_HUNDRED);
result = result.add(toCharge);
if (LOG.isDebugEnabled()) {
status.setReservedAmount(settings.round(status.getReservedAmount().add(toCharge)));
LOG.debug(FormatObject.formatObject(lastChargedDay) + "\t0\t" + status.getAvailableBalanceWithoutCreditLimit() + "\t" + days + "\t" + totalDays + "\t" + volume);
}
}
return settings.round(result);
}
/**
* Returns the missing periods for an account fee
*/
private List<Period> getMissingPeriods(final AccountFee fee) {
final TimePeriod recurrence = fee.getRecurrence();
Calendar since;
final Calendar now = DateUtils.truncate(Calendar.getInstance(), Calendar.HOUR_OF_DAY);
// Determine since when the fee should have run
final AccountFeeLog lastLog = getLastLog(fee);
if (lastLog == null || lastLog.getDate().before(fee.getEnabledSince())) {
// May be 2 cases: Either the fee never ran or was re-enabled after the last run
since = fee.getEnabledSince();
} else {
// The fee ran and is enabled, just has missing logs
since = lastLog.getDate();
}
// Resolve the periods
final List<Period> periods = new ArrayList<Period>();
Calendar date = DateHelper.truncate(since);
Period period = recurrence.previousPeriod(date);
while (true) {
date = (Calendar) period.getEnd().clone();
date.add(Calendar.SECOND, 1);
period = recurrence.periodStartingAt(date);
if (period.getEnd().before(now)) {
periods.add(period);
} else {
break;
}
}
// Check if the last one should be really there
if (!periods.isEmpty()) {
// Do not use the last period if the listener has not run yet
final byte thisDay = (byte) now.get(Calendar.DAY_OF_MONTH);
final byte thisHour = (byte) now.get(Calendar.HOUR_OF_DAY);
boolean removeLast = false;
final Byte feeDay = fee.getDay();
if (feeDay != null && thisDay < feeDay) {
removeLast = true;
} else if (feeDay == null || thisDay == feeDay) {
removeLast = thisHour < fee.getHour();
}
if (removeLast) {
periods.remove(periods.size() - 1);
}
}
// Check if any of those logs are present
final Iterator<Period> it = periods.iterator();
while (it.hasNext()) {
final Period current = it.next();
final AccountFeeLogQuery logQuery = new AccountFeeLogQuery();
logQuery.setPageForCount();
logQuery.setAccountFee(fee);
logQuery.setPeriodStartAt(current.getBegin());
final int count = PageHelper.getTotalCount(accountFeeLogDao.search(logQuery));
if (count > 0) {
it.remove();
}
}
return periods;
}
private Validator getValidator(final AccountFee fee) {
final Validator validator = new Validator("accountFee");
validator.property("accountType").required();
validator.property("transferType").required();
validator.property("name").required().maxLength(100);
validator.property("description").maxLength(1000);
validator.property("amount").required().positiveNonZero();
validator.property("chargeMode").required();
validator.property("paymentDirection").required();
Property runMode = validator.property("runMode").required();
if (fee.getChargeMode() != null && fee.getChargeMode().isVolume()) {
runMode.anyOf(RunMode.SCHEDULED);
}
if (fee.getRunMode() == RunMode.SCHEDULED) {
validator.property("recurrence.number").key("accountFee.recurrence").required().positiveNonZero().lessThan(28);
validator.property("recurrence.field").key("accountFee.recurrence").required().anyOf(TimePeriod.Field.DAYS, TimePeriod.Field.WEEKS, TimePeriod.Field.MONTHS);
Property day = validator.property("day");
Field field = fee.getRecurrence() == null ? null : fee.getRecurrence().getField();
if (field != null && field != Field.DAYS) {
day.required();
if (field == Field.WEEKS) {
day.between(1, 7);
} else {
day.between(1, 28);
}
}
validator.property("hour").required().between(0, 23);
}
if (fee.isMemberToSystem()) {
validator.property("invoiceMode").required();
}
if (fee.isTransient()) {
validator.property("enabledSince").key("accountFee.firstPeriodAfter").futureOrToday();
}
return validator;
}
private AccountFee getVolumeFee(final AccountType accountType, final MemberGroup group) {
Pair<Long, Long> key = new Pair<Long, Long>(accountType.getId(), group.getId());
return getVolumeFeeByAccountCache().get(key, new CacheCallback() {
@Override
public Object retrieve() {
final AccountFeeQuery query = new AccountFeeQuery();
query.setAccountType(accountType);
query.setGroups(Collections.singleton(group));
query.setType(RunMode.SCHEDULED);
List<AccountFee> fees = search(query);
for (Iterator<AccountFee> iterator = fees.iterator(); iterator.hasNext();) {
if (!iterator.next().getChargeMode().isVolume()) {
iterator.remove();
}
}
if (fees.size() > 1) {
throw new ValidationException("accountFee.error.multipleVolumeFees");
}
return fees.isEmpty() ? null : fees.iterator().next();
}
});
}
private Cache getVolumeFeeByAccountCache() {
return cacheManager.getCache("cyclos.VolumeFeeByAccount");
}
private void insertMissingLogs() {
final AccountFeeQuery query = new AccountFeeQuery();
query.setReturnDisabled(false);
query.setType(RunMode.SCHEDULED);
Calendar thisHour = DateUtils.truncate(Calendar.getInstance(), Calendar.HOUR_OF_DAY);
final List<AccountFee> accountFees = accountFeeDao.search(query);
for (final AccountFee fee : accountFees) {
final Field recurrenceField = fee.getRecurrence().getField();
final List<Period> missingPeriods = getMissingPeriods(fee);
if (!missingPeriods.isEmpty()) {
for (final Period period : missingPeriods) {
final Calendar shouldHaveChargedAt = DateHelper.truncate(period.getEnd());
shouldHaveChargedAt.add(Calendar.DAY_OF_MONTH, 1);
switch (recurrenceField) {
case WEEKS:
// Go to the day it should have been charged
int max = 7;
while (max > 0 && shouldHaveChargedAt.get(Calendar.DAY_OF_WEEK) < fee.getDay()) {
shouldHaveChargedAt.add(Calendar.DAY_OF_MONTH, 1);
max--;
}
break;
case MONTHS:
shouldHaveChargedAt.set(Calendar.DAY_OF_MONTH, fee.getDay());
break;
}
shouldHaveChargedAt.set(Calendar.HOUR_OF_DAY, fee.getHour());
// Only insert the missing log if it should have run before or at this hour
if (!shouldHaveChargedAt.after(thisHour)) {
final AccountFeeLog log = new AccountFeeLog();
log.setAccountFee(fee);
log.setDate(shouldHaveChargedAt);
log.setPeriod(period);
log.setAmount(fee.getAmount());
log.setFreeBase(fee.getFreeBase());
accountFeeLogDao.insert(log);
}
}
}
}
}
private AccountFeeLog insertNextExecution(AccountFee fee) {
fee = fetchService.fetch(fee);
if (!fee.isEnabled()) {
return null;
}
// Resolve the period
Period period = null;
Calendar executionDate = null;
if (fee.getRunMode() == RunMode.MANUAL) {
// Manual fee
executionDate = Calendar.getInstance();
} else {
// Scheduled fee
executionDate = fee.getNextExecutionDate();
// Do not insert future account fees.
if (executionDate.after(Calendar.getInstance())) {
return null;
}
period = fee.getRecurrence().previousPeriod(executionDate);
}
// Create the log
AccountFeeLog nextExecution = new AccountFeeLog();
nextExecution.setAccountFee(fee);
nextExecution.setDate(executionDate);
nextExecution.setPeriod(period);
nextExecution.setAmount(fee.getAmount());
nextExecution.setFreeBase(fee.getFreeBase());
return accountFeeLogDao.insert(nextExecution);
}
}