package io.budgetapp.service;
import io.budgetapp.application.DataConstraintException;
import io.budgetapp.crypto.PasswordEncoder;
import io.budgetapp.dao.AuthTokenDAO;
import io.budgetapp.dao.CategoryDAO;
import io.budgetapp.dao.BudgetDAO;
import io.budgetapp.dao.BudgetTypeDAO;
import io.budgetapp.dao.RecurringDAO;
import io.budgetapp.dao.TransactionDAO;
import io.budgetapp.dao.UserDAO;
import io.budgetapp.model.AccountSummary;
import io.budgetapp.model.AuthToken;
import io.budgetapp.model.Budget;
import io.budgetapp.model.BudgetType;
import io.budgetapp.model.Category;
import io.budgetapp.model.CategoryType;
import io.budgetapp.model.Group;
import io.budgetapp.model.Point;
import io.budgetapp.model.PointType;
import io.budgetapp.model.Recurring;
import io.budgetapp.model.Transaction;
import io.budgetapp.model.UsageSummary;
import io.budgetapp.model.User;
import io.budgetapp.model.form.LoginForm;
import io.budgetapp.model.form.SignUpForm;
import io.budgetapp.model.form.TransactionForm;
import io.budgetapp.model.form.budget.AddBudgetForm;
import io.budgetapp.model.form.budget.UpdateBudgetForm;
import io.budgetapp.model.form.recurring.AddRecurringForm;
import io.budgetapp.model.form.report.SearchFilter;
import io.budgetapp.model.form.user.Password;
import io.budgetapp.model.form.user.Profile;
import io.budgetapp.util.Util;
import org.hibernate.SessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
*
*/
public class FinanceService {
private static final Logger LOGGER = LoggerFactory.getLogger(FinanceService.class);
private static final DateTimeFormatter SUMMARY_DATE_FORMATTER = DateTimeFormatter.ofPattern("dd MMM");
private final SessionFactory sessionFactory;
private final UserDAO userDAO;
private final BudgetDAO budgetDAO;
private final BudgetTypeDAO budgetTypeDAO;
private final CategoryDAO categoryDAO;
private final TransactionDAO transactionDAO;
private final RecurringDAO recurringDAO;
private final AuthTokenDAO authTokenDAO;
private final PasswordEncoder passwordEncoder;
public FinanceService(SessionFactory sessionFactory, UserDAO userDAO, BudgetDAO budgetDAO, BudgetTypeDAO budgetTypeDAO, CategoryDAO categoryDAO, TransactionDAO transactionDAO, RecurringDAO recurringDAO, AuthTokenDAO authTokenDAO, PasswordEncoder passwordEncoder) {
this.sessionFactory = sessionFactory;
this.userDAO = userDAO;
this.budgetDAO = budgetDAO;
this.budgetTypeDAO = budgetTypeDAO;
this.categoryDAO = categoryDAO;
this.transactionDAO = transactionDAO;
this.recurringDAO = recurringDAO;
this.authTokenDAO = authTokenDAO;
this.passwordEncoder = passwordEncoder;
}
public SessionFactory getSessionFactory() {
return sessionFactory;
}
//==================================================================
// USER
//==================================================================
public User addUser(SignUpForm signUp) {
Optional<User> optional = userDAO.findByUsername(signUp.getUsername());
if(optional.isPresent()) {
throw new DataConstraintException("username", "Username already taken.");
}
signUp.setPassword(passwordEncoder.encode(signUp.getPassword()));
User user = userDAO.add(signUp);
LocalDate now = LocalDate.now();
// init account
initCategoriesAndBudgets(user, now.getMonthValue(), now.getYear());
return user;
}
public User update(User user, Profile profile) {
user.setName(profile.getName());
user.setCurrency(profile.getCurrency());
userDAO.update(user);
return user;
}
public void changePassword(User user, Password password) {
User originalUser = userDAO.findById(user.getId());
if(!Objects.equals(password.getPassword(), password.getConfirm())) {
throw new DataConstraintException("confirm", "Confirm Password does not match");
}
if(!passwordEncoder.matches(password.getOriginal(), user.getPassword())) {
throw new DataConstraintException("original", "Current Password does not match");
}
originalUser.setPassword(passwordEncoder.encode(password.getPassword()));
userDAO.update(originalUser);
}
public Optional<User> findUserByToken(String token) {
Optional<AuthToken> authToken = authTokenDAO.find(token);
if(authToken.isPresent()) {
return Optional.of(authToken.get().getUser());
} else {
return Optional.empty();
}
}
public Optional<User> login(LoginForm login) {
Optional<User> optionalUser = userDAO.findByUsername(login.getUsername());
if(optionalUser.isPresent()) {
User user = optionalUser.get();
if(passwordEncoder.matches(login.getPassword(), user.getPassword())) {
List<AuthToken> tokens = authTokenDAO.findByUser(user);
if(tokens.isEmpty()) {
AuthToken token = authTokenDAO.add(optionalUser.get());
optionalUser.get().setToken(token.getToken());
return optionalUser;
} else {
optionalUser.get().setToken(tokens.get(0).getToken());
return optionalUser;
}
}
}
return Optional.empty();
}
//==================================================================
// END USER
//==================================================================
public AccountSummary findAccountSummaryByUser(User user, Integer month, Integer year) {
if(month == null || year == null) {
LocalDate now = LocalDate.now();
month = now.getMonthValue();
year = now.getYear();
}
LOGGER.debug("Find account summary {} {}-{}", user, month, year);
AccountSummary accountSummary = new AccountSummary();
List<Budget> budgets = budgetDAO.findBudgets(user, month, year, true);
// no budgets, first time access
if(budgets.isEmpty()) {
LOGGER.debug("First time access budgets {} {}-{}", user, month, year);
initCategoriesAndBudgets(user, month, year);
budgets = budgetDAO.findBudgets(user, month, year, true);
}
Map<Category, List<Budget>> grouped = budgets
.stream()
.collect(Collectors.groupingBy(Budget::getCategory));
for(Map.Entry<Category, List<Budget>> entry: grouped.entrySet()) {
Category category = entry.getKey();
double budget = entry.getValue().stream().mapToDouble(Budget::getProjected).sum();
double spent = entry.getValue().stream().mapToDouble(Budget::getActual).sum();
Group group = new Group(category.getId(), category.getName());
group.setType(category.getType());
group.setBudget(budget);
group.setSpent(spent);
group.setBudgets(entry.getValue());
accountSummary.getGroups().add(group);
}
Collections.sort(accountSummary.getGroups(), (o1, o2) -> o1.getId().compareTo(o2.getId()));
return accountSummary;
}
private void initCategoriesAndBudgets(User user, int month, int year) {
Collection<Category> categories = categoryDAO.findCategories(user);
// no categories, first time access
if(categories.isEmpty()) {
LOGGER.debug("Create default categories and budgets {} {}-{}", user, month, year);
generateDefaultCategoriesAndBudgets(user, month, year);
} else {
LOGGER.debug("Copy budgets {} {}-{}", user, month, year);
generateBudgets(user, month, year);
}
}
public UsageSummary findUsageSummaryByUser(User user, Integer month, Integer year) {
if(month == null || year == null) {
LocalDate now = LocalDate.now();
month = now.getMonthValue();
year = now.getYear();
}
List<Budget> budgets = budgetDAO.findBudgets(user, month, year, true);
double income =
budgets
.stream()
.filter(p -> p.getCategory().getType() == CategoryType.INCOME)
.mapToDouble(Budget::getActual)
.sum();
double budget =
budgets
.stream()
.filter(p -> p.getCategory().getType() == CategoryType.EXPENDITURE)
.mapToDouble(Budget::getProjected)
.sum();
double spent =
budgets
.stream()
.filter(p -> p.getCategory().getType() == CategoryType.EXPENDITURE)
.mapToDouble(Budget::getActual)
.sum();
return new UsageSummary(income, budget, spent);
}
private void generateDefaultCategoriesAndBudgets(User user, int month, int year) {
Collection<Category> categories = categoryDAO.addDefaultCategories(user);
Map<String, List<Budget>> defaultBudgets = budgetDAO.findDefaultBudgets();
Date period = Util.yearMonthDate(month, year);
for(Category category: categories) {
List<Budget> budgets = defaultBudgets.get(category.getName());
if(budgets != null) {
for(Budget budget : budgets) {
BudgetType budgetType = budgetTypeDAO.addBudgetType();
Budget newBudget = new Budget();
newBudget.setName(budget.getName());
newBudget.setPeriod(period);
newBudget.setCategory(category);
newBudget.setBudgetType(budgetType);
budgetDAO.addBudget(user, newBudget);
}
}
}
}
//==================================================================
// BUDGET
//==================================================================
public Budget addBudget(User user, AddBudgetForm budgetForm) {
BudgetType budgetType = budgetTypeDAO.addBudgetType();
Budget budget = new Budget(budgetForm);
budget.setBudgetType(budgetType);
return budgetDAO.addBudget(user, budget);
}
public Budget updateBudget(User user, UpdateBudgetForm budgetForm) {
Budget budget = budgetDAO.findById(user, budgetForm.getId());
Category category = categoryDAO.findById(budget.getCategory().getId());
budget.setName(budgetForm.getName());
budget.setProjected(budgetForm.getProjected());
// INCOME type allow user change actual without
// add transactions
if(category.getType() == CategoryType.INCOME) {
budget.setActual(budgetForm.getActual());
}
budgetDAO.update(budget);
return budget;
}
public void deleteBudget(User user, long budgetId) {
Budget budget = budgetDAO.findById(user, budgetId);
budgetDAO.delete(budget);
}
public List<Budget> findBudgetsByUser(User user) {
return budgetDAO.findBudgets(user);
}
public Budget findBudgetById(User user, long budgetId) {
return budgetDAO.findById(user, budgetId);
}
public List<Budget> findBudgetsByCategory(User user, long categoryId) {
return budgetDAO.findByUserAndCategory(user, categoryId);
}
public List<String> findBudgetSuggestions(User user, String q) {
return budgetDAO.findSuggestions(user, q);
}
private void generateBudgets(User user, int month, int year) {
LocalDate now = LocalDate.now();
// use current month's budgets
// when user navigate backward
List<Budget> originalBudgets = budgetDAO.findBudgets(user, now.getMonthValue(), now.getYear(), false);
// current month budget is empty
if(originalBudgets.isEmpty()) {
// use latest budget
Date latestDate = budgetDAO.findLatestBudget(user);
LocalDate date = latestDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
originalBudgets = budgetDAO.findBudgets(user, date.getMonthValue(), date.getYear(), false);
}
Date period = Util.yearMonthDate(month, year);
for(Budget budget : originalBudgets) {
Budget newBudget = new Budget();
newBudget.setName(budget.getName());
newBudget.setProjected(budget.getProjected());
newBudget.setPeriod(period);
newBudget.setCategory(budget.getCategory());
newBudget.setBudgetType(budget.getBudgetType());
budgetDAO.addBudget(user, newBudget);
}
}
//==================================================================
// END BUDGET
//==================================================================
//==================================================================
// RECURRING
//==================================================================
public Recurring addRecurring(User user, AddRecurringForm recurringForm) {
// validation
Date now = new Date();
if(!Util.inMonth(recurringForm.getRecurringAt(), new Date())) {
throw new DataConstraintException("recurringAt", "Recurring Date must within " + Util.toFriendlyMonthDisplay(now) + " " + (now.getYear() + 1900));
}
// end validation
Budget budget = findBudgetById(user, recurringForm.getBudgetId());
budget.setActual(budget.getActual() + recurringForm.getAmount());
budgetDAO.update(budget);
Recurring recurring = new Recurring();
recurring.setAmount(recurringForm.getAmount());
recurring.setLastRunAt(recurringForm.getRecurringAt());
recurring.setRecurringType(recurringForm.getRecurringType());
recurring.setBudgetType(budget.getBudgetType());
recurring.setRemark(recurringForm.getRemark());
recurring = recurringDAO.addRecurring(recurring);
Transaction transaction = new Transaction();
transaction.setName(budget.getName());
transaction.setAmount(recurring.getAmount());
transaction.setRemark(recurringForm.getRemark());
transaction.setAuto(Boolean.TRUE);
transaction.setTransactionOn(recurring.getLastRunAt());
transaction.setBudget(budget);
transaction.setRecurring(recurring);
transactionDAO.addTransaction(transaction);
return recurring;
}
public List<Recurring> findRecurrings(User user) {
List<Recurring> results = recurringDAO.findRecurrings(user);
// TODO: fix N + 1 query but now still OK
results.forEach(this::populateRecurring);
LOGGER.debug("Found recurrings {}", results);
return results;
}
public void updateRecurrings() {
LOGGER.debug("Begin update recurrings...");
List<Recurring> recurrings = recurringDAO.findActiveRecurrings();
LOGGER.debug("Found {} recurring(s) item to update", recurrings.size());
for (Recurring recurring : recurrings) {
// budget
Budget budget = budgetDAO.findByBudgetType(recurring.getBudgetType().getId());
budget.setActual(budget.getActual() + recurring.getAmount());
budgetDAO.update(budget);
// end budget
// recurring
recurring.setLastRunAt(new Date());
recurringDAO.update(recurring);
// end recurring
// transaction
Transaction transaction = new Transaction();
transaction.setName(budget.getName());
transaction.setAmount(recurring.getAmount());
transaction.setRecurring(recurring);
transaction.setRemark(recurring.getRecurringTypeDisplay() + " recurring for " + budget.getName());
transaction.setAuto(true);
transaction.setBudget(budget);
transaction.setTransactionOn(new Date());
transactionDAO.addTransaction(transaction);
// end transaction
}
LOGGER.debug("Finish update recurrings...");
}
private void populateRecurring(Recurring recurring) {
Budget budget = budgetDAO.findByBudgetType(recurring.getBudgetType().getId());
recurring.setName(budget.getName());
}
public void deleteRecurring(User user, long recurringId) {
Recurring recurring = recurringDAO.find(user, recurringId);
recurringDAO.delete(recurring);
}
//==================================================================
// END RECURRING
//==================================================================
//==================================================================
// TRANSACTION
//==================================================================
public Transaction addTransaction(User user, TransactionForm transactionForm) {
Budget budget = budgetDAO.findById(user, transactionForm.getBudget().getId());
// validation
if(transactionForm.getAmount() == 0) {
throw new DataConstraintException("amount", "Amount is required");
}
if(Boolean.TRUE.equals(transactionForm.getRecurring()) && transactionForm.getRecurringType() == null) {
throw new DataConstraintException("recurringType", "Recurring Type is required");
}
Date transactionOn = transactionForm.getTransactionOn();
if(!Util.inMonth(transactionOn, budget.getPeriod())) {
throw new DataConstraintException("transactionOn", "Transaction Date must within " + Util.toFriendlyMonthDisplay(budget.getPeriod()) + " " + (budget.getPeriod().getYear() + 1900));
}
// end validation
budget.setActual(budget.getActual() + transactionForm.getAmount());
budgetDAO.update(budget);
Recurring recurring = new Recurring();
if(Boolean.TRUE.equals(transactionForm.getRecurring())) {
LOGGER.debug("Add recurring {} by {}", transactionForm, user);
recurring.setAmount(transactionForm.getAmount());
recurring.setRecurringType(transactionForm.getRecurringType());
recurring.setBudgetType(budget.getBudgetType());
recurring.setRemark(transactionForm.getRemark());
recurring.setLastRunAt(transactionForm.getTransactionOn());
recurringDAO.addRecurring(recurring);
}
Transaction transaction = new Transaction();
transaction.setName(budget.getName());
transaction.setAmount(transactionForm.getAmount());
transaction.setRemark(transactionForm.getRemark());
transaction.setAuto(Boolean.TRUE.equals(transactionForm.getRecurring()));
transaction.setTransactionOn(transactionForm.getTransactionOn());
transaction.setBudget(transactionForm.getBudget());
if(Boolean.TRUE.equals(transactionForm.getRecurring())) {
transaction.setRecurring(recurring);
}
return transactionDAO.addTransaction(transaction);
}
public boolean deleteTransaction(User user, long transactionId) {
// only delete transaction that belong to that user
Optional<Transaction> optional = transactionDAO.findById(user, transactionId);
if(optional.isPresent()) {
Transaction transaction = optional.get();
Budget budget = transaction.getBudget();
budget.setActual(budget.getActual() - transaction.getAmount());
transactionDAO.delete(transaction);
return true;
}
return false;
}
public Transaction findTransactionById(long transactionId) {
return transactionDAO.findById(transactionId);
}
public List<Transaction> findRecentTransactions(User user, Integer limit) {
if(limit == null) {
limit = 20;
}
return transactionDAO.find(user, limit);
}
public List<Transaction> findTodayRecurringsTransactions(User user) {
SearchFilter filter = new SearchFilter();
filter.setStartOn(new Date());
filter.setEndOn(new Date());
filter.setAuto(Boolean.TRUE);
return transactionDAO.findTransactions(user, filter);
}
public List<Transaction> findTransactionsByRecurring(User user, long recurringId) {
return transactionDAO.findByRecurring(user, recurringId);
}
public List<Transaction> findTransactions(User user, SearchFilter filter) {
LOGGER.debug("Search transactions with {}", filter);
return transactionDAO.findTransactions(user, filter);
}
public List<Transaction> findTransactionsByBudget(User user, long budgetId) {
return transactionDAO.findByBudget(user, budgetId);
}
public List<Point> findTransactionUsage(User user, Integer month, Integer year) {
LocalDate now = LocalDate.now();
if(month == null || year == null) {
month = now.getMonthValue();
year = now.getYear();
}
List<Point> points = new ArrayList<>();
LocalDate begin = LocalDate.of(year, month, 1);
LocalDate ending = LocalDate.of(year, month, begin.lengthOfMonth());
if(now.getMonthValue() == month && now.getYear() == year) {
ending = now;
}
// first 1-7 days show last 7 days's transactions instead of
// show 1 or 2 days
if(ending.getDayOfMonth() < 7) {
begin = begin.minusDays(7 - ending.getDayOfMonth());
}
Instant instantStart = begin.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant();
Date start = Date.from(instantStart);
Instant instantEnd = ending.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant();
Date end = Date.from(instantEnd);
List<Transaction> transactions = transactionDAO.findByRange(user, start, end);
Map<Date, List<Transaction>> groups = transactions
.stream()
.collect(Collectors.groupingBy(Transaction::getTransactionOn, TreeMap::new, Collectors.toList()));
int days = Period.between(begin, ending).getDays() + 1;
for (int i = 0; i < days; i++) {
LocalDate day = begin.plusDays(i);
Instant instantDay = day.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant();
Date dayDate = Date.from(instantDay);
groups.putIfAbsent(dayDate, Collections.emptyList());
}
for (Map.Entry<Date, List<Transaction>> entry : groups.entrySet()) {
double total = entry.getValue()
.stream()
.mapToDouble(Transaction::getAmount)
.sum();
LocalDate res = Util.toLocalDate(entry.getKey());
Point point = new Point(SUMMARY_DATE_FORMATTER.format(res), entry.getKey().getTime(), total, PointType.TRANSACTIONS);
points.add(point);
}
return points;
}
//==================================================================
// END TRANSACTION
//==================================================================
//==================================================================
// CATEGORY
//==================================================================
public List<Category> findCategories(User user) {
return categoryDAO.findCategories(user);
}
public Category addCategory(User user, Category category) {
return categoryDAO.addCategory(user, category);
}
public Category findCategoryById(long categoryId) {
return categoryDAO.findById(categoryId);
}
public List<Point> findUsageByCategory(User user, Integer month, Integer year) {
if(month == null || year == null) {
LocalDate now = LocalDate.now();
month = now.getMonthValue();
year = now.getYear();
}
List<Point> points = new ArrayList<>();
List<Budget> budgets = budgetDAO.findBudgets(user, month, year, true);
Map<Category, List<Budget>> groups = budgets
.stream()
.filter(b -> b.getCategory().getType() == CategoryType.EXPENDITURE)
.collect(Collectors.groupingBy(Budget::getCategory));
for (Map.Entry<Category, List<Budget>> entry : groups.entrySet()) {
double total = entry.getValue()
.stream()
.mapToDouble(Budget::getActual)
.sum();
Point point = new Point(entry.getKey().getName(), entry.getKey().getId(), total, PointType.CATEGORY);
points.add(point);
}
Collections.sort(points, (p1, p2) -> Double.compare(p2.getValue(), p1.getValue()));
return points;
}
public List<Point> findMonthlyTransactionUsage(User user) {
List<Point> points = new ArrayList<>();
LocalDate end = LocalDate.now();
LocalDate start = end.minusMonths(6);
List<Budget> budgets = budgetDAO.findByRange(user, start.getMonthValue(), start.getYear(), end.getMonthValue(), end.getYear());
// group by period
Map<Date, List<Budget>> groups = budgets
.stream()
.collect(Collectors.groupingBy(Budget::getPeriod, TreeMap::new, Collectors.toList()));
LocalDate now = LocalDate.now();
// populate empty months, if any
for (int i = 0; i < 6; i++) {
LocalDate day = now.minusMonths(i).withDayOfMonth(1);
groups.putIfAbsent(Util.toDate(day), Collections.emptyList());
}
// generate points
for (Map.Entry<Date, List<Budget>> entry : groups.entrySet()) {
// budget
double budget = entry.getValue()
.stream()
.filter(b -> b.getCategory().getType() == CategoryType.EXPENDITURE)
.mapToDouble(Budget::getProjected)
.sum();
// spending
double spending = entry.getValue()
.stream()
.filter(b -> b.getActual() > 0)
.filter(b -> b.getCategory().getType() == CategoryType.EXPENDITURE)
.mapToDouble(Budget::getActual)
.sum();
// refund
double refund = entry.getValue()
.stream()
.filter(b -> b.getActual() < 0)
.mapToDouble(Budget::getActual)
.sum();
String month = Util.toFriendlyMonthDisplay(entry.getKey());
Point spendingPoint = new Point(month, entry.getKey().getTime(), spending, PointType.MONTHLY_SPEND);
Point refundPoint = new Point(month, entry.getKey().getTime(), refund, PointType.MONTHLY_REFUND);
Point budgetPoint = new Point(month, entry.getKey().getTime(), budget, PointType.MONTHLY_BUDGET);
points.add(spendingPoint);
points.add(refundPoint);
points.add(budgetPoint);
}
return points;
}
public void deleteCategory(User user, long categoryId) {
Category category = categoryDAO.find(user, categoryId);
categoryDAO.delete(category);
}
public List<String> findCategorySuggestions(User user, String q) {
return categoryDAO.findSuggestions(user, q);
}
//==================================================================
// END CATEGORY
//==================================================================
}