From 09c3d932c2f77d5cd4e98cf89b4fe39b05438901 Mon Sep 17 00:00:00 2001 From: Steve Schafer Date: Sat, 17 Jan 2026 09:57:29 -0700 Subject: [PATCH] Add BudgetAmounts Add CategorizerController Add ReportController --- .classpath | 23 +- .factorypath | 146 +++---- .settings/org.eclipse.core.resources.prefs | 2 + .settings/org.eclipse.jdt.core.prefs | 2 +- pom.xml | 5 + .../stephenschafer/budget/BudgetAmounts.java | 23 ++ .../com/stephenschafer/budget/CatRegex.java | 175 +++++++++ .../budget/CategorizationResponse.java | 17 + .../budget/CategorizerController.java | 362 ++++++++++++++++++ .../com/stephenschafer/budget/Category.java | 15 + .../budget/CategoryDaoImpl.java | 2 +- .../budget/CustomCorsConfiguration.java | 3 +- .../com/stephenschafer/budget/Detail.java | 21 + .../stephenschafer/budget/JwtTokenUtil.java | 16 +- .../budget/MultiplyAssignedTransaction.java | 16 + .../budget/RegexController.java | 22 ++ .../stephenschafer/budget/RegexDisplay.java | 7 + .../com/stephenschafer/budget/Report.java | 16 + .../stephenschafer/budget/ReportCategory.java | 232 +++++++++++ .../budget/ReportController.java | 272 +++++++++++++ .../stephenschafer/budget/ReportDetail.java | 80 ++++ .../stephenschafer/budget/UnresolvedItem.java | 61 +++ .../java/com/stephenschafer/budget/Util.java | 18 + .../budget/getBudgetAmounts.sql | 3 + src/main/resources/application.properties | 3 + src/main/resources/clearExtraDescriptions.sql | 1 + src/main/resources/createCategoryTable.sql | 6 + .../createIfNotExistBudgetAmount.sql | 16 + src/main/resources/createRegexTable.sql | 10 + .../resources/createTransactionRegexTable.sql | 5 + src/main/resources/createTransactionTable.sql | 12 + src/main/resources/createYearsTable.sql | 3 + src/main/resources/findTable.sql | 1 + src/main/resources/getCategories.sql | 5 + src/main/resources/getChase.sql | 8 + src/main/resources/getChildCategoryId.sql | 1 + src/main/resources/getCiti.sql | 7 + src/main/resources/getDigitalOrders.sql | 15 + src/main/resources/getDigitalReturns.sql | 14 + src/main/resources/getDiscover.sql | 7 + src/main/resources/getFirstBank.sql | 6 + .../getMultiplyAssignedTransactions.sql | 5 + src/main/resources/getPaypal.sql | 13 + src/main/resources/getRegexes.sql | 10 + src/main/resources/getRetailOrders.sql | 8 + src/main/resources/getRootCategoryId.sql | 1 + .../resources/getTransactionDescriptions.sql | 3 + src/main/resources/getTransactionDetail.sql | 3 + src/main/resources/getTransactionRegex.sql | 1 + src/main/resources/getYears.sql | 3 + src/main/resources/insertChildCategory.sql | 1 + src/main/resources/insertRegex.sql | 9 + src/main/resources/insertRootCategory.sql | 1 + src/main/resources/insertTransaction.sql | 8 + src/main/resources/insertTransactionRegex.sql | 1 + src/main/resources/insertYear.sql | 3 + src/main/resources/setExtraDescription.sql | 1 + src/main/resources/updateRegexLink.sql | 1 + 58 files changed, 1640 insertions(+), 91 deletions(-) create mode 100644 src/main/java/com/stephenschafer/budget/BudgetAmounts.java create mode 100644 src/main/java/com/stephenschafer/budget/CatRegex.java create mode 100644 src/main/java/com/stephenschafer/budget/CategorizationResponse.java create mode 100644 src/main/java/com/stephenschafer/budget/CategorizerController.java create mode 100644 src/main/java/com/stephenschafer/budget/Detail.java create mode 100644 src/main/java/com/stephenschafer/budget/MultiplyAssignedTransaction.java create mode 100644 src/main/java/com/stephenschafer/budget/Report.java create mode 100644 src/main/java/com/stephenschafer/budget/ReportCategory.java create mode 100644 src/main/java/com/stephenschafer/budget/ReportController.java create mode 100644 src/main/java/com/stephenschafer/budget/ReportDetail.java create mode 100644 src/main/java/com/stephenschafer/budget/UnresolvedItem.java create mode 100644 src/main/java/com/stephenschafer/budget/getBudgetAmounts.sql create mode 100644 src/main/resources/clearExtraDescriptions.sql create mode 100644 src/main/resources/createCategoryTable.sql create mode 100644 src/main/resources/createIfNotExistBudgetAmount.sql create mode 100644 src/main/resources/createRegexTable.sql create mode 100644 src/main/resources/createTransactionRegexTable.sql create mode 100644 src/main/resources/createTransactionTable.sql create mode 100644 src/main/resources/createYearsTable.sql create mode 100644 src/main/resources/findTable.sql create mode 100644 src/main/resources/getCategories.sql create mode 100644 src/main/resources/getChase.sql create mode 100644 src/main/resources/getChildCategoryId.sql create mode 100644 src/main/resources/getCiti.sql create mode 100644 src/main/resources/getDigitalOrders.sql create mode 100644 src/main/resources/getDigitalReturns.sql create mode 100644 src/main/resources/getDiscover.sql create mode 100644 src/main/resources/getFirstBank.sql create mode 100644 src/main/resources/getMultiplyAssignedTransactions.sql create mode 100644 src/main/resources/getPaypal.sql create mode 100644 src/main/resources/getRegexes.sql create mode 100644 src/main/resources/getRetailOrders.sql create mode 100644 src/main/resources/getRootCategoryId.sql create mode 100644 src/main/resources/getTransactionDescriptions.sql create mode 100644 src/main/resources/getTransactionDetail.sql create mode 100644 src/main/resources/getTransactionRegex.sql create mode 100644 src/main/resources/getYears.sql create mode 100644 src/main/resources/insertChildCategory.sql create mode 100644 src/main/resources/insertRegex.sql create mode 100644 src/main/resources/insertRootCategory.sql create mode 100644 src/main/resources/insertTransaction.sql create mode 100644 src/main/resources/insertTransactionRegex.sql create mode 100644 src/main/resources/insertYear.sql create mode 100644 src/main/resources/setExtraDescription.sql create mode 100644 src/main/resources/updateRegexLink.sql diff --git a/.classpath b/.classpath index 25127bf..1a4ae87 100644 --- a/.classpath +++ b/.classpath @@ -24,14 +24,6 @@ - - - - - - - - @@ -39,12 +31,21 @@ - + + + + + + - - + + + + + + diff --git a/.factorypath b/.factorypath index 38d6468..7c47bf9 100644 --- a/.factorypath +++ b/.factorypath @@ -1,74 +1,80 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs index abdea9a..29abf99 100644 --- a/.settings/org.eclipse.core.resources.prefs +++ b/.settings/org.eclipse.core.resources.prefs @@ -1,4 +1,6 @@ eclipse.preferences.version=1 encoding//src/main/java=UTF-8 encoding//src/main/resources=UTF-8 +encoding//src/test/java=UTF-8 +encoding//src/test/resources=UTF-8 encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs index 47dcc0b..cd99dd0 100644 --- a/.settings/org.eclipse.jdt.core.prefs +++ b/.settings/org.eclipse.jdt.core.prefs @@ -10,5 +10,5 @@ org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning org.eclipse.jdt.core.compiler.processAnnotations=enabled -org.eclipse.jdt.core.compiler.release=disabled +org.eclipse.jdt.core.compiler.release=enabled org.eclipse.jdt.core.compiler.source=17 diff --git a/pom.xml b/pom.xml index 66c1755..85f9d64 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,11 @@ jaxb-api 2.3.0 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + diff --git a/src/main/java/com/stephenschafer/budget/BudgetAmounts.java b/src/main/java/com/stephenschafer/budget/BudgetAmounts.java new file mode 100644 index 0000000..d110a3f --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/BudgetAmounts.java @@ -0,0 +1,23 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class BudgetAmounts { + private String year = null; + private Integer categoryId; + private BigDecimal yearBudget; + private Map monthBudgets = new HashMap<>(); + + public void setMonthBudget(final int monthNum, final BigDecimal amount) { + monthBudgets.put(Integer.valueOf(monthNum), amount); + } +} diff --git a/src/main/java/com/stephenschafer/budget/CatRegex.java b/src/main/java/com/stephenschafer/budget/CatRegex.java new file mode 100644 index 0000000..98aa0b7 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CatRegex.java @@ -0,0 +1,175 @@ +package com.stephenschafer.budget; + +import java.util.regex.Pattern; + +public class CatRegex { + private final Pattern pattern; + private final String category; + private final String source; + private final int priority; + private final String extraDescription; + private final Integer year; + private int id; + + public CatRegex(final int id, final Pattern pattern, final String category, final String source, + final int priority, final String extraDescription, final Integer year) { + this.id = id; + this.pattern = pattern; + this.category = category; + this.source = source; + this.priority = priority; + this.extraDescription = extraDescription; + this.year = year; + } + + public CatRegex(final String line) { + final String[] parts = line.split(","); + final String category = parts[0].trim(); + final String regex = parts[1].trim(); + String flagsString = ""; + String source = ""; + int priority = 0; + String extraDescription = ""; + Integer year = null; + if (parts.length > 2) { + flagsString = parts[2].trim(); + if (parts.length > 3) { + source = parts[3].trim(); + if (parts.length > 4) { + final String priorityString = parts[4].trim(); + if (priorityString.length() > 0) { + try { + priority = Integer.parseInt(priorityString); + } + catch (final NumberFormatException e) { + System.out.println( + "Invalid priority in reegex line: " + line + "
"); + } + } + if (parts.length > 5) { + extraDescription = parts[5].trim(); + if (parts.length > 6) { + final String yearString = parts[6].trim(); + if (yearString.length() > 0) { + try { + year = Integer.valueOf(yearString); + } + catch (final NumberFormatException e) { + System.out.println( + "Invalid year in reegex line: " + line + "
"); + } + } + if (parts.length > 7) { + System.out.println("Too many parts in " + line); + } + } + } + } + } + } + int flags = 0; + if (flagsString.indexOf("i") >= 0) { + flags |= Pattern.CASE_INSENSITIVE; + } + if (flagsString.indexOf("m") >= 0) { + flags |= Pattern.MULTILINE; + } + pattern = Pattern.compile(regex, flags); + this.category = category; + this.source = source.length() == 0 ? null : source; + this.priority = priority; + this.extraDescription = extraDescription; + this.year = year; + } + + public String toLine() { + final StringBuilder sb = new StringBuilder(); + sb.append(category); + sb.append(", "); + sb.append(pattern.pattern()); + sb.append(", "); + String flags = ""; + if ((pattern.flags() & Pattern.CASE_INSENSITIVE) != 0) { + flags += "i"; + } + if ((pattern.flags() & Pattern.MULTILINE) != 0) { + flags += "m"; + } + sb.append(flags); + sb.append(", "); + sb.append(source == null ? "" : source); + sb.append(", "); + sb.append(priority == 0 ? "" : String.valueOf(priority)); + sb.append(", "); + sb.append(extraDescription); + sb.append(", "); + sb.append(year == null ? "" : year.toString()); + return sb.toString(); + } + + public String getSource() { + return source; + } + + public Pattern getPattern() { + return pattern; + } + + public String getCategory() { + return category; + } + + public int getPriority() { + return priority; + } + + @Override + public int hashCode() { + int hashCode = 0; + if (source != null) { + hashCode += source.hashCode(); + } + return hashCode + pattern.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + if (obj instanceof CatRegex) { + final CatRegex that = (CatRegex) obj; + if (!source.equals(that.source) || !pattern.equals(that.pattern)) { + return false; + } + } + return true; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append(category); + sb.append(": "); + sb.append(pattern.toString()); + if (source != null && source.trim().length() > 0) { + sb.append(" (" + source + ")"); + } + sb.append(", "); + sb.append(priority); + return sb.toString(); + } + + public String getExtraDescription() { + return extraDescription; + } + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public Integer getYear() { + return year; + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/CategorizationResponse.java b/src/main/java/com/stephenschafer/budget/CategorizationResponse.java new file mode 100644 index 0000000..f4f05f2 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CategorizationResponse.java @@ -0,0 +1,17 @@ +package com.stephenschafer.budget; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class CategorizationResponse { + String year = null; + List multiplyAssignedTransactions = new ArrayList<>(); + List unassignedTransactions = new ArrayList<>(); +} diff --git a/src/main/java/com/stephenschafer/budget/CategorizerController.java b/src/main/java/com/stephenschafer/budget/CategorizerController.java new file mode 100644 index 0000000..be423d8 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CategorizerController.java @@ -0,0 +1,362 @@ +package com.stephenschafer.budget; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.StatementCallback; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +public class CategorizerController { + @Autowired + TransactionDao transactionDao; + @Autowired + UserService userService; + @Autowired + JdbcTemplate jdbcTemplate; + + @GetMapping("/problems") + @ResponseBody + private ApiResponse> getProblems( + final HttpServletRequest request) + throws SQLException, UnsupportedEncodingException, FileNotFoundException, IOException { + log.debug("GET /problems"); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final Map response = new HashMap<>(); + final Set years = getYears(); + for (final String year : years) { + final CategorizationResponse yearResponse = new CategorizationResponse(); + yearResponse.year = year; + response.put(year, yearResponse); + final String uatSql = Util.getResourceAsString("getTransactionDescriptions.sql") // + .replace("${databaseName}", "budget_" + year); + jdbcTemplate.execute((StatementCallback) stmt -> { + try (ResultSet rs = stmt.executeQuery(uatSql)) { + while (rs.next()) { + rs.getInt(1); + final java.sql.Date date = rs.getDate(2); + final String source = rs.getString(3); + final String description = rs.getString(4); + final BigDecimal tmpAmount = rs.getBigDecimal(5); + final BigDecimal amount = tmpAmount == null ? new BigDecimal(0) : tmpAmount; + final UnresolvedItem unresolvedItem = new UnresolvedItem( + Integer.parseInt(year), source, description, date, amount); + yearResponse.unassignedTransactions.add(unresolvedItem); + } + } + return null; + }); + final String matSql = Util.getResourceAsString("getMultiplyAssignedTransactions.sql") // + .replace("${databaseName}", "budget_" + year); + jdbcTemplate.execute((StatementCallback) stmt -> { + try (ResultSet rs = stmt.executeQuery(matSql)) { + final List regexes = new ArrayList<>(); + Detail detail = null; + while (rs.next()) { + final Detail lastDetail = detail; + int i = 0; + final int transId = rs.getInt(++i); + final java.sql.Date date = rs.getDate(++i); + final String source = rs.getString(++i); + final String description = rs.getString(++i); + final BigDecimal tmpAmount = rs.getBigDecimal(++i); + final BigDecimal amount = tmpAmount == null ? new BigDecimal(0) : tmpAmount; + final int regexId = rs.getInt(++i); + final int categoryId = rs.getInt(++i); + final String expression = rs.getString(++i); + final int flags = rs.getInt(++i); + final String requiredSource = rs.getString(++i); + final int priority = rs.getInt(++i); + final String extraDescription = rs.getString(++i); + final int tmpRequiredYear = rs.getInt(++i); + final Integer requiredYear = rs.wasNull() ? null + : Integer.valueOf(tmpRequiredYear); + final String fqCategoryName = null; + final Regex regex = new Regex(regexId, categoryId, expression, flags, + requiredSource, priority, extraDescription, requiredYear); + final RegexDisplay regexDisplay = new RegexDisplay(regex, fqCategoryName); + regexes.add(regexDisplay); + detail = new Detail(transId, source, description, date, amount); + if (lastDetail != null + && lastDetail.getTransactionId() != detail.getTransactionId()) { + final MultiplyAssignedTransaction mat = new MultiplyAssignedTransaction(); + mat.setRegexes(regexes); + mat.setTransaction(lastDetail); + yearResponse.multiplyAssignedTransactions.add(mat); + regexes.clear(); + } + } + if (detail != null) { + final MultiplyAssignedTransaction mat = new MultiplyAssignedTransaction(); + mat.setRegexes(regexes); + mat.setTransaction(detail); + yearResponse.multiplyAssignedTransactions.add(mat); + } + } + return null; + }); + } + return new ApiResponse<>(HttpStatus.OK.value(), "Problems fetched successfully", response); + } + + @GetMapping("/categorize") + @ResponseBody + private ApiResponse> categorize( + final HttpServletRequest request) + throws SQLException, UnsupportedEncodingException, FileNotFoundException, IOException { + log.info("GET /categorize"); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final Map response = new HashMap<>(); + final Map> categoryMap = new HashMap<>(); + final Set years = getYears(); + final List catRegexes = getCatRegexes(); + final Set unresolvedSet = new HashSet<>(); + for (final String year : years) { + final CategorizationResponse yearResponse = new CategorizationResponse(); + yearResponse.year = year; + response.put(year, yearResponse); + final Map extraDescriptionMap = new HashMap<>(); + final Map linkedRegexes = new HashMap<>(); + jdbcTemplate.execute(Util.getResourceAsString("clearExtraDescriptions.sql") // + .replace("${databaseName}", "budget_" + year)); + jdbcTemplate.execute("drop table if exists ${databaseName}.transaction_regex_mtm" // + .replace("${databaseName}", "budget_" + year)); + jdbcTemplate.execute(Util.getResourceAsString("createTransactionRegexTable.sql") // + .replace("${databaseName}", "budget_" + year)); + final String sql = Util.getResourceAsString("getTransactionDescriptions.sql") // + .replace("${databaseName}", "budget_" + year); + final String insertSql = Util.getResourceAsString("insertTransactionRegex.sql") // + .replace("${databaseName}", "budget_" + year); + jdbcTemplate.execute((StatementCallback) stmt -> { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + final int transactionId = rs.getInt(1); + final java.sql.Date date = rs.getDate(2); + final String source = rs.getString(3); + final String description = rs.getString(4); + final BigDecimal tmpAmount = rs.getBigDecimal(5); + final BigDecimal amount = tmpAmount == null ? new BigDecimal(0) : tmpAmount; + final Detail detail = new Detail(transactionId, source, description, date, + amount); + if (description != null) { + int maxPriority = Integer.MIN_VALUE; + final Map> matchesFound = new HashMap<>(); + for (final CatRegex catRegex : catRegexes) { + final String requiredSource = catRegex.getSource(); + if (requiredSource == null || requiredSource.equals(source)) { + final Integer requiredYear = catRegex.getYear(); + if (requiredYear == null + || requiredYear.equals(Integer.valueOf(year))) { + final Pattern pattern = catRegex.getPattern(); + final Matcher matcher = pattern.matcher(description); + final String category = catRegex.getCategory(); + if (matcher.matches()) { + final int priority = catRegex.getPriority(); + if (priority > maxPriority) { + maxPriority = priority; + final int regexId = catRegex.getId(); + linkedRegexes.put(transactionId, regexId); + } + Set set = matchesFound.get(category); + if (set == null) { + set = new HashSet<>(); + matchesFound.put(category, set); + } + set.add(catRegex); + } + } + } + } + if (matchesFound.isEmpty()) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(date); + if (cal.get(Calendar.YEAR) > 2023) { + final UnresolvedItem unresolvedItem = new UnresolvedItem( + Integer.parseInt(year), source, description, date, + amount); + unresolvedSet.add(unresolvedItem); + yearResponse.unassignedTransactions.add(unresolvedItem); + } + } + else if (matchesFound.size() > 1) { + // more than one category matches this description + // find the one(s) with the highest priority + final Map> actualMatchesFound = new HashMap<>(); + for (final String category : matchesFound.keySet()) { + final Set set = matchesFound.get(category); + for (final CatRegex catRegex : set) { + if (catRegex.getPriority() == maxPriority) { + Set newSet = actualMatchesFound.get(category); + if (newSet == null) { + newSet = new HashSet<>(); + actualMatchesFound.put(category, newSet); + } + newSet.add(catRegex); + } + } + } + if (actualMatchesFound.size() > 1) { + // more than one category matches this description and also has the max priority + // print to HTML so user can make the regex more precise + final MultiplyAssignedTransaction multiplyAssignedTransaction = new MultiplyAssignedTransaction(); + multiplyAssignedTransaction.transaction = detail; + yearResponse.multiplyAssignedTransactions.add( + multiplyAssignedTransaction); + for (final String category : actualMatchesFound.keySet()) { + final Set newSet = actualMatchesFound.get( + category); + for (final CatRegex catRegex : newSet) { + multiplyAssignedTransaction.regexes.add( + new RegexDisplay(catRegex)); + jdbcTemplate.update(insertSql, catRegex.getId(), + transactionId); + } + } + } + else { + addTransactionToCategory(year, actualMatchesFound, categoryMap, + extraDescriptionMap, detail); + } + } + else { + addTransactionToCategory(year, matchesFound, categoryMap, + extraDescriptionMap, detail); + } + } + } + } + return null; + }); + for (final Integer transactionId : extraDescriptionMap.keySet()) { + final String extraDescription = extraDescriptionMap.get(transactionId); + final String setSql = Util.getResourceAsString("setExtraDescription.sql") // + .replace("${databaseName}", "budget_" + year); + jdbcTemplate.update(setSql, extraDescription, transactionId); + } + for (final Integer transactionId : linkedRegexes.keySet()) { + final Integer regexId = linkedRegexes.get(transactionId); + final String linkRegexSql = Util.getResourceAsString("updateRegexLink.sql") // + .replace("${databaseName}", "budget_" + year); + try { + System.out.println("updating year " + year + ", trans " + transactionId + + " with regex id " + regexId); + jdbcTemplate.update(linkRegexSql, regexId, transactionId); + } + catch (final Exception e) { + e.printStackTrace(); + } + } + } + return new ApiResponse<>(HttpStatus.OK.value(), "Categorization fetched successfully", + response); + } + + void addTransactionToCategory(final String year, final Map> matchesFound, + final Map> categoryMap, + final Map extraDescriptionMap, final Detail detail) { + final StringBuilder sb = new StringBuilder(); + String sep = ""; + for (final String category : matchesFound.keySet()) { + final Set regexSet = matchesFound.get(category); + List details = categoryMap.get(category); + if (details == null) { + details = new ArrayList<>(); + categoryMap.put(category, details); + } + details.add(detail); + for (final CatRegex regex : regexSet) { + sb.append(sep); + sep = ", "; + sb.append(regex.getExtraDescription()); + } + } + extraDescriptionMap.put(detail.transactionId, sb.toString()); + } + + Set getYears() throws IOException, SQLException { + final String sql = Util.getResourceAsString("getYears.sql") // + .replace("${databaseName}", "budget"); + final List list = jdbcTemplate.queryForList(sql, Integer.class); + final Set result = new HashSet<>(); + for (final Integer year : list) { + result.add(year.toString()); + } + return result; + } + + Map loadCategories() throws IOException, SQLException { + final Map categories = new HashMap<>(); + final String sql = Util.getResourceAsString("getCategories.sql") // + .replace("${databaseName}", "budget"); + final List categoryList = jdbcTemplate.query(sql, + (RowMapper) (rs, rowNum) -> { + final int id = rs.getInt(1); + final int parentId = rs.getInt(2); + final String name = rs.getString(3); + return new Category(id, parentId, name); + }); + for (final Category category : categoryList) { + categories.put(Integer.valueOf(category.id), category); + } + return categories; + } + + List getCatRegexes() throws IOException, SQLException { + final Map categories = loadCategories(); + final String sql = Util.getResourceAsString("getRegexes.sql") // + .replace("${databaseName}", "budget"); + final List catRegexes = jdbcTemplate.query(sql, + (RowMapper) (resultSet, rowNum) -> { + int i = 0; + final int id = resultSet.getInt(++i); + final int categoryId = resultSet.getInt(++i); + final String regex = resultSet.getString(++i); + final int flags = resultSet.getInt(++i); + final String source = resultSet.getString(++i); + final int priority = resultSet.getInt(++i); + final String description = resultSet.getString(++i); + final int yearInt = resultSet.getInt(++i); + final Integer year = resultSet.wasNull() ? null : Integer.valueOf(yearInt); + final Pattern pattern = Pattern.compile(regex, flags); + final Category category = categories.get(categoryId); + final String categoryName = category == null ? null + : category.getFullName(categories); + final CatRegex catRegex = new CatRegex(id, pattern, categoryName, source, priority, + description, year); + return catRegex; + }); + return catRegexes; + } +} diff --git a/src/main/java/com/stephenschafer/budget/Category.java b/src/main/java/com/stephenschafer/budget/Category.java index 24c2992..96be2d1 100644 --- a/src/main/java/com/stephenschafer/budget/Category.java +++ b/src/main/java/com/stephenschafer/budget/Category.java @@ -1,5 +1,7 @@ package com.stephenschafer.budget; +import java.util.Map; + import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -17,6 +19,19 @@ public class Category { this(intValue, category.getParentCategoryId(), category.getName()); } + String getFullName(final Map categories) { + final StringBuilder sb = new StringBuilder(); + if (parentCategoryId != null) { + final Category parentCategory = categories.get(parentCategoryId); + if (parentCategory != null) { + sb.append(parentCategory.getFullName(categories)); + sb.append("."); + } + } + sb.append(name); + return sb.toString(); + } + Integer id; Integer parentCategoryId; String name; diff --git a/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java b/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java index 798e387..3d474ad 100644 --- a/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java +++ b/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java @@ -79,7 +79,7 @@ public class CategoryDaoImpl implements CategoryDao { @Override public List getCategoryAncestry(final int categoryId) { - return getCategoryAncestry(null, categoryId); + return getCategoryAncestry(new ArrayList<>(), categoryId); } private List getCategoryAncestry(final List ancestry, diff --git a/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java b/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java index 14117b9..8e4f3dc 100644 --- a/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java +++ b/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java @@ -13,7 +13,8 @@ public class CustomCorsConfiguration implements CorsConfigurationSource { @Override public CorsConfiguration getCorsConfiguration(final HttpServletRequest request) { final CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedOrigins( + List.of("http://localhost:3000", "http://localhost:3001", "http://kirk:3001")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); return config; diff --git a/src/main/java/com/stephenschafer/budget/Detail.java b/src/main/java/com/stephenschafer/budget/Detail.java new file mode 100644 index 0000000..93ce835 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Detail.java @@ -0,0 +1,21 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +class Detail { + int transactionId; + String source; + String description; + Date date; + BigDecimal amount; +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java b/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java index d69a4de..0d35536 100644 --- a/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java +++ b/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java @@ -19,8 +19,8 @@ import io.jsonwebtoken.SignatureAlgorithm; @Component public class JwtTokenUtil implements Serializable { - @Serial - private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; public String getUsernameFromToken(final String token) { return getClaimFromToken(token, Claims::getSubject); @@ -51,11 +51,13 @@ public class JwtTokenUtil implements Serializable { private String doGenerateToken(final String subject) { final Claims claims = Jwts.claims().setSubject(subject); claims.put("scopes", Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"))); - return Jwts.builder().setClaims(claims).setIssuer("http://stephenschafer.com").setIssuedAt( - new Date(System.currentTimeMillis())).setExpiration( - new Date(System.currentTimeMillis() - + ACCESS_TOKEN_VALIDITY_SECONDS * 1000L)).signWith(SignatureAlgorithm.HS256, - SIGNING_KEY).compact(); + return Jwts.builder() // + .setClaims(claims) // + .setIssuer("http://stephenschafer.com") // + .setIssuedAt(new Date(System.currentTimeMillis())) // + .setExpiration( + new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY_SECONDS * 1000L)) // + .signWith(SignatureAlgorithm.HS256, SIGNING_KEY).compact(); } public Boolean validateToken(final String token, final UserDetails userDetails) { diff --git a/src/main/java/com/stephenschafer/budget/MultiplyAssignedTransaction.java b/src/main/java/com/stephenschafer/budget/MultiplyAssignedTransaction.java new file mode 100644 index 0000000..3c1b484 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/MultiplyAssignedTransaction.java @@ -0,0 +1,16 @@ +package com.stephenschafer.budget; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class MultiplyAssignedTransaction { + Detail transaction = null; + List regexes = new ArrayList<>(); +} diff --git a/src/main/java/com/stephenschafer/budget/RegexController.java b/src/main/java/com/stephenschafer/budget/RegexController.java index cde146c..1ff1ca4 100644 --- a/src/main/java/com/stephenschafer/budget/RegexController.java +++ b/src/main/java/com/stephenschafer/budget/RegexController.java @@ -7,6 +7,7 @@ import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -90,6 +91,27 @@ public class RegexController { return new ApiResponse<>(HttpStatus.OK.value(), "Regex retrieved successfully", regex); } + @DeleteMapping("/regex/{regexId}") + @ResponseBody + public ApiResponse deleteRegex(@PathVariable(required = true) final Integer regexId, + final HttpServletRequest request) { + log.info("DELETE /regex/" + regexId); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + if (regexId == null) { + return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Regex ID not specified", + null); + } + final Optional optionalRegex = regexDao.getById(regexId.intValue()); + if (!optionalRegex.isPresent()) { + return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Regex ID not found", null); + } + regexDao.deleteById(regexId.intValue()); + return new ApiResponse<>(HttpStatus.OK.value(), "Regex deleted successfully", null); + } + @GetMapping("/regexes/category/{categoryName}") @ResponseBody public ApiResponse> getRegexesByCategory( diff --git a/src/main/java/com/stephenschafer/budget/RegexDisplay.java b/src/main/java/com/stephenschafer/budget/RegexDisplay.java index 9c9e327..cc66f48 100644 --- a/src/main/java/com/stephenschafer/budget/RegexDisplay.java +++ b/src/main/java/com/stephenschafer/budget/RegexDisplay.java @@ -15,6 +15,13 @@ public class RegexDisplay { regex.priority, regex.description, regex.year); } + public RegexDisplay(final CatRegex catRegex) { + this(catRegex.getId(), (Integer) null, catRegex.getCategory(), + catRegex.getPattern().pattern(), catRegex.getPattern().flags(), + catRegex.getSource(), catRegex.getPriority(), catRegex.getExtraDescription(), + catRegex.getYear()); + } + Integer id; Integer categoryId; String fqCategoryName; diff --git a/src/main/java/com/stephenschafer/budget/Report.java b/src/main/java/com/stephenschafer/budget/Report.java new file mode 100644 index 0000000..768aa53 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Report.java @@ -0,0 +1,16 @@ +package com.stephenschafer.budget; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class Report { + String year = null; + int monthCount = 0; + ReportCategory rootCategory = null; +} diff --git a/src/main/java/com/stephenschafer/budget/ReportCategory.java b/src/main/java/com/stephenschafer/budget/ReportCategory.java new file mode 100644 index 0000000..5f91022 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/ReportCategory.java @@ -0,0 +1,232 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class ReportCategory { + private final Integer id; + private final Integer parentId; + private final String name; + @JsonIgnore + private ReportCategory parent; + private boolean included; + private List details = new ArrayList<>(); + private Map monthTotals = null; + private Map monthGrandTotals = null; + @JsonIgnore + private final Map children = new HashMap<>(); + private final int monthCount; + private final BudgetAmounts budgetAmounts = new BudgetAmounts(); + + public ReportCategory(final Integer id, final Integer parentId, final String name, + final int monthCount) { + this.id = id; + this.parentId = parentId; + this.name = name; + this.monthCount = monthCount; + } + + public void updateParent(final Map categories) { + if (parentId == null) { + parent = null; + } + else { + parent = categories.get(parentId); + parent.addChild(this); + } + } + + public void addChild(final ReportCategory category) { + children.put(category.id, category); + } + + @JsonIgnore + public ReportCategory getParent() { + return parent; + } + + public void setParent(final ReportCategory parent) { + this.parent = parent; + } + + public Integer getId() { + return id; + } + + public Integer getParentId() { + return parentId; + } + + public String getName() { + return name; + } + + public String getQualifiedName() { + final StringBuilder sb = new StringBuilder(); + if (parent != null) { + sb.append(parent.getQualifiedName()); + sb.append("."); + } + sb.append(name); + return sb.toString(); + } + + public List getDetails() { + return details; + } + + public void setDetails(final List details) { + this.details = details; + } + + public void addDetail(final ReportDetail detail) { + details.add(detail); + } + + public Map getChildren() { + return children; + } + + public BigDecimal getDetailTotal() { + var amount = new BigDecimal(0); + for (final ReportDetail detail : details) { + amount = amount.add(detail.amount); + } + return amount; + } + + public BigDecimal getMonthTotal(final int month) { + var amount = new BigDecimal(0); + for (final ReportDetail detail : details) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(detail.date); + if (month == cal.get(Calendar.MONTH)) { + amount = amount.add(detail.amount); + } + } + return amount; + } + + public Map getMonthTotals() { + Map monthTotals = this.monthTotals; + if (monthTotals == null) { + monthTotals = new HashMap<>(); + for (final ReportDetail detail : details) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(detail.date); + final int month = cal.get(Calendar.MONTH); + BigDecimal monthTotal = monthTotals.get(month); + if (monthTotal == null) { + monthTotal = new BigDecimal(0); + } + monthTotals.put(month, monthTotal.add(detail.amount)); + } + this.monthTotals = monthTotals; + } + return monthTotals; + } + + public BigDecimal getGrandTotal() { + var total = getDetailTotal(); + for (final Integer categoryId : children.keySet()) { + final var category = children.get(categoryId); + total = total.add(category.getGrandTotal()); + } + return total; + } + + public BigDecimal getGrandAverage() { + final BigDecimal grandTotal = getGrandTotal(); + if (monthCount == 0) { + return new BigDecimal(0); + } + return grandTotal.divide(new BigDecimal(monthCount), RoundingMode.HALF_DOWN); + } + + public Map getMonthGrandTotals() { + Map monthGrandTotals = this.monthGrandTotals; + if (monthGrandTotals == null) { + monthGrandTotals = this.getMonthTotals(); + for (final Integer categoryId : children.keySet()) { + final var childCategory = children.get(categoryId); + final Map childMonthGrandTotals = childCategory.getMonthGrandTotals(); + for (final Integer monthNum : childMonthGrandTotals.keySet()) { + final BigDecimal childMonthGrandTotal = childMonthGrandTotals.get(monthNum); + if (childMonthGrandTotal != null) { + final BigDecimal previousValue = monthGrandTotals.containsKey(monthNum) + ? monthGrandTotals.get(monthNum) + : new BigDecimal(0); + monthGrandTotals.put(monthNum, previousValue.add(childMonthGrandTotal)); + } + } + } + this.monthGrandTotals = monthGrandTotals; + } + return monthGrandTotals; + } + + public int getLargestMonth() { + final Map monthGrandTotals = this.getMonthGrandTotals(); + BigDecimal max = new BigDecimal(0); + int maxMonth = -1; + int maxCount = 0; + for (int month = 0; month < 12; month++) { + BigDecimal monthAmount = monthGrandTotals.get(month); + if (monthAmount == null) { + monthAmount = new BigDecimal(0); + } + if (monthAmount.compareTo(max) == 0) { + maxCount++; + } + else if (monthAmount.compareTo(max) > 0) { + max = monthAmount; + maxMonth = month; + maxCount = 1; + } + } + return maxCount > 1 ? -1 : maxMonth; + } + + public List getChildCategories() { + final List categories = new ArrayList<>(); + for (final Integer categoryId : children.keySet()) { + final var category = children.get(categoryId); + categories.add(category); + } + categories.sort((arg0, arg1) -> { + final var name0 = arg0.getName(); + final var name1 = arg1.getName(); + final var comparison = name0.compareTo(name1); + if (comparison != 0) { + return comparison; + } + final var id0 = arg0.getId(); + final var id1 = arg1.getId(); + return id0.compareTo(id1); + }); + return categories; + } + + public boolean isIncluded() { + return included; + } + + public void setIncluded(final boolean include) { + this.included = include; + } +} diff --git a/src/main/java/com/stephenschafer/budget/ReportController.java b/src/main/java/com/stephenschafer/budget/ReportController.java new file mode 100644 index 0000000..f6f8e48 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/ReportController.java @@ -0,0 +1,272 @@ +package com.stephenschafer.budget; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowCallbackHandler; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +public class ReportController { + @Autowired + TransactionDao transactionDao; + @Autowired + UserService userService; + @Autowired + JdbcTemplate jdbcTemplate; + + @PutMapping("/budget") + @ResponseBody + public ApiResponse putBudget(@RequestBody final Map reports, + final HttpServletRequest request) { + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + categoryDao.update(report); + return new ApiResponse<>(HttpStatus.OK.value(), "Category updated successfully", report); + } + + @GetMapping("/report") + @ResponseBody + private ApiResponse> report( + @RequestParam(required = false) List years, + @RequestParam(required = false, name = "startDate") final String startDateString, + @RequestParam(required = false, name = "endDate") final String endDateString, + @RequestParam(required = false) final List includes, + final HttpServletRequest request) + throws SQLException, UnsupportedEncodingException, FileNotFoundException, IOException { + log.info("GET /report"); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + if (years == null) { + years = getYears(); + } + var tmpIncludeExpenses = true; + var tmpIncludeIncome = true; + var tmpIncludePayments = false; + var tmpIncludeInvestments = false; + if (includes != null && !includes.isEmpty()) { + tmpIncludeExpenses = false; + tmpIncludeIncome = false; + tmpIncludePayments = false; + tmpIncludeInvestments = false; + for (final String include : includes) { + if ("expenses".equalsIgnoreCase(include)) { + tmpIncludeExpenses = true; + } + else if ("income".equalsIgnoreCase(include)) { + tmpIncludeIncome = true; + } + else if ("payments".equalsIgnoreCase(include)) { + tmpIncludePayments = true; + } + else if ("investments".equalsIgnoreCase(include)) { + tmpIncludeInvestments = true; + } + } + } + final var includeExpenses = tmpIncludeExpenses; + final var includeIncome = tmpIncludeIncome; + final var includePayments = tmpIncludePayments; + final var includeInvestments = tmpIncludeInvestments; + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + final Date startDate; + final Date endDate; + try { + startDate = startDateString == null ? null : df.parse(startDateString); + endDate = endDateString == null ? null : df.parse(endDateString); + } + catch (final ParseException e) { + return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Invalid date", null); + } + final int startMonth; + if (startDate != null) { + final Calendar startCal = new GregorianCalendar(); + startCal.setTime(startDate); + startMonth = startCal.get(Calendar.MONTH); + } + else { + startMonth = 0; + } + final int endMonth; + if (endDate != null) { + final Calendar endCal = new GregorianCalendar(); + endCal.setTime(endDate); + endMonth = endCal.get(Calendar.MONTH); + } + else { + endMonth = 11; + } + final int monthCount = endMonth - startMonth + 1; + final Map response = new HashMap<>(); + for (final String year : years) { + final var categoryMap = loadCategories(year, includeIncome, includePayments, + includeInvestments, includeExpenses, monthCount); + final String createBudgetTableSql = Util.getResourceAsString( + "createIfNotExistBudgetAmount.sql"); + jdbcTemplate.update(createBudgetTableSql); + final String getBudgetAmountsSql = Util.getResourceAsString("getBudgetAmounts.sql"); + jdbcTemplate.query(getBudgetAmountsSql, new Object[] {}, new int[] {}, + (RowCallbackHandler) rs -> { + var i = 0; + final int categoryId = rs.getInt(++i); + final var category = categoryMap.get(categoryId); + if (category != null) { + final BudgetAmounts budgetAmounts = category.getBudgetAmounts(); + budgetAmounts.setCategoryId(categoryId); + budgetAmounts.setYear(year); + budgetAmounts.setYearBudget(rs.getBigDecimal(++i)); + for (int monthIndex = 0; monthIndex < 12; monthIndex++) { + budgetAmounts.setMonthBudget(monthIndex, rs.getBigDecimal(++i)); + } + } + }); + final String sql = Util.getResourceAsString("getTransactionDetail.sql") // + .replace("${databaseName}", "budget_" + year) // + .replace("${regexDatabaseName}", "budget"); + jdbcTemplate.query(sql, new Object[] {}, new int[] {}, (RowCallbackHandler) rs -> { + final var detail = new ReportDetail(); + var i = 0; + detail.transactionId = rs.getInt(++i); + detail.date = rs.getDate(++i); + detail.source = rs.getString(++i); + detail.description = rs.getString(++i); + detail.amount = rs.getBigDecimal(++i); + if (rs.wasNull()) { + detail.amount = new BigDecimal(0); + } + final var categoryId = rs.getInt(++i); + detail.regex = rs.getString(++i); + detail.flags = rs.getInt(++i); + detail.requiredSource = rs.getString(++i); + detail.extraDescription = rs.getString(++i); + final var category = categoryMap.get(categoryId); + if (category != null && // + detail.isAfter(startDate) && // + detail.isBefore(endDate) && // + category.isIncluded()) { + category.addDetail(detail); + } + }); + final var rootCategory = new ReportCategory(-1, null, "total", monthCount); + for (final Integer categoryId : categoryMap.keySet()) { + final var category = categoryMap.get(categoryId); + if ((category != null) && (category.getParent() == null)) { + rootCategory.addChild(category); + } + } + final Report report = new Report(year, monthCount, rootCategory); + response.put(year, report); + } + return new ApiResponse<>(HttpStatus.OK.value(), "Report fetched successfully", response); + } + + List getYears() throws IOException, SQLException { + final String sql = Util.getResourceAsString("getYears.sql") // + .replace("${databaseName}", "budget"); + final List list = jdbcTemplate.queryForList(sql, Integer.class); + final List result = new ArrayList<>(); + for (final Integer year : list) { + result.add(year.toString()); + } + return result; + } + + interface CategoryFilter { + boolean include(ReportCategory category); + } + + private Map loadCategories(final String year, + final boolean includeIncome, final boolean includePayments, + final boolean includeInvestments, final boolean includeExpenses, final int monthCount) + throws SQLException, IOException { + ReportCategory unknownCategory = null; + final Map categories = new HashMap<>(); + final var sql = Util.getResourceAsString("getCategories.sql").replace("${databaseName}", + "budget"); + final List list = jdbcTemplate.query(sql, + (RowMapper) (rs, rowNum) -> { + final Integer id = rs.getInt(1); + Integer parentId = rs.getInt(2); + if (rs.wasNull()) { + parentId = null; + } + final var name = rs.getString(3); + final var category = new ReportCategory(id, parentId, name, monthCount); + return category; + }); + for (final ReportCategory category : list) { + categories.put(category.getId(), category); + if ("unknown".equals(category.getName()) && category.getParentId() == null) { + unknownCategory = category; + } + } + for (final Integer categoryId : categories.keySet()) { + final var category = categories.get(categoryId); + category.updateParent(categories); + } + if (unknownCategory == null) { + unknownCategory = new ReportCategory(-1, null, "unknown", monthCount); + categories.put(unknownCategory.getId(), unknownCategory); + } + for (final Integer categoryId : categories.keySet()) { + final var category = categories.get(categoryId); + final CategoryFilter filter = category1 -> { + final String categoryName = category1.getName(); + if ("income".equals(categoryName) || "refunds".equals(categoryName)) { + if (!includeIncome) { + return false; + } + } + else if ("payment".equals(categoryName)) { + if (!includePayments) { + return false; + } + } + else if ("investment".equals(categoryName)) { + if (!includeInvestments) { + return false; + } + } + else if (!includeExpenses) { + return false; + } + return true; + }; + final boolean included = filter.include(category); + category.setIncluded(included); + } + return categories; + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/ReportDetail.java b/src/main/java/com/stephenschafer/budget/ReportDetail.java new file mode 100644 index 0000000..eb6a8c4 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/ReportDetail.java @@ -0,0 +1,80 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.util.Date; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +class ReportDetail implements Comparable { + int transactionId; + String source; + String description; + Date date; + BigDecimal amount; + String regex; + int flags; + String requiredSource; + String extraDescription; + + @Override + public int compareTo(final ReportDetail arg1) { + final var arg0 = this; + int comparison; + if (arg0.date == null) { + if (arg1.date != null) { + return -1; + } + } + else if (arg1.date == null) { + return 1; + } + else { + comparison = arg0.date.compareTo(arg1.date); + if (comparison != 0) { + return comparison; + } + } + if (arg0.source == null) { + if (arg1.source != null) { + return -1; + } + } + else if (arg1.source == null) { + return 1; + } + else { + comparison = arg0.source.compareTo(arg1.source); + if (comparison != 0) { + return comparison; + } + } + if (arg0.description == null) { + if (arg1.description != null) { + return -1; + } + } + else if (arg1.description == null) { + return 1; + } + else { + comparison = arg0.description.compareTo(arg1.description); + if (comparison != 0) { + return comparison; + } + } + return 0; + } + + public boolean isAfter(final Date startDate) { + return startDate == null || startDate.getTime() <= this.date.getTime(); + } + + public boolean isBefore(final Date endDate) { + return endDate == null || endDate.getTime() > this.date.getTime(); + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/UnresolvedItem.java b/src/main/java/com/stephenschafer/budget/UnresolvedItem.java new file mode 100644 index 0000000..d94f00a --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UnresolvedItem.java @@ -0,0 +1,61 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DateFormat; +import java.text.SimpleDateFormat; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +class UnresolvedItem implements Comparable { + private final int year; + private final String source; + private final String description; + private final java.sql.Date date; + private final BigDecimal amount; + + @Override + public int compareTo(final UnresolvedItem that) { + if (this.year < that.year) { + return -1; + } + if (this.year > that.year) { + return 1; + } + String thisSource = source; + if (thisSource == null) { + thisSource = ""; + } + String thatSource = that.source; + if (thatSource == null) { + thatSource = ""; + } + final int comparison = thisSource.compareTo(thatSource); + if (comparison != 0) { + return comparison; + } + String thisdescription = description; + if (thisdescription == null) { + thisdescription = ""; + } + String thatdescription = that.description; + if (thatdescription == null) { + thatdescription = ""; + } + return thisdescription.compareTo(thatdescription); + } + + public String replace(final String string) { + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + return string.replace("${year}", String.valueOf(year)).replace("${source}", source).replace( + "${description}", description).replace("${date}", df.format(date)).replace("${amount}", + amount.setScale(2, RoundingMode.HALF_UP).toPlainString()); + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/Util.java b/src/main/java/com/stephenschafer/budget/Util.java index 283c36d..39883f2 100644 --- a/src/main/java/com/stephenschafer/budget/Util.java +++ b/src/main/java/com/stephenschafer/budget/Util.java @@ -1,4 +1,22 @@ package com.stephenschafer.budget; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; + public class Util { + public static String getResourceAsString(final String resourceName) throws IOException { + final ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + final StringBuilder sb = new StringBuilder(); + try (final Reader reader = new InputStreamReader( + classLoader.getResourceAsStream(resourceName))) { + final char[] buffer = new char[0x1000]; + int charsRead = reader.read(buffer); + while (charsRead >= 0) { + sb.append(buffer, 0, charsRead); + charsRead = reader.read(buffer); + } + } + return sb.toString(); + } } diff --git a/src/main/java/com/stephenschafer/budget/getBudgetAmounts.sql b/src/main/java/com/stephenschafer/budget/getBudgetAmounts.sql new file mode 100644 index 0000000..a2348ac --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/getBudgetAmounts.sql @@ -0,0 +1,3 @@ +select category_id, year_amount, jan_amount, feb_amount, mar_amount, apr_amount, may_amount, jun_amount, + jul_amount, aug_amount, sep_amount, oct_amount, nov_amount, dec_amount + from ${databaseName}.budget_amount \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 04515c7..21c58ec 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,3 +18,6 @@ spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.Ph #server.ssl.key-alias=timesheet #server.ssl.key-password=foobar #server.ssl.enabled=true + +# this is necessary to see sql exceptions +logging.level.com.stephenschafer.budget=DEBUG \ No newline at end of file diff --git a/src/main/resources/clearExtraDescriptions.sql b/src/main/resources/clearExtraDescriptions.sql new file mode 100644 index 0000000..d756d27 --- /dev/null +++ b/src/main/resources/clearExtraDescriptions.sql @@ -0,0 +1 @@ +update ${databaseName}.transaction set extra_description = '', regex_id = null \ No newline at end of file diff --git a/src/main/resources/createCategoryTable.sql b/src/main/resources/createCategoryTable.sql new file mode 100644 index 0000000..4c6013f --- /dev/null +++ b/src/main/resources/createCategoryTable.sql @@ -0,0 +1,6 @@ +create table ${databaseName}.category ( + id int not null primary key auto_increment, + parent_category_id int, + name text not null, + constraint unique key (parent_category_id, name) +) diff --git a/src/main/resources/createIfNotExistBudgetAmount.sql b/src/main/resources/createIfNotExistBudgetAmount.sql new file mode 100644 index 0000000..4f347b3 --- /dev/null +++ b/src/main/resources/createIfNotExistBudgetAmount.sql @@ -0,0 +1,16 @@ +create table if not exists ${databaseName}.budget_amount ( + category_id int not null primary key, + year_amount decimal(10,2), + jan_amount decimal(10,2), + feb_amount decimal(10,2), + mar_amount decimal(10,2), + apr_amount decimal(10,2), + may_amount decimal(10,2), + jun_amount decimal(10,2), + jul_amount decimal(10,2), + aug_amount decimal(10,2), + sep_amount decimal(10,2), + oct_amount decimal(10,2), + nov_amount decimal(10,2), + dec_amount decimal(10,2) +) diff --git a/src/main/resources/createRegexTable.sql b/src/main/resources/createRegexTable.sql new file mode 100644 index 0000000..afb364a --- /dev/null +++ b/src/main/resources/createRegexTable.sql @@ -0,0 +1,10 @@ +create table ${databaseName}.regex ( + id int not null primary key auto_increment, + category_id int, + regex text not null, + flags int, + source varchar(32), + priority int, + description text, + year int +) diff --git a/src/main/resources/createTransactionRegexTable.sql b/src/main/resources/createTransactionRegexTable.sql new file mode 100644 index 0000000..987e91d --- /dev/null +++ b/src/main/resources/createTransactionRegexTable.sql @@ -0,0 +1,5 @@ +create table ${databaseName}.transaction_regex_mtm ( + regex_id int not null, + transaction_id int not null, + primary key (transaction_id, regex_id) +) \ No newline at end of file diff --git a/src/main/resources/createTransactionTable.sql b/src/main/resources/createTransactionTable.sql new file mode 100644 index 0000000..3e8974e --- /dev/null +++ b/src/main/resources/createTransactionTable.sql @@ -0,0 +1,12 @@ +create table ${databaseName}.transaction ( + id int not null primary key auto_increment, + source varchar(32), + unique_identifier varchar(64), + type varchar(64), + description text, + extra_description text, + date date, + amount decimal(10,2), + optional int default 0, + regex_id int +) diff --git a/src/main/resources/createYearsTable.sql b/src/main/resources/createYearsTable.sql new file mode 100644 index 0000000..d0a14cd --- /dev/null +++ b/src/main/resources/createYearsTable.sql @@ -0,0 +1,3 @@ +create table ${databaseName}.years ( + year int not null primary key +) diff --git a/src/main/resources/findTable.sql b/src/main/resources/findTable.sql new file mode 100644 index 0000000..51345d8 --- /dev/null +++ b/src/main/resources/findTable.sql @@ -0,0 +1 @@ +select table_rows from information_schema.tables where table_schema = ? and table_name = ? \ No newline at end of file diff --git a/src/main/resources/getCategories.sql b/src/main/resources/getCategories.sql new file mode 100644 index 0000000..b584f9e --- /dev/null +++ b/src/main/resources/getCategories.sql @@ -0,0 +1,5 @@ +select +id, +parent_category_id, +name +from ${databaseName}.category \ No newline at end of file diff --git a/src/main/resources/getChase.sql b/src/main/resources/getChase.sql new file mode 100644 index 0000000..a89c2c7 --- /dev/null +++ b/src/main/resources/getChase.sql @@ -0,0 +1,8 @@ +select +transaction_date, +description, +category, +type, +amount +from ${databaseName}.chase + diff --git a/src/main/resources/getChildCategoryId.sql b/src/main/resources/getChildCategoryId.sql new file mode 100644 index 0000000..57b7dd7 --- /dev/null +++ b/src/main/resources/getChildCategoryId.sql @@ -0,0 +1 @@ +select id from ${databaseName}.category where name = ? and parent_category_id = ? \ No newline at end of file diff --git a/src/main/resources/getCiti.sql b/src/main/resources/getCiti.sql new file mode 100644 index 0000000..db1e756 --- /dev/null +++ b/src/main/resources/getCiti.sql @@ -0,0 +1,7 @@ +select +status, +date, +description, +debit, +credit +from ${databaseName}.citi \ No newline at end of file diff --git a/src/main/resources/getDigitalOrders.sql b/src/main/resources/getDigitalOrders.sql new file mode 100644 index 0000000..32a6b1e --- /dev/null +++ b/src/main/resources/getDigitalOrders.sql @@ -0,0 +1,15 @@ +select +m.monetary_component_type_code as type, +i.${productNameCol} as name, +i.digital_order_item_id as order_id, +i.order_date as date, +sum(m.transaction_amount) as amount +from ${databaseName}.${digOrdItems} i +left outer join ${databaseName}.${digOrders} o on o.order_id = i.order_id +left outer join ${databaseName}.${digOrdersMonetary} m on m.digital_order_item_id = i.digital_order_item_id +group by +i.${productNameCol}, +i.digital_order_item_id, +i.order_date, +m.monetary_component_type_code + diff --git a/src/main/resources/getDigitalReturns.sql b/src/main/resources/getDigitalReturns.sql new file mode 100644 index 0000000..49a5bc9 --- /dev/null +++ b/src/main/resources/getDigitalReturns.sql @@ -0,0 +1,14 @@ +select +m.monetary_component_type as type, +i.${productNameCol} as name, +i.digital_order_item_id as order_id, +i.order_date as date, +-sum(m.transaction_amount) as amount +from ${databaseName}.${digOrdItems} i +inner join ${databaseName}.${digOrdReturnsMonetary} m on m.digital_order_item_id = i.digital_order_item_id +group by +i.${productNameCol}, +i.digital_order_item_id, +i.order_date, +m.monetary_component_type + diff --git a/src/main/resources/getDiscover.sql b/src/main/resources/getDiscover.sql new file mode 100644 index 0000000..02c1d94 --- /dev/null +++ b/src/main/resources/getDiscover.sql @@ -0,0 +1,7 @@ +select +trans_date, +post_date, +description, +amount, +category +from ${databaseName}.discover \ No newline at end of file diff --git a/src/main/resources/getFirstBank.sql b/src/main/resources/getFirstBank.sql new file mode 100644 index 0000000..e0dc4da --- /dev/null +++ b/src/main/resources/getFirstBank.sql @@ -0,0 +1,6 @@ +select +date, +description, +type, +amount +from ${databaseName}.first_bank \ No newline at end of file diff --git a/src/main/resources/getMultiplyAssignedTransactions.sql b/src/main/resources/getMultiplyAssignedTransactions.sql new file mode 100644 index 0000000..6dfaadb --- /dev/null +++ b/src/main/resources/getMultiplyAssignedTransactions.sql @@ -0,0 +1,5 @@ +select t.id, t.date, t.source, t.description, t.amount, + r.id, r.category_id, r.regex, r.flags, r.source, r.priority, r.description, r.year +from ${databaseName}.transaction t +inner join ${databaseName}.transaction_regex_mtm tr on tr.transaction_id = t.id +inner join budget.regex r on r.id = tr.regex_id \ No newline at end of file diff --git a/src/main/resources/getPaypal.sql b/src/main/resources/getPaypal.sql new file mode 100644 index 0000000..172ce8f --- /dev/null +++ b/src/main/resources/getPaypal.sql @@ -0,0 +1,13 @@ +select +date, +time, +time_zone, +name, +type, +status, +currency, +amount, +receipt_id, +balance +from ${databaseName}.paypal + diff --git a/src/main/resources/getRegexes.sql b/src/main/resources/getRegexes.sql new file mode 100644 index 0000000..cc2031e --- /dev/null +++ b/src/main/resources/getRegexes.sql @@ -0,0 +1,10 @@ +select +id, +category_id, +regex, +flags, +source, +priority, +description, +year +from ${databaseName}.regex \ No newline at end of file diff --git a/src/main/resources/getRetailOrders.sql b/src/main/resources/getRetailOrders.sql new file mode 100644 index 0000000..e7e1b7b --- /dev/null +++ b/src/main/resources/getRetailOrders.sql @@ -0,0 +1,8 @@ +select +website as website, +product_name as name, +order_id as order_id, +date(order_date) as date, +total_owed as amount +from ${databaseName}.${retailOrders} o + diff --git a/src/main/resources/getRootCategoryId.sql b/src/main/resources/getRootCategoryId.sql new file mode 100644 index 0000000..d7aab50 --- /dev/null +++ b/src/main/resources/getRootCategoryId.sql @@ -0,0 +1 @@ +select id from ${databaseName}.category where name = ? and parent_category_id is null \ No newline at end of file diff --git a/src/main/resources/getTransactionDescriptions.sql b/src/main/resources/getTransactionDescriptions.sql new file mode 100644 index 0000000..5b4fdc0 --- /dev/null +++ b/src/main/resources/getTransactionDescriptions.sql @@ -0,0 +1,3 @@ +select t.id, t.date, t.source, t.description, t.amount +from ${databaseName}.transaction t +where t.regex_id is null \ No newline at end of file diff --git a/src/main/resources/getTransactionDetail.sql b/src/main/resources/getTransactionDetail.sql new file mode 100644 index 0000000..c5e1d7b --- /dev/null +++ b/src/main/resources/getTransactionDetail.sql @@ -0,0 +1,3 @@ +select t.id, t.date, t.source, t.description, t.amount, r.category_id, r.regex, r.flags, r.source, r.description +from ${databaseName}.transaction t +inner join ${regexDatabaseName}.regex r on r.id = t.regex_id \ No newline at end of file diff --git a/src/main/resources/getTransactionRegex.sql b/src/main/resources/getTransactionRegex.sql new file mode 100644 index 0000000..18b21b1 --- /dev/null +++ b/src/main/resources/getTransactionRegex.sql @@ -0,0 +1 @@ +select regex_id from ${databaseName}.transaction_regex_mtm where transaction_id = ? \ No newline at end of file diff --git a/src/main/resources/getYears.sql b/src/main/resources/getYears.sql new file mode 100644 index 0000000..4b36ede --- /dev/null +++ b/src/main/resources/getYears.sql @@ -0,0 +1,3 @@ +select +year +from ${databaseName}.years \ No newline at end of file diff --git a/src/main/resources/insertChildCategory.sql b/src/main/resources/insertChildCategory.sql new file mode 100644 index 0000000..e55d482 --- /dev/null +++ b/src/main/resources/insertChildCategory.sql @@ -0,0 +1 @@ +insert into ${databaseName}.category (name, parent_category_id) values (?, ?) \ No newline at end of file diff --git a/src/main/resources/insertRegex.sql b/src/main/resources/insertRegex.sql new file mode 100644 index 0000000..59b3246 --- /dev/null +++ b/src/main/resources/insertRegex.sql @@ -0,0 +1,9 @@ +insert into ${databaseName}.regex ( + category_id, + regex, + flags, + source, + priority, + description, + year +) values (?, ?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/src/main/resources/insertRootCategory.sql b/src/main/resources/insertRootCategory.sql new file mode 100644 index 0000000..9801091 --- /dev/null +++ b/src/main/resources/insertRootCategory.sql @@ -0,0 +1 @@ +insert into ${databaseName}.category (name) values (?) \ No newline at end of file diff --git a/src/main/resources/insertTransaction.sql b/src/main/resources/insertTransaction.sql new file mode 100644 index 0000000..2f686c6 --- /dev/null +++ b/src/main/resources/insertTransaction.sql @@ -0,0 +1,8 @@ +insert into ${databaseName}.transaction ( + source, + unique_identifier, + type, + description, + date, + amount +) values (?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/src/main/resources/insertTransactionRegex.sql b/src/main/resources/insertTransactionRegex.sql new file mode 100644 index 0000000..a5430b7 --- /dev/null +++ b/src/main/resources/insertTransactionRegex.sql @@ -0,0 +1 @@ +insert into ${databaseName}.transaction_regex_mtm (regex_id, transaction_id) values (?, ?) \ No newline at end of file diff --git a/src/main/resources/insertYear.sql b/src/main/resources/insertYear.sql new file mode 100644 index 0000000..bcc8e43 --- /dev/null +++ b/src/main/resources/insertYear.sql @@ -0,0 +1,3 @@ +insert into ${databaseName}.years ( + year +) values (?) \ No newline at end of file diff --git a/src/main/resources/setExtraDescription.sql b/src/main/resources/setExtraDescription.sql new file mode 100644 index 0000000..3e3337e --- /dev/null +++ b/src/main/resources/setExtraDescription.sql @@ -0,0 +1 @@ +update ${databaseName}.transaction set extra_description = ? where id = ? \ No newline at end of file diff --git a/src/main/resources/updateRegexLink.sql b/src/main/resources/updateRegexLink.sql new file mode 100644 index 0000000..e9e0fbe --- /dev/null +++ b/src/main/resources/updateRegexLink.sql @@ -0,0 +1 @@ +update ${databaseName}.transaction set regex_id = ? where id = ? \ No newline at end of file