- Refactored transcations table and queries to use catetories table

- Added historical rates chart for investments page
This commit is contained in:
Kiyan 2026-02-12 21:49:33 +02:00
parent 60ddc816a0
commit f5b3e49ca6
12 changed files with 449 additions and 235 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View File

@ -135,7 +135,8 @@ public class PdfManager {
if (matchedCategory != null) {
String label = matchedCategory.replace(" ", "");
transaction.setCategory(matchedCategory);
int categoryId = categoriesRepo.findCategoryIdByCategoryName(matchedCategory);
transaction.setCategory(Integer.toString(categoryId));
int endIndex = rightmostIndex + label.length();
line = line.substring(0, rightmostIndex) + line.substring(endIndex);

View File

@ -16,8 +16,7 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import com.example.FinanceTracker.Components.BootstrapColours;
// import com.example.FinanceTracker.Components.ColourComponent;
//import com.example.FinanceTracker.Components.PdfManager;
import com.example.FinanceTracker.Components.PdfManager;
import com.example.FinanceTracker.DTOs.GroupedTransaction;
import com.example.FinanceTracker.DTOs.Home.OverviewCard;
import com.example.FinanceTracker.Models.Transaction;
@ -27,8 +26,8 @@ import com.example.FinanceTracker.Services.TransactionService;
@Controller
public class HomeController {
// @Autowired
// private PdfManager pdfManager;
@Autowired
private PdfManager pdfManager;
@Autowired
private TransactionService transactionsService;
@ -42,7 +41,7 @@ public class HomeController {
@GetMapping("/")
public String Home(Model model) {
// transactionsService.saveAll(pdfManager.extractTransactions());
//transactionsService.saveAll(pdfManager.extractTransactions());
// Balance Text
model.addAttribute("latestBalance",

View File

@ -1,33 +1,52 @@
package com.example.FinanceTracker.Controllers;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import com.example.FinanceTracker.DTOs.Investments.CurrRate;
import com.example.FinanceTracker.DTOs.Investments.Currency;
import com.example.FinanceTracker.DTOs.Investments.ExchangeRate;
import com.example.FinanceTracker.Services.InvestmentService;
@Controller
public class InvestmentsController {
@Autowired
private RestClient restClient;
private InvestmentService investmentService;
@GetMapping("/investments")
public String investments(Model model) {
// Create Currency Pills
List<CurrRate> allRates = investmentService.getCurrRates();
model.addAttribute("currRates", allRates);
// Populate Historical Rates Dropdown
List<Currency> allCurrencies = getAllCurrencies();
List<Currency> allCurrencies = investmentService.getAllCurrencies();
model.addAttribute("allCurrencies", allCurrencies);
// Create Currency Pills
List<CurrRate> allRates = getCurrRates();
model.addAttribute("currRates", allRates);
// historical rates chart
List<ExchangeRate> historicalExchangeRates = investmentService.getHistoricalRates("USD", 1).reversed();
// Get all the rates from the historical Exchange Rates
ArrayList<BigDecimal> historicalRates = new ArrayList<>();
ArrayList<LocalDate> historicalRatesLabels = new ArrayList<>();
for (ExchangeRate exchangeRate : historicalExchangeRates) {
historicalRates.add(exchangeRate.getRate());
historicalRatesLabels.add(exchangeRate.getCreatedAt().toLocalDate());
}
model.addAttribute("historicalRates", historicalRates);
model.addAttribute("labels", historicalRatesLabels);
// footer
model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear());
@ -35,53 +54,50 @@ public class InvestmentsController {
return "investments";
}
private List<CurrRate> getCurrRates() {
return List.of("EUR", "USD", "JPY", "GBP", "CAD", "AUD").stream()
.map(code -> {
Double rate = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/exchangeRate/pair")
.queryParam("from", code)
.queryParam("to", "ZAR")
.build())
.retrieve()
.body(Double.class);
@GetMapping("/historicalRatesWeekly")
public String showHistoricalRatesWeek(Model model,
@RequestParam("historicalRatesWeekly") boolean historicalRatesWeekly){
return new CurrRate(code, rate.toString(), getChange(code));
})
.toList();
// historical rates chart
List<ExchangeRate> historicalExchangeRates = investmentService.getHistoricalRates("USD", 3).reversed();
// Get all the rates from the historical Exchange Rates
ArrayList<BigDecimal> historicalRates = new ArrayList<>();
ArrayList<LocalDate> historicalRatesLabels = new ArrayList<>();
for (ExchangeRate exchangeRate : historicalExchangeRates) {
historicalRates.add(exchangeRate.getRate());
historicalRatesLabels.add(exchangeRate.getCreatedAt().toLocalDate());
}
private String getChange(String targetCurr) {
model.addAttribute("historicalRates", historicalRates);
model.addAttribute("labels", historicalRatesLabels);
final String effectiveTargetCurr = targetCurr.toUpperCase().contains("USD") ? "ZAR" : targetCurr;
List<ExchangeRate> exchangeRate = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("exchangeRate/Historical")
.queryParam("targetCurrency", effectiveTargetCurr)
.queryParam("fromDate", LocalDate.now().minusDays(1))
.queryParam("toDate", LocalDate.now().plusDays(1))
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<ExchangeRate>>() {
});
if (exchangeRate.get(0).getRate().compareTo(exchangeRate.get(1).getRate()) > 0) {
return "UP";
} else {
return "DOWN";
return "investments :: HistoricalRatesChart";
}
@GetMapping("/changeHistoricalCurr")
public String changeHistoricalCurr(Model model,
@RequestParam("currencyChange") String currencyChange){
// historical rates chart
List<ExchangeRate> historicalExchangeRates = investmentService.getHistoricalRates(currencyChange, 3).reversed();
// Get all the rates from the historical Exchange Rates
ArrayList<BigDecimal> historicalRates = new ArrayList<>();
ArrayList<LocalDate> historicalRatesLabels = new ArrayList<>();
for (ExchangeRate exchangeRate : historicalExchangeRates) {
historicalRates.add(exchangeRate.getRate());
historicalRatesLabels.add(exchangeRate.getCreatedAt().toLocalDate());
}
private List<Currency> getAllCurrencies() {
model.addAttribute("historicalRates", historicalRates);
model.addAttribute("labels", historicalRatesLabels);
return restClient.get()
.uri("/currency/currencies")
.retrieve()
.body(new ParameterizedTypeReference<List<Currency>>() {
});
return "investments :: HistoricalRatesChart";
}
}

View File

@ -32,7 +32,7 @@ public class Transaction {
private String description;
@Column
private String category;
private int category;
@Column
private BigDecimal moneyIn;

View File

@ -4,6 +4,7 @@ import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import com.example.FinanceTracker.Models.Categories;
@ -15,4 +16,7 @@ public interface CategoriesRepo extends JpaRepository<Categories, Integer>{
@Query(value = "SELECT category_name FROM categories", nativeQuery=true)
List<String> findAllCategoryNames();
@Query(value = "SELECT id FROM categories WHERE category_name = :category_name", nativeQuery = true)
int findCategoryIdByCategoryName(@Param("category_name") String category_name);
}

View File

@ -27,12 +27,22 @@ public interface TransactionRepo extends JpaRepository<Transaction, UUID> {
Page<Transaction> getTransactionsBetween(Pageable pageable, @Param("fromDate") LocalDate fromDate,
@Param("toDate") LocalDate toDate);
@Query(value = "SELECT * FROM transactions ORDER BY date DESC", nativeQuery = true)
@Query(value = """
SELECT t.balance, t.date, t.money_in, t.money_out,
t.description, c.category_name as category, t.fee,
t.id
FROM transactions t
JOIN categories c ON t.category = c.id
ORDER BY date DESC
""", nativeQuery = true)
Page<Transaction> findAllPaged(Pageable pageable);
@Query(value = """
SELECT * FROM transactions
WHERE CONCAT_WS('', date, description, category, money_in, money_out, fee, balance)
SELECT
t.balance, t.date, t.money_in, t.money_out, t.description, c.category_name as category, t.fee, t.id
FROM transactions t
JOIN categories c ON t.category = c.id
WHERE CONCAT_WS('', t.balance, t.date, t.money_in, t.money_out, t.description, c.category_name, t.fee)
LIKE Concat('%', :searchValue, '%')
ORDER BY date
""", nativeQuery = true)
@ -71,8 +81,8 @@ public interface TransactionRepo extends JpaRepository<Transaction, UUID> {
double getAverageMonthlyMoneyIn(int year);
@Query(value = """
SELECT category, SUM(money_in)
FROM transactions
SELECT c.category_name, SUM(money_in)
FROM transactions t JOIN categories c ON t.category = c.id
WHERE money_in != 0
GROUP BY category
""", nativeQuery = true)
@ -96,8 +106,8 @@ public interface TransactionRepo extends JpaRepository<Transaction, UUID> {
double getAverageMonthlyMoneyOut(int year);
@Query(value = """
SELECT category, SUM(money_out)
FROM transactions
SELECT c.category_name, SUM(money_out)
FROM transactions t JOIN categories c ON t.category = c.id
WHERE money_out != 0
GROUP BY category
""", nativeQuery = true)

View File

@ -0,0 +1,112 @@
package com.example.FinanceTracker.Services;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import com.example.FinanceTracker.DTOs.Investments.CurrRate;
import com.example.FinanceTracker.DTOs.Investments.Currency;
import com.example.FinanceTracker.DTOs.Investments.ExchangeRate;
@Service
public class InvestmentService {
@Autowired
private RestClient restClient;
private final int WEEK = 1;
private final int MONTH = 2;
private final int YEAR = 3;
public List<CurrRate> getCurrRates() {
return List.of("EUR", "USD", "JPY", "GBP", "CAD", "AUD").stream()
.map(code -> {
Double rate = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/exchangeRate/pair")
.queryParam("from", code)
.queryParam("to", "ZAR")
.build())
.retrieve()
.body(Double.class);
return new CurrRate(code, rate.toString(), getChange(code, "ZAR"));
})
.toList();
}
private String getChange(String targetCurr, String desiredCurrency) {
final String effectiveTargetCurr = targetCurr.toUpperCase().contains("USD") ? "ZAR" : targetCurr;
List<ExchangeRate> exchangeRate = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("exchangeRate/historical")
.queryParam("targetCurrency", effectiveTargetCurr)
.queryParam("desiredCurrency", desiredCurrency)
.queryParam("fromDate", LocalDate.now().minusDays(1))
.queryParam("toDate", LocalDate.now().plusDays(1))
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<ExchangeRate>>() {
});
if (exchangeRate.get(0).getRate().compareTo(exchangeRate.get(1).getRate()) > 0) {
return "UP";
} else {
return "DOWN";
}
}
public List<ExchangeRate> getHistoricalRates(String targetCurr, int duration) {
LocalDate toDate = LocalDate.now().plusDays(1);
LocalDate fromDate;
switch (duration) {
case WEEK:
fromDate = LocalDate.now().minusWeeks(1);
break;
case MONTH:
fromDate = LocalDate.now().minusMonths(1);
break;
case YEAR:
fromDate = LocalDate.now().minusYears(1);
break;
default:
fromDate = LocalDate.now().minusWeeks(1);
}
List<ExchangeRate> historicalRates = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("exchangeRate/historical")
.queryParam("targetCurrency", targetCurr)
.queryParam("desiredCurrency", "ZAR")
.queryParam("fromDate", fromDate.toString())
.queryParam("toDate", toDate.toString())
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<ExchangeRate>>() {
});
return historicalRates;
}
public List<Currency> getAllCurrencies() {
return restClient.get()
.uri("/currency/currencies")
.retrieve()
.body(new ParameterizedTypeReference<List<Currency>>() {
});
}
}

View File

@ -1,39 +1,32 @@
<div th:fragment="overviewCard(card)" class="col-md-6 col-xl-3 mb-4">
<div class="card shadow-sm h-100 border-start border-4"
th:classappend="${card.borderColour}"
<div class="card shadow-sm h-100 text-center"
role="group"
th:aria-label="${card.heading}">
<div class="card-body d-flex align-items-center justify-content-between">
<!-- Text Content -->
<div class="me-3">
<div class="text-uppercase fw-semibold small"
th:classappend="${card.textColour}">
<span th:text="${card.heading}">
Average Earnings (Monthly)
</span>
</div>
<div class="card-body">
<div class="fw-bold fs-4 text-dark">
<span th:text="${card.value}">$40,000</span>
</div>
<!-- Optional subtext -->
<div class="text-muted small"
th:if="${card.subText}"
th:text="${card.subText}">
Compared to last month
</div>
</div>
<!-- Icon -->
<div class="flex-shrink-0">
<i class="material-icons fs-2 text-muted"
<i class="material-icons fs-3 mb-2 text-muted"
th:text="${card.icon}"
aria-hidden="true">
attach_money
</i>
<div class="fw-bold fs-5"
th:text="${card.value}">
$40,000
</div>
<div class="text-uppercase fs-7 fw-semibold mt-1"
th:classappend="${card.textColour}">
<span th:text="${card.heading}">Average Earnings</span>
</div>
<div class="small text-muted mt-1"
th:if="${card.subText}"
th:text="${card.subText}">
Compared to last month
</div>
</div>
</div>
</div>

View File

@ -6,15 +6,16 @@
${currRate.change} == 'UP'
? ' border border-success text-success'
: ' border border-danger text-danger'
"
>
">
<span
class="material-icons bg-gray"
th:text="${currRate.change} == 'UP' ? 'trending_up' : 'trending_down'">
</span>
<span class="fw-semibold text-dark" th:text="${currRate.ISO}"></span>
<span th:text="${currRate.value}"></span>
</span>

View File

@ -135,9 +135,12 @@
<span class="fw-bold text-primary mb-1">Historical Rates</span>
</div>
<div class="col-lg-4 mw-50">
<div class="btn-group d-flex justify-content-center mb-1" role="group" aria-label="Time range">
<input type="radio" class="btn-check" name="options" id="option1"
autocomplete="off" checked>
<div class="btn-group d-flex justify-content-center mb-1" role="group"
aria-label="Time range">
<input th:hx-get="@{/historicalRatesWeekly}" hx-trigger="click once"
hx-swap="outerHTML" hx-target="#HistoricalRatesChartContainer"
type="radio" class="btn-check" name="historicalRatesWeekly"
id="option1" autocomplete="off" checked>
<label class="btn btn-secondary" for="option1">Week</label>
<input type="radio" class="btn-check" name="options" id="option2"
@ -151,7 +154,11 @@
</div>
<div class="col-lg-4 d-flex justify-content-end">
<select class="form-select" name="" id="" style="max-width: 200px;">
<select class="form-select" name="currencyChange" id="" style="max-width: 200px;"
th:hx-get="@{/changeHistoricalCurr}"
hx-trigger="change"
hx-swap="outerHTML"
hx-target="#HistoricalRatesChartContainer">
<option th:each="currency : ${allCurrencies}"
th:value="${currency.code}"
th:text="${currency.code + ' - ' + currency.currencyName}">Test
@ -161,7 +168,44 @@
</div>
</div>
<div class="card-body">
<div id="HistoricalRatesChartContainer" th:fragment="HistoricalRatesChart"
style="height: 350px;">
<canvas id="HistoricalRatesChart"></canvas>
<script th:inline="javascript">
/*<![CDATA[*/
new Chart(document.getElementById('HistoricalRatesChart'), {
type: 'line',
data: {
labels: /*[[${labels}]]*/[],
datasets: [{
label: 'Rates',
data: /*[[${historicalRates}]]*/[],
borderColor: '#2E7D32',
backgroundColor: 'rgba(46,125,50,0.15)',
borderWidth: 2,
fill: true,
tension: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: v => `$${v.toLocaleString()}`
}
}
}
}
});
/*]]>*/
</script>
</div>
</div>
</div>
</div>
@ -185,6 +229,40 @@
<script src="assets/js/chart.min.js"></script>
<script src="assets/js/bs-init.js"></script>
<script src="assets/js/theme.js"></script>
<script th:inline="javascript">
/*<![CDATA[*/
new Chart(document.getElementById('HistoricalRatesChart'), {
type: 'line',
data: {
labels: /*[[${labels}]]*/[],
datasets: [
{
label: 'Rates',
data: /*[[${historicalRates}]]*/[],
borderColor: '#2E7D32',
backgroundColor: 'rgba(46,125,50,0.15)',
borderWidth: 2,
fill: true,
tension: 0
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: v => `$${v.toLocaleString()}`
}
}
}
}
});
/*]]>*/
</script>
</body>
</html>

View File

@ -128,22 +128,22 @@
<table th:fragment="transactionTable" class="table my-0" id="dataTable">
<!-- Fixed column widths -->
<colgroup>
<col style="width: 120px;"> <!-- Date -->
<col style="width: 130px;"> <!-- Money In -->
<col style="width: 130px;"> <!-- Money Out -->
<col style="width: 140px;"> <!-- Balance -->
<col style="width: 110px;"> <!-- Fees -->
<col style="width: 140px;"> <!-- Balance -->
<col style="width: 120px;"> <!-- Date -->
<col style="width: 160px;"> <!-- Category -->
<col style="width: 260px;"> <!-- Description -->
</colgroup>
<thead>
<tr>
<th>Date</th>
<th class="numeric">Money In</th>
<th class="numeric">Money Out</th>
<th class="numeric">Balance</th>
<th class="numeric">Fees</th>
<th class="numeric">Balance</th>
<th>Date</th>
<th>Category</th>
<th>Description</th>
@ -154,15 +154,15 @@
<tr th:each="transaction : ${transactions}"
th:with="rowClass=${(transaction.moneyIn + transaction.moneyOut + transaction.fee) < 0 ? 'txn-negative' : 'txn-positive'}"
th:class="${rowClass}">
<td th:text="${transaction.date}"></td>
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.moneyIn)}"></td>
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.moneyOut)}"></td>
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.balance)}"></td>
<td class="numeric" th:text="${#numbers.formatCurrency(transaction.fee)}">
</td>
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.balance)}"></td>
<td th:text="${transaction.date}"></td>
<td th:text="${transaction.category}"></td>
<td class="description" th:text="${transaction.description}"></td>