From fef8e645a3298b6e815ff1341b6016726b3a2ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hor=C3=A1nszki=20Patrik?= Date: Sun, 1 Mar 2026 18:05:43 +0100 Subject: [PATCH 1/5] EX-262: Create widgets --- .../exence/finance/common/util/DateUtils.java | 57 ++++ .../annotations/AtLeastOneNotEmpty.java | 22 ++ .../annotations/ValidWidgetLayout.java | 24 ++ .../controller/WidgetController.java | 19 ++ .../controller/impl/WidgetControllerImpl.java | 56 ++++ .../statistics/dto/ChartLayoutItem.java | 5 + .../statistics/dto/StatCardLayoutItem.java | 5 + .../modules/statistics/dto/Timeframe.java | 50 ++++ .../statistics/dto/UpdateLayoutRequest.java | 8 + .../modules/statistics/dto/WidgetDTO.java | 16 + .../modules/statistics/dto/WidgetRequest.java | 6 + .../modules/statistics/dto/WidgetSetting.java | 31 ++ .../modules/statistics/dto/WidgetType.java | 86 ++++++ .../dto/payload/BoxplotPayload.java | 5 + .../statistics/dto/payload/BoxplotPoint.java | 6 + .../statistics/dto/payload/BubblePayload.java | 5 + .../statistics/dto/payload/BubblePoint.java | 6 + .../statistics/dto/payload/BubbleSeries.java | 5 + .../statistics/dto/payload/DataPoint.java | 5 + .../dto/payload/DistributionItem.java | 5 + .../dto/payload/DistributionPayload.java | 5 + .../statistics/dto/payload/GaugePayload.java | 5 + .../statistics/dto/payload/SankeyLink.java | 5 + .../statistics/dto/payload/SankeyPayload.java | 5 + .../statistics/dto/payload/SeriesItem.java | 5 + .../statistics/dto/payload/SeriesPayload.java | 5 + .../statistics/dto/payload/SlopeItem.java | 6 + .../statistics/dto/payload/SlopePayload.java | 5 + .../dto/payload/StatCardPayload.java | 13 + .../modules/statistics/dto/payload/Trend.java | 7 + .../dto/payload/WidgetDataPayload.java | 11 + .../projection/CategoryAmountProjection.java | 8 + .../projection/CategoryAverageProjection.java | 8 + .../projection/CategoryFlowProjection.java | 11 + .../projection/CategoryStatsProjection.java | 12 + .../dto/projection/DailyTrendProjection.java | 13 + .../dto/projection/HeatmapProjection.java | 11 + .../projection/MonthlyBalanceProjection.java | 8 + .../projection/MonthlyCategoryProjection.java | 9 + .../MonthlyIncomeExpenseProjection.java | 12 + .../projection/MonthlyTrendProjection.java | 13 + .../dto/projection/ScatterProjection.java | 11 + .../dto/projection/TypeAmountProjection.java | 10 + .../projection/YearlyCategoryProjection.java | 10 + .../projection/base/CategoryProjection.java | 7 + .../projection/base/MonthlyProjection.java | 7 + .../dto/response/ChartWidgetDTO.java | 6 + .../dto/response/StatCardWidgetDTO.java | 14 + .../dto/response/WidgetDataResponse.java | 6 + .../dto/response/WidgetLayoutResponse.java | 5 + .../statistics/entity/DailyCategoryStat.java | 66 +++++ .../entity/DailyCategoryStatId.java | 19 ++ .../modules/statistics/entity/Widget.java | 70 +++++ .../statistics/mapper/WidgetMapper.java | 25 ++ .../repository/StatisticsRepository.java | 275 ++++++++++++++++++ .../repository/WidgetRepository.java | 19 ++ .../statistics/service/WidgetService.java | 18 ++ .../service/impl/WidgetServiceImpl.java | 142 +++++++++ .../provider/BalanceTrendProvider.java | 39 +++ .../BalanceYearComparisonProvider.java | 59 ++++ .../provider/CategoryAvgPolarProvider.java | 35 +++ .../provider/CategoryBoxplotProvider.java | 52 ++++ .../provider/CategoryBubbleProvider.java | 39 +++ .../provider/CategoryTreemapProvider.java | 49 ++++ .../ExpenseCategoryColumnProvider.java | 35 +++ .../ExpenseCategoryTrendProvider.java | 35 +++ .../service/provider/ExpensePieProvider.java | 31 ++ .../provider/ExpenseSavingsComboProvider.java | 58 ++++ .../provider/ExpenseTrendProvider.java | 39 +++ .../provider/IncomeCategoryTrendProvider.java | 35 +++ .../provider/IncomeExpenseColumnProvider.java | 56 ++++ .../service/provider/IncomePieProvider.java | 31 ++ .../service/provider/IncomeTrendProvider.java | 39 +++ .../MonthlyBalanceColumnProvider.java | 43 +++ .../provider/MonthlyBoxplotProvider.java | 66 +++++ .../MonthlyCategoryRadarProvider.java | 49 ++++ .../provider/MonthlyPeakPolarProvider.java | 43 +++ .../service/provider/ProviderHelper.java | 131 +++++++++ .../service/provider/SankeyProvider.java | 46 +++ .../provider/SavingsGaugeProvider.java | 38 +++ .../provider/SpendingHeatmapProvider.java | 56 ++++ .../provider/SpendingRadarProvider.java | 31 ++ .../service/provider/StatCardProvider.java | 22 ++ .../TransactionCountExpenseComboProvider.java | 57 ++++ .../provider/TransactionScatterProvider.java | 53 ++++ .../provider/WealthGrowthComboProvider.java | 53 ++++ .../service/provider/WidgetDataProvider.java | 40 +++ .../service/provider/YearlySlopeProvider.java | 66 +++++ .../AtLeastOneNotEmptyValidator.java | 22 ++ .../validators/WidgetLayoutValidator.java | 92 ++++++ .../repository/TransactionRepository.java | 36 +++ .../db/changelog/v1.1.0/changelog-v1.1.0.yaml | 14 +- .../v1.1.0/create-mv-daily-category-stat.yaml | 68 +++++ .../v1.1.0/create-mv-refresh-trigger.yaml | 50 ++++ .../changelog/v1.1.0/create-widget-table.yaml | 74 +++++ .../sql/create-mv-daily-category-stat.sql | 15 + .../v1.1.0/sql/create-refresh-function.sql | 7 + .../v1.1.0/sql/create-trigger-on-category.sql | 4 + .../sql/create-trigger-on-transaction.sql | 4 + 99 files changed, 3120 insertions(+), 1 deletion(-) create mode 100644 backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/AtLeastOneNotEmpty.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/WidgetController.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/impl/WidgetControllerImpl.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItem.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItem.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/UpdateLayoutRequest.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetDTO.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetSetting.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPoint.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePoint.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubbleSeries.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DataPoint.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionItem.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionPayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/GaugePayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyLink.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyPayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesItem.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesPayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopeItem.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopePayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/Trend.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/WidgetDataPayload.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAmountProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAverageProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryFlowProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryStatsProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/DailyTrendProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/HeatmapProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyBalanceProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyCategoryProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyIncomeExpenseProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyTrendProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/ScatterProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TypeAmountProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/YearlyCategoryProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/CategoryProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/MonthlyProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetLayoutResponse.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStatId.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/Widget.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/mapper/WidgetMapper.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/WidgetRepository.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetService.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceTrendProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceYearComparisonProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryAvgPolarProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBoxplotProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBubbleProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryTreemapProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryColumnProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpensePieProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseTrendProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomePieProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeTrendProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBalanceColumnProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBoxplotProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyCategoryRadarProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyPeakPolarProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SankeyProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsGaugeProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingHeatmapProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingRadarProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionScatterProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/YearlySlopeProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/AtLeastOneNotEmptyValidator.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/WidgetLayoutValidator.java create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-daily-category-stat.yaml create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/create-widget-table.yaml create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-mv-daily-category-stat.sql create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql diff --git a/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java b/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java new file mode 100644 index 0000000..2785ae9 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java @@ -0,0 +1,57 @@ +package com.exence.finance.common.util; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.Month; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.format.TextStyle; +import java.time.temporal.IsoFields; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import lombok.experimental.UtilityClass; + +// TODO: timezone AND Locale settings should be calculated from the user's profile settings +@UtilityClass +public final class DateUtils { + + public static final ZoneId DISPLAY_ZONE = ZoneId.of("Europe/Budapest"); + public static final int DAYS_PER_WEEK = 7; + + public static final int DECEMBER = 12; + public static final int LAST_WEEK_DECEMBER_DAY = 28; // dec 28 is always in the last week of the year + + public static String toDisplayDate(Instant instant) { + return instant.atZone(DISPLAY_ZONE).toLocalDate().toString(); + } + + public static List getMonthsInRange(Instant startDate, Instant endDate) { + List months = new ArrayList<>(); + + YearMonth current = YearMonth.from(startDate.atZone(DISPLAY_ZONE)); + YearMonth last = YearMonth.from(endDate.atZone(DISPLAY_ZONE)); + + while (!current.isAfter(last)) { + months.add(current); + current = current.plusMonths(1); + } + + return months; + } + + public static String getMonthName(int monthNum) { + return Month.of(monthNum).getDisplayName(TextStyle.SHORT, Locale.ENGLISH); + } + + /** @param isoDayOfWeek ISO day-of-week (1 = Monday, 7 = Sunday) */ + public static String getDayName(int isoDayOfWeek) { + return DayOfWeek.of(isoDayOfWeek).getDisplayName(TextStyle.SHORT, Locale.ENGLISH); + } + + public static int getIsoWeekCount(Instant instant) { + int year = instant.atZone(DISPLAY_ZONE).getYear(); + return LocalDate.of(year, DECEMBER, LAST_WEEK_DECEMBER_DAY).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/AtLeastOneNotEmpty.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/AtLeastOneNotEmpty.java new file mode 100644 index 0000000..9d670fc --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/AtLeastOneNotEmpty.java @@ -0,0 +1,22 @@ +package com.exence.finance.modules.statistics.annotations; + +import com.exence.finance.modules.statistics.validators.AtLeastOneNotEmptyValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = AtLeastOneNotEmptyValidator.class) +@Documented +public @interface AtLeastOneNotEmpty { + String message() default "At least one of statCards or charts must be provided"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java new file mode 100644 index 0000000..1c50873 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java @@ -0,0 +1,24 @@ +package com.exence.finance.modules.statistics.annotations; + +import com.exence.finance.modules.statistics.validators.WidgetLayoutValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = WidgetLayoutValidator.class) +@Documented +public @interface ValidWidgetLayout { + String message() default + "Invalid widget layout: StatCard requires displayOrder only, graph widgets require position and size" + + " fields"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/WidgetController.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/WidgetController.java new file mode 100644 index 0000000..acb29c8 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/WidgetController.java @@ -0,0 +1,19 @@ +package com.exence.finance.modules.statistics.controller; + +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; +import com.exence.finance.modules.statistics.dto.WidgetDTO; +import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse; +import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse; +import org.springframework.http.ResponseEntity; + +public interface WidgetController { + + ResponseEntity getLayout(); + + ResponseEntity getWidgetData(Long widgetId, Timeframe timeframe); + + ResponseEntity createWidget(WidgetDTO widgetDTO); + + ResponseEntity updateLayout(UpdateLayoutRequest request); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/impl/WidgetControllerImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/impl/WidgetControllerImpl.java new file mode 100644 index 0000000..bef1fd7 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/controller/impl/WidgetControllerImpl.java @@ -0,0 +1,56 @@ +package com.exence.finance.modules.statistics.controller.impl; + +import com.exence.finance.common.util.ResponseFactory; +import com.exence.finance.modules.statistics.controller.WidgetController; +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; +import com.exence.finance.modules.statistics.dto.WidgetDTO; +import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse; +import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse; +import com.exence.finance.modules.statistics.service.WidgetService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/statistics/widgets") +@CrossOrigin(origins = "http://localhost:4200") +@RequiredArgsConstructor +public class WidgetControllerImpl implements WidgetController { + + private final WidgetService widgetService; + + @Override + @GetMapping("/layout") + public ResponseEntity getLayout() { + return ResponseFactory.ok(widgetService.getLayout()); + } + + @Override + @GetMapping("/{widgetId}/data") + public ResponseEntity getWidgetData( + @PathVariable Long widgetId, @RequestParam(required = false) Timeframe timeframe) { + return ResponseFactory.ok(widgetService.getWidgetData(widgetId, timeframe)); + } + + @Override + @PostMapping + public ResponseEntity createWidget(@Valid @RequestBody WidgetDTO widgetDTO) { + return ResponseFactory.ok(widgetService.createWidget(widgetDTO)); + } + + @Override + @PutMapping("/layout") + public ResponseEntity updateLayout(@Valid @RequestBody UpdateLayoutRequest request) { + return ResponseFactory.ok(widgetService.updateLayout(request)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItem.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItem.java new file mode 100644 index 0000000..0fdfb0d --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItem.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto; + +import jakarta.validation.constraints.NotNull; + +public record ChartLayoutItem(@NotNull Long id, @NotNull Integer x, @NotNull Integer y) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItem.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItem.java new file mode 100644 index 0000000..1695468 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItem.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto; + +import jakarta.validation.constraints.NotNull; + +public record StatCardLayoutItem(@NotNull Long id, @NotNull Integer displayOrder) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java new file mode 100644 index 0000000..5b10859 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java @@ -0,0 +1,50 @@ +package com.exence.finance.modules.statistics.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +public enum Timeframe { + ONE_WEEK("1W", 7, ChronoUnit.DAYS), + ONE_MONTH("1M", 1, ChronoUnit.MONTHS), + THREE_MONTHS("3M", 3, ChronoUnit.MONTHS), + SIX_MONTHS("6M", 6, ChronoUnit.MONTHS), + ONE_YEAR("1Y", 1, ChronoUnit.YEARS), + YTD("YTD", 0, null), + ALL_TIME("ALL", Integer.MAX_VALUE, ChronoUnit.YEARS); + + private final String code; + private final int amount; + private final ChronoUnit unit; + + Timeframe(String code, int amount, ChronoUnit unit) { + this.code = code; + this.amount = amount; + this.unit = unit; + } + + public Instant toStartDate() { + if (this == ALL_TIME) { + return Instant.EPOCH; + } + if (this == YTD) { + return LocalDate.now(ZoneOffset.UTC) + .withDayOfYear(1) + .atStartOfDay(ZoneOffset.UTC) + .toInstant(); + } + return ZonedDateTime.now(ZoneOffset.UTC).minus(amount, unit).toInstant(); + } + + // fallback to 1y if null or unrecognized + public static Timeframe fromCode(String code) { + if (code == null) return YTD; + for (Timeframe t : values()) { + if (t.code.equalsIgnoreCase(code)) return t; + } + + return YTD; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/UpdateLayoutRequest.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/UpdateLayoutRequest.java new file mode 100644 index 0000000..7b28090 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/UpdateLayoutRequest.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.statistics.dto; + +import com.exence.finance.modules.statistics.annotations.AtLeastOneNotEmpty; +import jakarta.validation.Valid; +import java.util.List; + +@AtLeastOneNotEmpty +public record UpdateLayoutRequest(@Valid List statCards, @Valid List charts) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetDTO.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetDTO.java new file mode 100644 index 0000000..a8a68e0 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetDTO.java @@ -0,0 +1,16 @@ +package com.exence.finance.modules.statistics.dto; + +import com.exence.finance.modules.statistics.annotations.ValidWidgetLayout; +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +@ValidWidgetLayout +public record WidgetDTO( + Long id, + @NotNull WidgetType type, + String title, + Timeframe timeframe, + Integer displayOrder, + Integer x, + Integer y, + Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java new file mode 100644 index 0000000..b357057 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto; + +import java.time.Instant; +import java.util.Map; + +public record WidgetRequest(Instant startDate, Instant endDate, Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetSetting.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetSetting.java new file mode 100644 index 0000000..ea7d943 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetSetting.java @@ -0,0 +1,31 @@ +package com.exence.finance.modules.statistics.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum WidgetSetting { + ICON("icon"), + ICON_COLOR("iconColor"), + CONTEXT_LABEL("contextLabel"); + + private final String key; + + WidgetSetting(String key) { + this.key = key; + } + + @JsonValue + public String getKey() { + return key; + } + + @JsonCreator + public static WidgetSetting fromKey(String key) { + for (WidgetSetting setting : values()) { + if (setting.key.equals(key)) { + return setting; + } + } + throw new IllegalArgumentException("Unknown widget setting: " + key); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java new file mode 100644 index 0000000..cbc336f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java @@ -0,0 +1,86 @@ +package com.exence.finance.modules.statistics.dto; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +public enum WidgetType { + // --- Stat Cards --- + EXAMPLE_STATCARD, + + // --- Charts --- + + // Area/Line trends + INCOME_TREND, + EXPENSE_TREND, + BALANCE_TREND, + INCOME_CATEGORY_TREND, + EXPENSE_CATEGORY_TREND, + BALANCE_YEAR_COMPARISON, + + // Column/Bar + INCOME_EXPENSE_COLUMN, + EXPENSE_CATEGORY_COLUMN, + MONTHLY_BALANCE_COLUMN, + + // Mixed + EXPENSE_SAVINGS_COMBO, + TRANSACTION_COUNT_EXPENSE_COMBO, + WEALTH_GROWTH_COMBO, + + // Pie/Donut + EXPENSE_PIE, + INCOME_PIE, + + // Radar + SPENDING_RADAR, + MONTHLY_CATEGORY_RADAR, + + // Polar Area + CATEGORY_AVG_POLAR, + MONTHLY_PEAK_POLAR, + + // Bubble + CATEGORY_BUBBLE, + + // Scatter + TRANSACTION_SCATTER, + + // Heatmap + SPENDING_HEATMAP, + + // Treemap + CATEGORY_TREEMAP, + + // Boxplot + CATEGORY_BOXPLOT, + MONTHLY_BOXPLOT, + + // Gauge + SAVINGS_RATE_GAUGE, + + // Slope + YEARLY_SLOPE, + + // Sankey + CATEGORY_SANKEY; + + public static final Set STAT_CARD_TYPES = EnumSet.of(WidgetType.EXAMPLE_STATCARD); + + public static final Set GRAPH_TYPES = + Collections.unmodifiableSet(EnumSet.complementOf(EnumSet.copyOf(STAT_CARD_TYPES))); + + public static final Set YTD_ONLY_TYPES = EnumSet.of(WidgetType.SPENDING_HEATMAP); + + public boolean isStatCard() { + return STAT_CARD_TYPES.contains(this); + } + + public boolean isGraph() { + return GRAPH_TYPES.contains(this); + } + + public boolean isYtdOnly() { + return YTD_ONLY_TYPES.contains(this); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPayload.java new file mode 100644 index 0000000..242bb2f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record BoxplotPayload(List data) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPoint.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPoint.java new file mode 100644 index 0000000..68126e5 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BoxplotPoint.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; +import java.util.List; + +public record BoxplotPoint(Object x, List y, String color) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePayload.java new file mode 100644 index 0000000..d0925d6 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record BubblePayload(List series) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePoint.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePoint.java new file mode 100644 index 0000000..fb96843 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubblePoint.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; + +// x = count, y = average, z = sum +public record BubblePoint(Integer x, BigDecimal y, BigDecimal z) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubbleSeries.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubbleSeries.java new file mode 100644 index 0000000..b517582 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/BubbleSeries.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record BubbleSeries(String name, List data, String color) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DataPoint.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DataPoint.java new file mode 100644 index 0000000..84e6740 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DataPoint.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; + +public record DataPoint(Object x, BigDecimal y, String fillColor) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionItem.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionItem.java new file mode 100644 index 0000000..459fee5 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionItem.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; + +public record DistributionItem(String name, BigDecimal amount, String color) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionPayload.java new file mode 100644 index 0000000..873bbdd --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/DistributionPayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record DistributionPayload(List data) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/GaugePayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/GaugePayload.java new file mode 100644 index 0000000..9ab3f8f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/GaugePayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; + +public record GaugePayload(BigDecimal data) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyLink.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyLink.java new file mode 100644 index 0000000..9ad6fb4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyLink.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; + +public record SankeyLink(String from, String to, BigDecimal value, String color) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyPayload.java new file mode 100644 index 0000000..85b7525 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SankeyPayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record SankeyPayload(List data) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesItem.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesItem.java new file mode 100644 index 0000000..b8b1ae1 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesItem.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record SeriesItem(String name, String type, String color, List data) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesPayload.java new file mode 100644 index 0000000..1ebd3d2 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SeriesPayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record SeriesPayload(List series) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopeItem.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopeItem.java new file mode 100644 index 0000000..6a782f7 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopeItem.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; +import java.util.Map; + +public record SlopeItem(String category, Map yearsData, String color) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopePayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopePayload.java new file mode 100644 index 0000000..e667701 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/SlopePayload.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.util.List; + +public record SlopePayload(List data) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java new file mode 100644 index 0000000..5fa5fb5 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java @@ -0,0 +1,13 @@ +package com.exence.finance.modules.statistics.dto.payload; + +import java.math.BigDecimal; + +public record StatCardPayload( + BigDecimal value, + String unit, + BigDecimal changePercentage, + Trend trend, + String contextLabel, + String icon, + String iconColor) + implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/Trend.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/Trend.java new file mode 100644 index 0000000..b8068f9 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/Trend.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.payload; + +public enum Trend { + UP, + DOWN, + NEUTRAL +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/WidgetDataPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/WidgetDataPayload.java new file mode 100644 index 0000000..aacc928 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/WidgetDataPayload.java @@ -0,0 +1,11 @@ +package com.exence.finance.modules.statistics.dto.payload; + +public sealed interface WidgetDataPayload + permits BoxplotPayload, + BubblePayload, + DistributionPayload, + GaugePayload, + SankeyPayload, + SeriesPayload, + SlopePayload, + StatCardPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAmountProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAmountProjection.java new file mode 100644 index 0000000..75f2cca --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAmountProjection.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import java.math.BigDecimal; + +public interface CategoryAmountProjection extends CategoryProjection { + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAverageProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAverageProjection.java new file mode 100644 index 0000000..10d0536 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAverageProjection.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import java.math.BigDecimal; + +public interface CategoryAverageProjection extends CategoryProjection { + BigDecimal getAvgAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryFlowProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryFlowProjection.java new file mode 100644 index 0000000..3e77236 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryFlowProjection.java @@ -0,0 +1,11 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; + +public interface CategoryFlowProjection extends CategoryProjection { + TransactionType getType(); + + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryStatsProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryStatsProjection.java new file mode 100644 index 0000000..f6b60c4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryStatsProjection.java @@ -0,0 +1,12 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import java.math.BigDecimal; + +public interface CategoryStatsProjection extends CategoryProjection { + BigDecimal getTotalAmount(); + + Long getTransactionCount(); + + BigDecimal getAvgAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/DailyTrendProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/DailyTrendProjection.java new file mode 100644 index 0000000..4358097 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/DailyTrendProjection.java @@ -0,0 +1,13 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import java.time.Instant; + +public interface DailyTrendProjection { + TransactionType getType(); + + Instant getStatDate(); + + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/HeatmapProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/HeatmapProjection.java new file mode 100644 index 0000000..d4d489f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/HeatmapProjection.java @@ -0,0 +1,11 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import java.math.BigDecimal; + +public interface HeatmapProjection { + Integer getDayOfWeek(); + + Integer getWeekNumber(); + + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyBalanceProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyBalanceProjection.java new file mode 100644 index 0000000..96f01e4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyBalanceProjection.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.MonthlyProjection; +import java.math.BigDecimal; + +public interface MonthlyBalanceProjection extends MonthlyProjection { + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyCategoryProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyCategoryProjection.java new file mode 100644 index 0000000..8c95124 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyCategoryProjection.java @@ -0,0 +1,9 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import com.exence.finance.modules.statistics.dto.projection.base.MonthlyProjection; +import java.math.BigDecimal; + +public interface MonthlyCategoryProjection extends MonthlyProjection, CategoryProjection { + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyIncomeExpenseProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyIncomeExpenseProjection.java new file mode 100644 index 0000000..35872af --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyIncomeExpenseProjection.java @@ -0,0 +1,12 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.MonthlyProjection; +import java.math.BigDecimal; + +public interface MonthlyIncomeExpenseProjection extends MonthlyProjection { + BigDecimal getIncomeAmount(); + + BigDecimal getExpenseAmount(); + + Long getTransactionCount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyTrendProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyTrendProjection.java new file mode 100644 index 0000000..de1c5ab --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyTrendProjection.java @@ -0,0 +1,13 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.MonthlyProjection; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; + +public interface MonthlyTrendProjection extends MonthlyProjection { + TransactionType getType(); + + BigDecimal getTotalAmount(); + + Long getTransactionCount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/ScatterProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/ScatterProjection.java new file mode 100644 index 0000000..419336c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/ScatterProjection.java @@ -0,0 +1,11 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import java.math.BigDecimal; +import java.time.Instant; + +public interface ScatterProjection extends CategoryProjection { + Instant getTransactionDate(); + + BigDecimal getAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TypeAmountProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TypeAmountProjection.java new file mode 100644 index 0000000..bbff1c4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TypeAmountProjection.java @@ -0,0 +1,10 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; + +public interface TypeAmountProjection { + TransactionType getType(); + + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/YearlyCategoryProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/YearlyCategoryProjection.java new file mode 100644 index 0000000..323d30f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/YearlyCategoryProjection.java @@ -0,0 +1,10 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import java.math.BigDecimal; + +public interface YearlyCategoryProjection extends CategoryProjection { + Integer getStatYear(); + + BigDecimal getTotalAmount(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/CategoryProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/CategoryProjection.java new file mode 100644 index 0000000..eec65a2 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/CategoryProjection.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.projection.base; + +public interface CategoryProjection { + String getCategoryName(); + + String getCategoryColor(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/MonthlyProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/MonthlyProjection.java new file mode 100644 index 0000000..a661c2d --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/MonthlyProjection.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.projection.base; + +public interface MonthlyProjection { + Integer getStatYear(); + + Integer getStatMonth(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java new file mode 100644 index 0000000..9666e92 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.response; + +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.WidgetType; + +public record ChartWidgetDTO(Long id, WidgetType type, String title, Timeframe timeframe, int x, int y) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java new file mode 100644 index 0000000..552530b --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java @@ -0,0 +1,14 @@ +package com.exence.finance.modules.statistics.dto.response; + +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.WidgetSetting; +import com.exence.finance.modules.statistics.dto.WidgetType; +import java.util.Map; + +public record StatCardWidgetDTO( + Long id, + WidgetType type, + String title, + Timeframe timeframe, + int displayOrder, + Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java new file mode 100644 index 0000000..a9c442b --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.response; + +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.WidgetDataPayload; + +public record WidgetDataResponse(Long widgetId, WidgetType type, WidgetDataPayload payload) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetLayoutResponse.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetLayoutResponse.java new file mode 100644 index 0000000..46aee89 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetLayoutResponse.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.response; + +import java.util.List; + +public record WidgetLayoutResponse(List statCards, List charts) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java new file mode 100644 index 0000000..46df5af --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java @@ -0,0 +1,66 @@ +package com.exence.finance.modules.statistics.entity; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Filter; +import org.hibernate.annotations.Immutable; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Immutable +@Table(name = "mv_daily_category_stat") +@Filter(name = "userFilter", condition = "user_id = :userId") +@IdClass(DailyCategoryStatId.class) +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class DailyCategoryStat { + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + @Id + @Column(name = "stat_date", nullable = false) + private Instant statDate; + + @Id + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Id + @NotNull + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(name = "type", nullable = false) + private TransactionType type; + + // todo: redundáns az adat, de gyorsíthatja a lekérdezést, hogy ne kelljen joinolni a kategória nevéért?! + @Column(name = "category_name", nullable = false) + private String categoryName; + + @Column(name = "category_color", nullable = false) + private String categoryColor; + + // todo: az átlag kijön transactioncount/totalamount-ból + @Column(name = "total_amount", nullable = false) + private BigDecimal totalAmount; + + @Column(name = "transaction_count", nullable = false) + private Long transactionCount; + + // todo: ez is redundáns, de gyorsíthatja a lekérdezést, hogy ne kelljen újra iterálni a tranzakciókon a max + // megtalálásához + @Column(name = "max_amount", nullable = false) + private BigDecimal maxAmount; +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStatId.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStatId.java new file mode 100644 index 0000000..71c8d7f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStatId.java @@ -0,0 +1,19 @@ +package com.exence.finance.modules.statistics.entity; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +// composite key class for DailyCategoryStat entity +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class DailyCategoryStatId implements Serializable { + private Long userId; + private Instant statDate; + private Long categoryId; + private TransactionType type; +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/Widget.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/Widget.java new file mode 100644 index 0000000..8b6ae64 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/Widget.java @@ -0,0 +1,70 @@ +package com.exence.finance.modules.statistics.entity; + +import com.exence.finance.modules.auth.entity.User; +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.WidgetSetting; +import com.exence.finance.modules.statistics.dto.WidgetType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Filter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "widget") +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@Filter(name = "userFilter", condition = "user_id = :userId") +public class Widget { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_id_seq") + @SequenceGenerator(name = "widget_id_seq", sequenceName = "widget_id_seq", allocationSize = 1) + @Column(name = "id", nullable = false) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private WidgetType type; + + @Column(name = "title", nullable = false) + private String title; + + @Enumerated(EnumType.STRING) + @Column(name = "timeframe") + private Timeframe timeframe; + + @Column(name = "display_order") + private Integer displayOrder; + + @Column(name = "x") + private Integer x; + + @Column(name = "y") + private Integer y; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "settings", columnDefinition = "jsonb") + private Map settings; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/mapper/WidgetMapper.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/mapper/WidgetMapper.java new file mode 100644 index 0000000..eee7e73 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/mapper/WidgetMapper.java @@ -0,0 +1,25 @@ +package com.exence.finance.modules.statistics.mapper; + +import com.exence.finance.modules.statistics.dto.WidgetDTO; +import com.exence.finance.modules.statistics.dto.response.ChartWidgetDTO; +import com.exence.finance.modules.statistics.dto.response.StatCardWidgetDTO; +import com.exence.finance.modules.statistics.entity.Widget; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface WidgetMapper { + + StatCardWidgetDTO mapToStatCardDTO(Widget widget); + + ChartWidgetDTO mapToChartDTO(Widget widget); + + List mapToStatCardDTOList(List widgets); + + List mapToChartDTOList(List widgets); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "user", ignore = true) + Widget mapToWidget(WidgetDTO dto); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java new file mode 100644 index 0000000..20b633b --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java @@ -0,0 +1,275 @@ +package com.exence.finance.modules.statistics.repository; + +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.dto.projection.CategoryAverageProjection; +import com.exence.finance.modules.statistics.dto.projection.CategoryFlowProjection; +import com.exence.finance.modules.statistics.dto.projection.CategoryStatsProjection; +import com.exence.finance.modules.statistics.dto.projection.DailyTrendProjection; +import com.exence.finance.modules.statistics.dto.projection.HeatmapProjection; +import com.exence.finance.modules.statistics.dto.projection.MonthlyBalanceProjection; +import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; +import com.exence.finance.modules.statistics.dto.projection.TypeAmountProjection; +import com.exence.finance.modules.statistics.dto.projection.YearlyCategoryProjection; +import com.exence.finance.modules.statistics.entity.DailyCategoryStat; +import com.exence.finance.modules.statistics.entity.DailyCategoryStatId; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.Instant; +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; + +public interface StatisticsRepository extends JpaRepository { + + // --- General --- + + @Query(""" + SELECT MIN(s.statDate) + FROM DailyCategoryStat s + """) + Instant findEarliestStatDate(); + + // --- Type-level totals --- + + @Query(""" + SELECT s.type AS type, COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + GROUP BY s.type + """) + List sumByType(@Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + // --- Daily trends --- + + @Query(""" + SELECT s.statDate AS statDate, COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.statDate + ORDER BY s.statDate + """) + List findDailyTrendByType( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(value = """ + SELECT daily.stat_date AS "statDate", + SUM(daily.daily_balance) OVER (ORDER BY daily.stat_date) AS "totalAmount" + FROM ( + SELECT stat_date, + SUM(CASE WHEN + CAST(type AS TEXT) = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME.name()} + THEN total_amount ELSE -total_amount END) AS daily_balance + FROM mv_daily_category_stat + WHERE user_id = :userId + AND stat_date BETWEEN :startDate AND :endDate + GROUP BY stat_date + ) daily + ORDER BY daily.stat_date + """, nativeQuery = true) + List findCumulativeDailyBalance( + @Param("userId") Long userId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + // --- Monthly aggregations --- + + @Query(""" + SELECT year(s.statDate) AS statYear, month(s.statDate) AS statMonth, + COALESCE(SUM(CASE WHEN s.type = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} + THEN s.totalAmount ELSE -s.totalAmount END), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + GROUP BY year(s.statDate), month(s.statDate) + ORDER BY year(s.statDate), month(s.statDate) + """) + List findMonthlyBalance( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(""" + SELECT year(s.statDate) AS statYear, month(s.statDate) AS statMonth, + COALESCE(SUM(CASE WHEN s.type = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} + THEN s.totalAmount ELSE CAST(0 AS big_decimal) END), 0) AS incomeAmount, + COALESCE(SUM(CASE WHEN s.type <> :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} + THEN s.totalAmount ELSE CAST(0 AS big_decimal) END), 0) AS expenseAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + GROUP BY year(s.statDate), month(s.statDate) + ORDER BY year(s.statDate), month(s.statDate) + """) + List findMonthlyIncomeExpense( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(""" + SELECT year(s.statDate) AS statYear, month(s.statDate) AS statMonth, + COALESCE(SUM(CASE WHEN s.type = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} + THEN s.totalAmount ELSE CAST(0 AS big_decimal) END), 0) AS incomeAmount, + COALESCE(SUM(CASE WHEN s.type <> :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} + THEN s.totalAmount ELSE CAST(0 AS big_decimal) END), 0) AS expenseAmount, + COALESCE(SUM(s.transactionCount), 0) AS transactionCount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + GROUP BY year(s.statDate), month(s.statDate) + ORDER BY year(s.statDate), month(s.statDate) + """) + List findMonthlyIncomeExpenseWithTransactionCount( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(""" + SELECT year(s.statDate) AS statYear, month(s.statDate) AS statMonth, + COALESCE(MAX(s.maxAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY year(s.statDate), month(s.statDate) + ORDER BY year(s.statDate), month(s.statDate) + """) + List findMonthlyPeakByType( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + // --- Category totals --- + + @Query(""" + SELECT s.type AS type, s.categoryName AS categoryName, + s.categoryColor AS categoryColor, COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + GROUP BY s.type, s.categoryName, s.categoryColor + """) + List findCategoryFlow( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(""" + SELECT s.type AS type, s.categoryName AS categoryName, + s.categoryColor AS categoryColor, COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + GROUP BY s.type, s.categoryName, s.categoryColor + ORDER BY s.type, SUM(s.totalAmount) DESC + """) + List findCategoryTotalsGroupedByType( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(""" + SELECT s.categoryName AS categoryName, s.categoryColor AS categoryColor, + COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.categoryName, s.categoryColor + ORDER BY s.categoryName + """) + List findCategoryStatsAmount( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(""" + SELECT s.categoryName AS categoryName, s.categoryColor AS categoryColor, + ROUND(CASE WHEN SUM(s.transactionCount) > 0 + THEN SUM(s.totalAmount) / SUM(s.transactionCount) + ELSE 0.0 END, 2) AS avgAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.categoryName, s.categoryColor + ORDER BY s.categoryName + """) + List findCategoryStatsAverage( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(""" + SELECT s.categoryName AS categoryName, s.categoryColor AS categoryColor, + COALESCE(SUM(s.totalAmount), 0) AS totalAmount, + COALESCE(SUM(s.transactionCount), 0) AS transactionCount, + ROUND(CASE WHEN SUM(s.transactionCount) > 0 + THEN SUM(s.totalAmount) / SUM(s.transactionCount) + ELSE 0.0 END, 2) AS avgAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.categoryName, s.categoryColor + ORDER BY s.categoryName + """) + List findCategoryStatsAmountCountAverage( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + // --- Category trends (monthly / yearly) --- + + @Query(""" + SELECT s.categoryName AS categoryName, s.categoryColor AS categoryColor, + year(s.statDate) AS statYear, month(s.statDate) AS statMonth, + COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.categoryName, s.categoryColor, year(s.statDate), month(s.statDate) + ORDER BY year(s.statDate), month(s.statDate), s.categoryName + """) + List findMonthlyCategoryTotals( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(""" + SELECT s.categoryName AS categoryName, s.categoryColor AS categoryColor, + year(s.statDate) AS statYear, + COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.categoryName, s.categoryColor, year(s.statDate) + ORDER BY year(s.statDate), s.categoryName + """) + List findYearlyCategoryTotals( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + // --- Native queries (heatmap, boxplot) --- + + @Query(value = """ + SELECT EXTRACT(ISODOW FROM stat_date)::int AS "dayOfWeek", + EXTRACT(WEEK FROM stat_date)::int AS "weekNumber", + COALESCE(SUM(total_amount), 0) AS "totalAmount" + FROM mv_daily_category_stat + WHERE user_id = :userId + AND stat_date BETWEEN :startDate AND :endDate + AND CAST(type AS TEXT) = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).EXPENSE.name()} + GROUP BY EXTRACT(ISODOW FROM stat_date), EXTRACT(WEEK FROM stat_date) + ORDER BY "weekNumber", "dayOfWeek" + """, nativeQuery = true) + List findWeeklyHeatmapExpense( + @Param("userId") Long userId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(value = """ + SELECT sub.yr AS "year", sub.mn AS "month", + MIN(sub.daily_total) AS "minVal", + PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY sub.daily_total) AS "q1", + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sub.daily_total) AS "median", + PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY sub.daily_total) AS "q3", + MAX(sub.daily_total) AS "maxVal" + FROM ( + SELECT EXTRACT(YEAR FROM stat_date)::int AS yr, + EXTRACT(MONTH FROM stat_date)::int AS mn, + stat_date, + SUM(total_amount) AS daily_total + FROM mv_daily_category_stat + WHERE user_id = :userId + AND stat_date BETWEEN :startDate AND :endDate + AND CAST(type AS TEXT) = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).EXPENSE.name()} + GROUP BY stat_date + ) sub + GROUP BY sub.yr, sub.mn + ORDER BY sub.yr, sub.mn + """, nativeQuery = true) + List findBoxplotByMonthExpense( + @Param("userId") Long userId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/WidgetRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/WidgetRepository.java new file mode 100644 index 0000000..1161c58 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/WidgetRepository.java @@ -0,0 +1,19 @@ +package com.exence.finance.modules.statistics.repository; + +import com.exence.finance.modules.statistics.entity.Widget; +import java.util.List; +import java.util.Optional; +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; + +@Repository +public interface WidgetRepository extends JpaRepository { + + @Query("SELECT w FROM Widget w WHERE w.id = :id") + Optional find(@Param("id") Long id); + + @Query("SELECT w FROM Widget w") + List findAllWidgets(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetService.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetService.java new file mode 100644 index 0000000..1f78bc4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetService.java @@ -0,0 +1,18 @@ +package com.exence.finance.modules.statistics.service; + +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; +import com.exence.finance.modules.statistics.dto.WidgetDTO; +import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse; +import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse; + +public interface WidgetService { + + WidgetLayoutResponse getLayout(); + + WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe); + + WidgetLayoutResponse createWidget(WidgetDTO widgetDTO); + + WidgetLayoutResponse updateLayout(UpdateLayoutRequest request); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java new file mode 100644 index 0000000..4b30f0f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java @@ -0,0 +1,142 @@ +package com.exence.finance.modules.statistics.service.impl; + +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.ChartLayoutItem; +import com.exence.finance.modules.statistics.dto.StatCardLayoutItem; +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; +import com.exence.finance.modules.statistics.dto.WidgetDTO; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.WidgetDataPayload; +import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse; +import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse; +import com.exence.finance.modules.statistics.entity.Widget; +import com.exence.finance.modules.statistics.mapper.WidgetMapper; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.repository.WidgetRepository; +import com.exence.finance.modules.statistics.service.WidgetService; +import com.exence.finance.modules.statistics.service.provider.WidgetDataProvider; +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityNotFoundException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class WidgetServiceImpl implements WidgetService { + + private final WidgetMapper widgetMapper; + private final WidgetRepository widgetRepository; + private final StatisticsRepository statisticsRepository; + private final UserService userService; + private final List providers; + + private Map providerMap; + + @PostConstruct + private void init() { + this.providerMap = providers.stream() + .collect(Collectors.toUnmodifiableMap(WidgetDataProvider::getSupportedType, Function.identity())); + } + + @Override + public WidgetLayoutResponse getLayout() { + List widgets = widgetRepository.findAllWidgets(); + + return new WidgetLayoutResponse( + widgetMapper.mapToStatCardDTOList( + widgets.stream().filter(w -> w.getType().isStatCard()).toList()), + widgetMapper.mapToChartDTOList( + widgets.stream().filter(w -> w.getType().isGraph()).toList())); + } + + @Override + @Transactional + public WidgetLayoutResponse createWidget(WidgetDTO widgetDTO) { + Widget widget = widgetMapper.mapToWidget(widgetDTO); + widget.setUser(userService.getCurrentUser()); + + widgetRepository.save(widget); + + return getLayout(); + } + + @Override + @Transactional + public WidgetLayoutResponse updateLayout(UpdateLayoutRequest request) { + Map existingWidgets = + widgetRepository.findAll().stream().collect(Collectors.toMap(Widget::getId, Function.identity())); + + if (request.statCards() != null) { + for (StatCardLayoutItem item : request.statCards()) { + Widget widget = existingWidgets.get(item.id()); + if (widget == null) { + throw new EntityNotFoundException("Widget not found: " + item.id()); + } + widget.setDisplayOrder(item.displayOrder()); + } + } + + if (request.charts() != null) { + for (ChartLayoutItem item : request.charts()) { + Widget widget = existingWidgets.get(item.id()); + if (widget == null) { + throw new EntityNotFoundException("Widget not found: " + item.id()); + } + widget.setX(item.x()); + widget.setY(item.y()); + } + } + + return getLayout(); + } + + @Override + public WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe) { + // todo: exception + Widget widget = widgetRepository + .find(widgetId) + .orElseThrow(() -> new EntityNotFoundException("Widget not found: " + widgetId)); + + Timeframe resolvedTimeframe = resolveTimeframe(timeframe, widget); + + Instant startDate = resolvedTimeframe.toStartDate(); + if (resolvedTimeframe == Timeframe.ALL_TIME) { + Instant earliest = statisticsRepository.findEarliestStatDate(); + if (earliest != null) { + startDate = earliest; + } + } + + WidgetRequest request = new WidgetRequest(startDate, Instant.now(), widget.getSettings()); + WidgetDataProvider provider = providerMap.get(widget.getType()); + if (provider == null) { + // todo: exception + throw new UnsupportedOperationException("No data provider for widget type: " + widget.getType()); + } + + WidgetDataPayload payload = provider.getData(request); + return new WidgetDataResponse(widget.getId(), widget.getType(), payload); + } + + private Timeframe resolveTimeframe(Timeframe queryParamTimeframe, Widget widget) { + if (widget.getType().isYtdOnly()) { + return Timeframe.YTD; + } + if (queryParamTimeframe != null) { + return queryParamTimeframe; + } + if (widget.getTimeframe() != null) { + return widget.getTimeframe(); + } + return Timeframe.YTD; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceTrendProvider.java new file mode 100644 index 0000000..5428397 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceTrendProvider.java @@ -0,0 +1,39 @@ +package com.exence.finance.modules.statistics.service.provider; + +import static com.exence.finance.common.util.DateUtils.toDisplayDate; + +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class BalanceTrendProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + private final UserService userService; + + @Override + public WidgetType getSupportedType() { + return WidgetType.BALANCE_TREND; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List dataPoints = statisticsRepository + .findCumulativeDailyBalance(userService.getCurrentUserId(), request.startDate(), request.endDate()) + .stream() + .map(p -> new DataPoint(toDisplayDate(p.getStatDate()), p.getTotalAmount(), null)) + .toList(); + + SeriesItem series = new SeriesItem("Balance", "area", null, dataPoints); + return new SeriesPayload(List.of(series)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceYearComparisonProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceYearComparisonProvider.java new file mode 100644 index 0000000..ae5c6f5 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BalanceYearComparisonProvider.java @@ -0,0 +1,59 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyBalanceProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.time.YearMonth; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class BalanceYearComparisonProvider implements WidgetDataProvider { + + private static final int MONTHS_IN_YEAR = 12; + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.BALANCE_YEAR_COMPARISON; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findMonthlyBalance(request.startDate(), request.endDate()); + + Set years = results.stream() + .map(MonthlyBalanceProjection::getStatYear) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + List series = years.stream() + .map(year -> { + List points = IntStream.rangeClosed(1, MONTHS_IN_YEAR) + .mapToObj(monthNumber -> new DataPoint( + DateUtils.getMonthName(monthNumber), + ProviderHelper.getAmount( + results, + YearMonth.of(year, monthNumber), + MonthlyBalanceProjection::getTotalAmount), + null)) + .toList(); + return new SeriesItem(String.valueOf(year), "line", null, points); + }) + .toList(); + + return new SeriesPayload(series); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryAvgPolarProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryAvgPolarProvider.java new file mode 100644 index 0000000..e256030 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryAvgPolarProvider.java @@ -0,0 +1,35 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DistributionItem; +import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class CategoryAvgPolarProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.CATEGORY_AVG_POLAR; + } + + @Override + public DistributionPayload getData(WidgetRequest request) { + List items = + statisticsRepository + .findCategoryStatsAverage(request.startDate(), request.endDate(), TransactionType.EXPENSE) + .stream() + .map(r -> new DistributionItem(r.getCategoryName(), r.getAvgAmount(), r.getCategoryColor())) + .toList(); + + return new DistributionPayload(items); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBoxplotProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBoxplotProvider.java new file mode 100644 index 0000000..f83ac1f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBoxplotProvider.java @@ -0,0 +1,52 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.BoxplotPayload; +import com.exence.finance.modules.statistics.dto.payload.BoxplotPoint; +import com.exence.finance.modules.transaction.repository.TransactionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class CategoryBoxplotProvider implements WidgetDataProvider { + + private static final int NAME_INDEX = 0; + private static final int COLOR_INDEX = 1; + private static final int MIN_INDEX = 2; + private static final int Q1_INDEX = 3; + private static final int MEDIAN_INDEX = 4; + private static final int Q3_INDEX = 5; + private static final int MAX_INDEX = 6; + + private final TransactionRepository transactionRepository; + private final UserService userService; + + @Override + public WidgetType getSupportedType() { + return WidgetType.CATEGORY_BOXPLOT; + } + + @Override + public BoxplotPayload getData(WidgetRequest request) { + List results = transactionRepository.findBoxplotByExpenseCategory( + userService.getCurrentUserId(), request.startDate(), request.endDate()); + + List points = results.stream() + .map(row -> new BoxplotPoint( + (String) row[NAME_INDEX], + List.of( + ProviderHelper.toBigDecimal(row[MIN_INDEX]), + ProviderHelper.toBigDecimal(row[Q1_INDEX]), + ProviderHelper.toBigDecimal(row[MEDIAN_INDEX]), + ProviderHelper.toBigDecimal(row[Q3_INDEX]), + ProviderHelper.toBigDecimal(row[MAX_INDEX])), + (String) row[COLOR_INDEX])) + .toList(); + + return new BoxplotPayload(points); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBubbleProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBubbleProvider.java new file mode 100644 index 0000000..c549654 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryBubbleProvider.java @@ -0,0 +1,39 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.BubblePayload; +import com.exence.finance.modules.statistics.dto.payload.BubblePoint; +import com.exence.finance.modules.statistics.dto.payload.BubbleSeries; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class CategoryBubbleProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.CATEGORY_BUBBLE; + } + + @Override + public BubblePayload getData(WidgetRequest request) { + List allSeries = statisticsRepository + .findCategoryStatsAmountCountAverage(request.startDate(), request.endDate(), TransactionType.EXPENSE) + .stream() + .map(result -> { + BubblePoint point = new BubblePoint( + result.getTransactionCount().intValue(), result.getAvgAmount(), result.getTotalAmount()); + return new BubbleSeries(result.getCategoryName(), List.of(point), result.getCategoryColor()); + }) + .toList(); + + return new BubblePayload(allSeries); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryTreemapProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryTreemapProvider.java new file mode 100644 index 0000000..2f9ec6d --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryTreemapProvider.java @@ -0,0 +1,49 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryFlowProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class CategoryTreemapProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.CATEGORY_TREEMAP; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findCategoryTotalsGroupedByType(request.startDate(), request.endDate()); + + Map> byType = + results.stream().collect(Collectors.groupingBy(CategoryFlowProjection::getType)); + + List series = List.of( + toSeriesItem("Expense", byType.getOrDefault(TransactionType.EXPENSE, List.of())), + toSeriesItem("Income", byType.getOrDefault(TransactionType.INCOME, List.of()))); + + return new SeriesPayload(series); + } + + private SeriesItem toSeriesItem(String name, List data) { + List points = data.stream() + .map(r -> new DataPoint(r.getCategoryName(), r.getTotalAmount(), r.getCategoryColor())) + .toList(); + return new SeriesItem(name, null, null, points); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryColumnProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryColumnProvider.java new file mode 100644 index 0000000..33da4e0 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryColumnProvider.java @@ -0,0 +1,35 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class ExpenseCategoryColumnProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXPENSE_CATEGORY_COLUMN; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = statisticsRepository.findMonthlyCategoryTotals( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + return ProviderHelper.buildMonthlyCategorySeriesPayload(results, months, "column", null); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java new file mode 100644 index 0000000..1da37ee --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java @@ -0,0 +1,35 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class ExpenseCategoryTrendProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXPENSE_CATEGORY_TREND; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = statisticsRepository.findMonthlyCategoryTotals( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + return ProviderHelper.buildMonthlyCategorySeriesPayload(results, months, "area", "todo: main piros szin kene"); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpensePieProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpensePieProvider.java new file mode 100644 index 0000000..4fffc3d --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpensePieProvider.java @@ -0,0 +1,31 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class ExpensePieProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXPENSE_PIE; + } + + @Override + public DistributionPayload getData(WidgetRequest request) { + List results = statisticsRepository.findCategoryStatsAmount( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + return ProviderHelper.buildCategoryAmountDistributionPayload(results); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java new file mode 100644 index 0000000..a2ee92b --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java @@ -0,0 +1,58 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class ExpenseSavingsComboProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXPENSE_SAVINGS_COMBO; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findMonthlyIncomeExpense(request.startDate(), request.endDate()); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List expensePoints = new ArrayList<>(); + List savingsRatePoints = new ArrayList<>(); + + months.forEach(month -> { + Optional value = ProviderHelper.findByMonth(results, month); + + BigDecimal income = + value.map(MonthlyIncomeExpenseProjection::getIncomeAmount).orElse(BigDecimal.ZERO); + BigDecimal expense = + value.map(MonthlyIncomeExpenseProjection::getExpenseAmount).orElse(BigDecimal.ZERO); + + expensePoints.add(new DataPoint(month.toString(), expense, null)); + + BigDecimal savingsRate = ProviderHelper.calculateSavingsRate(income, expense); + savingsRatePoints.add(new DataPoint(month.toString(), savingsRate, null)); + }); + + return new SeriesPayload(List.of( + new SeriesItem("Expense", "column", "todo: piros szin", expensePoints), + new SeriesItem("Savings Rate", "line", null, savingsRatePoints))); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseTrendProvider.java new file mode 100644 index 0000000..4f53b06 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseTrendProvider.java @@ -0,0 +1,39 @@ +package com.exence.finance.modules.statistics.service.provider; + +import static com.exence.finance.common.util.DateUtils.toDisplayDate; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class ExpenseTrendProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXPENSE_TREND; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List dataPoints = + statisticsRepository + .findDailyTrendByType(request.startDate(), request.endDate(), TransactionType.EXPENSE) + .stream() + .map(p -> new DataPoint(toDisplayDate(p.getStatDate()), p.getTotalAmount(), null)) + .toList(); + + SeriesItem series = new SeriesItem("Expense", "area", null, dataPoints); + return new SeriesPayload(List.of(series)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java new file mode 100644 index 0000000..f106ca0 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java @@ -0,0 +1,35 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class IncomeCategoryTrendProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.INCOME_CATEGORY_TREND; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = statisticsRepository.findMonthlyCategoryTotals( + request.startDate(), request.endDate(), TransactionType.INCOME); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + return ProviderHelper.buildMonthlyCategorySeriesPayload(results, months, "area", "todo: main zöld szín kéne"); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java new file mode 100644 index 0000000..7cb694c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java @@ -0,0 +1,56 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class IncomeExpenseColumnProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.INCOME_EXPENSE_COLUMN; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findMonthlyIncomeExpense(request.startDate(), request.endDate()); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List incomePoints = new ArrayList<>(); + List expensePoints = new ArrayList<>(); + + months.forEach(month -> { + Optional value = ProviderHelper.findByMonth(results, month); + + BigDecimal income = + value.map(MonthlyIncomeExpenseProjection::getIncomeAmount).orElse(BigDecimal.ZERO); + BigDecimal expense = + value.map(MonthlyIncomeExpenseProjection::getExpenseAmount).orElse(BigDecimal.ZERO); + + incomePoints.add(new DataPoint(month.toString(), income, null)); + expensePoints.add(new DataPoint(month.toString(), expense, null)); + }); + + return new SeriesPayload(List.of( + new SeriesItem("Income", "column", "todo: zold kene ide", incomePoints), + new SeriesItem("Expense", "column", "todo: piros kene ide", expensePoints))); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomePieProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomePieProvider.java new file mode 100644 index 0000000..891092a --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomePieProvider.java @@ -0,0 +1,31 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class IncomePieProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.INCOME_PIE; + } + + @Override + public DistributionPayload getData(WidgetRequest request) { + List results = statisticsRepository.findCategoryStatsAmount( + request.startDate(), request.endDate(), TransactionType.INCOME); + + return ProviderHelper.buildCategoryAmountDistributionPayload(results); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeTrendProvider.java new file mode 100644 index 0000000..9cca694 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeTrendProvider.java @@ -0,0 +1,39 @@ +package com.exence.finance.modules.statistics.service.provider; + +import static com.exence.finance.common.util.DateUtils.toDisplayDate; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class IncomeTrendProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.INCOME_TREND; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List dataPoints = + statisticsRepository + .findDailyTrendByType(request.startDate(), request.endDate(), TransactionType.INCOME) + .stream() + .map(p -> new DataPoint(toDisplayDate(p.getStatDate()), p.getTotalAmount(), null)) + .toList(); + + SeriesItem series = new SeriesItem("Income", "area", null, dataPoints); + return new SeriesPayload(List.of(series)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBalanceColumnProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBalanceColumnProvider.java new file mode 100644 index 0000000..e5af1a4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBalanceColumnProvider.java @@ -0,0 +1,43 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyBalanceProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class MonthlyBalanceColumnProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.MONTHLY_BALANCE_COLUMN; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findMonthlyBalance(request.startDate(), request.endDate()); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List points = months.stream() + .map(month -> new DataPoint( + month.toString(), + ProviderHelper.getAmount(results, month, MonthlyBalanceProjection::getTotalAmount), + null)) + .toList(); + + return new SeriesPayload(List.of(new SeriesItem("Balance", "column", null, points))); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBoxplotProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBoxplotProvider.java new file mode 100644 index 0000000..8472ffb --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyBoxplotProvider.java @@ -0,0 +1,66 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.BoxplotPayload; +import com.exence.finance.modules.statistics.dto.payload.BoxplotPoint; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class MonthlyBoxplotProvider implements WidgetDataProvider { + + private static final int YEAR_INDEX = 0; + private static final int MONTH_INDEX = 1; + private static final int MIN_INDEX = 2; + private static final int Q1_INDEX = 3; + private static final int MEDIAN_INDEX = 4; + private static final int Q3_INDEX = 5; + private static final int MAX_INDEX = 6; + private static final int BOXPLOT_VALUES_COUNT = 5; + + private final StatisticsRepository statisticsRepository; + private final UserService userService; + + @Override + public WidgetType getSupportedType() { + return WidgetType.MONTHLY_BOXPLOT; + } + + @Override + public BoxplotPayload getData(WidgetRequest request) { + List results = statisticsRepository.findBoxplotByMonthExpense( + userService.getCurrentUserId(), request.startDate(), request.endDate()); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List zeroValues = Collections.nCopies(BOXPLOT_VALUES_COUNT, BigDecimal.ZERO); + + List points = months.stream() + .map(month -> results.stream() + .filter(row -> ((Number) row[YEAR_INDEX]).intValue() == month.getYear() + && ((Number) row[MONTH_INDEX]).intValue() == month.getMonthValue()) + .findFirst() + .map(row -> new BoxplotPoint( + month.toString(), + List.of( + ProviderHelper.toBigDecimal(row[MIN_INDEX]), + ProviderHelper.toBigDecimal(row[Q1_INDEX]), + ProviderHelper.toBigDecimal(row[MEDIAN_INDEX]), + ProviderHelper.toBigDecimal(row[Q3_INDEX]), + ProviderHelper.toBigDecimal(row[MAX_INDEX])), + null)) + .orElse(new BoxplotPoint(month.toString(), zeroValues, null))) + .toList(); + + return new BoxplotPayload(points); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyCategoryRadarProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyCategoryRadarProvider.java new file mode 100644 index 0000000..ebd3d73 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyCategoryRadarProvider.java @@ -0,0 +1,49 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.YearMonth; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class MonthlyCategoryRadarProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.MONTHLY_CATEGORY_RADAR; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = statisticsRepository.findMonthlyCategoryTotals( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + Set categories = ProviderHelper.getCategories(results); + + List series = months.stream() + .map(month -> { + List points = categories.stream() + .map(category -> new DataPoint( + category, ProviderHelper.getCategoryAmountForMonth(results, category, month), null)) + .toList(); + return new SeriesItem(month.toString(), null, null, points); + }) + .toList(); + + return new SeriesPayload(series); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyPeakPolarProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyPeakPolarProvider.java new file mode 100644 index 0000000..8323e99 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/MonthlyPeakPolarProvider.java @@ -0,0 +1,43 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DistributionItem; +import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyBalanceProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class MonthlyPeakPolarProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.MONTHLY_PEAK_POLAR; + } + + @Override + public DistributionPayload getData(WidgetRequest request) { + List results = statisticsRepository.findMonthlyPeakByType( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List items = months.stream() + .map(month -> new DistributionItem( + month.toString(), + ProviderHelper.getAmount(results, month, MonthlyBalanceProjection::getTotalAmount), + null)) + .toList(); + + return new DistributionPayload(items); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java new file mode 100644 index 0000000..1f818a8 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java @@ -0,0 +1,131 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.DistributionItem; +import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; +import com.exence.finance.modules.statistics.dto.projection.base.MonthlyProjection; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ProviderHelper { + + private static final int DIVISION_SCALE = 4; + private static final int PERCENTAGE_MULTIPLIER = 100; + private static final int DISPLAY_SCALE = 2; + + // --- BUILDERS --- + + public SeriesPayload buildMonthlyCategorySeriesPayload( + List results, List months, String seriesType, String totalColor) { + Map colors = getCategoryColorMap(results); + + // main series for each category + List series = new ArrayList<>(colors.keySet().stream() + .map(category -> { + List points = months.stream() + .map(month -> new DataPoint( + month.toString(), getCategoryAmountForMonth(results, category, month), null)) + .toList(); + return new SeriesItem(category, seriesType, colors.get(category), points); + }) + .toList()); + + // calculate totals + if (totalColor != null) { + List totalPoints = months.stream() + .map(month -> { + BigDecimal total = results.stream() + .filter(r -> + r.getStatYear() == month.getYear() && r.getStatMonth() == month.getMonthValue()) + .map(MonthlyCategoryProjection::getTotalAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return new DataPoint(month.toString(), total, null); + }) + .toList(); + series.addFirst(new SeriesItem("Total", "line", totalColor, totalPoints)); + } + + return new SeriesPayload(series); + } + + public DistributionPayload buildCategoryAmountDistributionPayload(List results) { + List items = results.stream() + .map(r -> new DistributionItem(r.getCategoryName(), r.getTotalAmount(), r.getCategoryColor())) + .toList(); + return new DistributionPayload(items); + } + + // --- UTILITIES --- + + public Optional findByMonth(List results, YearMonth month) { + return results.stream() + .filter(r -> r.getStatYear() == month.getYear() && r.getStatMonth() == month.getMonthValue()) + .findFirst(); + } + + public BigDecimal getAmount( + List results, YearMonth month, Function extractor) { + return findByMonth(results, month).map(extractor).orElse(BigDecimal.ZERO); + } + + public Set getCategories(List results) { + return results.stream().map(CategoryProjection::getCategoryName).collect(Collectors.toSet()); + } + + public Map getCategoryColorMap(List results) { + return results.stream() + .collect(Collectors.toMap( + CategoryProjection::getCategoryName, + CategoryProjection::getCategoryColor, + (a, b) -> a, + LinkedHashMap::new)); + } + + public BigDecimal getCategoryAmountForMonth( + List results, String category, YearMonth month) { + return results.stream() + .filter(r -> r.getCategoryName().equals(category) + && r.getStatYear() == month.getYear() + && r.getStatMonth() == month.getMonthValue()) + .map(MonthlyCategoryProjection::getTotalAmount) + .findFirst() + .orElse(BigDecimal.ZERO); + } + + public BigDecimal calculateSavingsRate(BigDecimal income, BigDecimal expense) { + if (income.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + + return income.subtract(expense) + .divide(income, DIVISION_SCALE, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(PERCENTAGE_MULTIPLIER)) + .setScale(DISPLAY_SCALE, RoundingMode.HALF_UP); + } + + public BigDecimal toBigDecimal(Object value) { + if (value instanceof BigDecimal bd) { + return bd; + } + if (value instanceof Number n) { + return BigDecimal.valueOf(n.doubleValue()); + } + return BigDecimal.ZERO; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SankeyProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SankeyProvider.java new file mode 100644 index 0000000..0f71b2f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SankeyProvider.java @@ -0,0 +1,46 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.SankeyLink; +import com.exence.finance.modules.statistics.dto.payload.SankeyPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryFlowProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class SankeyProvider implements WidgetDataProvider { + + private static final String CENTRAL_NODE = "Wallet"; + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.CATEGORY_SANKEY; + } + + @Override + public SankeyPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findCategoryFlow(request.startDate(), request.endDate()); + + List links = results.stream() + .map(p -> { + if (p.getType() == TransactionType.INCOME) { + return new SankeyLink( + p.getCategoryName(), CENTRAL_NODE, p.getTotalAmount(), p.getCategoryColor()); + } else { + return new SankeyLink( + CENTRAL_NODE, p.getCategoryName(), p.getTotalAmount(), p.getCategoryColor()); + } + }) + .toList(); + + return new SankeyPayload(links); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsGaugeProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsGaugeProvider.java new file mode 100644 index 0000000..00802d8 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsGaugeProvider.java @@ -0,0 +1,38 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.GaugePayload; +import com.exence.finance.modules.statistics.dto.projection.TypeAmountProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class SavingsGaugeProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.SAVINGS_RATE_GAUGE; + } + + @Override + public GaugePayload getData(WidgetRequest request) { + Map sums = + statisticsRepository.sumByType(request.startDate(), request.endDate()).stream() + .collect(Collectors.toMap(TypeAmountProjection::getType, TypeAmountProjection::getTotalAmount)); + + BigDecimal income = sums.getOrDefault(TransactionType.INCOME, BigDecimal.ZERO); + BigDecimal expense = sums.getOrDefault(TransactionType.EXPENSE, BigDecimal.ZERO); + + BigDecimal savingsRate = ProviderHelper.calculateSavingsRate(income, expense); + return new GaugePayload(savingsRate); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingHeatmapProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingHeatmapProvider.java new file mode 100644 index 0000000..90e027f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingHeatmapProvider.java @@ -0,0 +1,56 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.HeatmapProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class SpendingHeatmapProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + private final UserService userService; + + @Override + public WidgetType getSupportedType() { + return WidgetType.SPENDING_HEATMAP; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = statisticsRepository.findWeeklyHeatmapExpense( + userService.getCurrentUserId(), request.startDate(), request.endDate()); + + int totalWeeks = DateUtils.getIsoWeekCount(request.startDate()); + + Map> byDay = results.stream() + .collect(Collectors.groupingBy( + HeatmapProjection::getDayOfWeek, + Collectors.toMap(HeatmapProjection::getWeekNumber, HeatmapProjection::getTotalAmount))); + + List series = new ArrayList<>(); + for (int day = 1; day <= DateUtils.DAYS_PER_WEEK; day++) { + Map weekData = byDay.getOrDefault(day, Map.of()); + List points = new ArrayList<>(); + for (int week = 1; week <= totalWeeks; week++) { + points.add(new DataPoint(week, weekData.getOrDefault(week, BigDecimal.ZERO), null)); + } + series.add(new SeriesItem(DateUtils.getDayName(day), null, null, points)); + } + + return new SeriesPayload(series); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingRadarProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingRadarProvider.java new file mode 100644 index 0000000..74730a7 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingRadarProvider.java @@ -0,0 +1,31 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class SpendingRadarProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.SPENDING_RADAR; + } + + @Override + public DistributionPayload getData(WidgetRequest request) { + List results = statisticsRepository.findCategoryStatsAmount( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + return ProviderHelper.buildCategoryAmountDistributionPayload(results); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java new file mode 100644 index 0000000..42166ab --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java @@ -0,0 +1,22 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class StatCardProvider implements WidgetDataProvider { + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXAMPLE_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + return null; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java new file mode 100644 index 0000000..13faf01 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java @@ -0,0 +1,57 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class TransactionCountExpenseComboProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.TRANSACTION_COUNT_EXPENSE_COMBO; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findMonthlyIncomeExpenseWithTransactionCount( + request.startDate(), request.endDate()); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List expensePoints = new ArrayList<>(); + List countPoints = new ArrayList<>(); + + months.forEach(month -> { + Optional value = ProviderHelper.findByMonth(results, month); + + BigDecimal expense = + value.map(MonthlyIncomeExpenseProjection::getExpenseAmount).orElse(BigDecimal.ZERO); + long count = value.map(MonthlyIncomeExpenseProjection::getTransactionCount) + .orElse(0L); + + expensePoints.add(new DataPoint(month.toString(), expense, null)); + countPoints.add(new DataPoint(month.toString(), BigDecimal.valueOf(count), null)); + }); + + return new SeriesPayload(List.of( + new SeriesItem("Expense", "column", "todo: piros szin", expensePoints), + new SeriesItem("Transaction Count", "line", null, countPoints))); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionScatterProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionScatterProvider.java new file mode 100644 index 0000000..79644e5 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionScatterProvider.java @@ -0,0 +1,53 @@ +package com.exence.finance.modules.statistics.service.provider; + +import static com.exence.finance.common.util.DateUtils.toDisplayDate; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.ScatterProjection; +import com.exence.finance.modules.transaction.dto.TransactionType; +import com.exence.finance.modules.transaction.repository.TransactionRepository; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class TransactionScatterProvider implements WidgetDataProvider { + + private final TransactionRepository transactionRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.TRANSACTION_SCATTER; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + transactionRepository.findScatterData(request.startDate(), request.endDate(), TransactionType.EXPENSE); + + Map categoryColorMap = ProviderHelper.getCategoryColorMap(results); + + Map> categoryPoints = new LinkedHashMap<>(); + categoryColorMap.keySet().forEach(cat -> categoryPoints.put(cat, new ArrayList<>())); + + for (ScatterProjection p : results) { + categoryPoints + .get(p.getCategoryName()) + .add(new DataPoint(toDisplayDate(p.getTransactionDate()), p.getAmount(), null)); + } + + List series = categoryPoints.entrySet().stream() + .map(e -> new SeriesItem(e.getKey(), null, categoryColorMap.get(e.getKey()), e.getValue())) + .toList(); + + return new SeriesPayload(series); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java new file mode 100644 index 0000000..12ec786 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java @@ -0,0 +1,53 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.DataPoint; +import com.exence.finance.modules.statistics.dto.payload.SeriesItem; +import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.projection.MonthlyBalanceProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class WealthGrowthComboProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.WEALTH_GROWTH_COMBO; + } + + @Override + public SeriesPayload getData(WidgetRequest request) { + List results = + statisticsRepository.findMonthlyBalance(request.startDate(), request.endDate()); + + List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); + + List profitPoints = new ArrayList<>(); + List cumulativePoints = new ArrayList<>(); + BigDecimal cumulative = BigDecimal.ZERO; + + for (YearMonth month : months) { + BigDecimal balance = ProviderHelper.getAmount(results, month, MonthlyBalanceProjection::getTotalAmount); + + cumulative = cumulative.add(balance); + + profitPoints.add(new DataPoint(month.toString(), balance, null)); + cumulativePoints.add(new DataPoint(month.toString(), cumulative, null)); + } + + return new SeriesPayload(List.of( + new SeriesItem("Profit", "column", "todo: zold szin", profitPoints), + new SeriesItem("Cumulative Balance", "line", null, cumulativePoints))); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java new file mode 100644 index 0000000..2f3b5f8 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java @@ -0,0 +1,40 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.WidgetDataPayload; + +public sealed interface WidgetDataProvider + permits BalanceTrendProvider, + BalanceYearComparisonProvider, + CategoryAvgPolarProvider, + CategoryBoxplotProvider, + CategoryBubbleProvider, + CategoryTreemapProvider, + ExpenseCategoryColumnProvider, + ExpenseCategoryTrendProvider, + ExpensePieProvider, + ExpenseSavingsComboProvider, + ExpenseTrendProvider, + IncomeExpenseColumnProvider, + IncomeCategoryTrendProvider, + IncomePieProvider, + IncomeTrendProvider, + MonthlyBalanceColumnProvider, + MonthlyBoxplotProvider, + MonthlyCategoryRadarProvider, + MonthlyPeakPolarProvider, + SankeyProvider, + SavingsGaugeProvider, + SpendingHeatmapProvider, + SpendingRadarProvider, + StatCardProvider, + TransactionScatterProvider, + TransactionCountExpenseComboProvider, + WealthGrowthComboProvider, + YearlySlopeProvider { + + WidgetType getSupportedType(); + + WidgetDataPayload getData(WidgetRequest request); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/YearlySlopeProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/YearlySlopeProvider.java new file mode 100644 index 0000000..d956e7d --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/YearlySlopeProvider.java @@ -0,0 +1,66 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.SlopeItem; +import com.exence.finance.modules.statistics.dto.payload.SlopePayload; +import com.exence.finance.modules.statistics.dto.projection.YearlyCategoryProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class YearlySlopeProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.YEARLY_SLOPE; + } + + @Override + public SlopePayload getData(WidgetRequest request) { + List results = statisticsRepository.findYearlyCategoryTotals( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + Map categoryMap = new LinkedHashMap<>(); + TreeSet allYears = new TreeSet<>(); + + for (YearlyCategoryProjection p : results) { + String year = String.valueOf(p.getStatYear()); + allYears.add(year); + SlopeEntry entry = + categoryMap.computeIfAbsent(p.getCategoryName(), k -> new SlopeEntry(p.getCategoryColor())); + entry.yearsData.put(year, p.getTotalAmount()); + } + + List items = categoryMap.entrySet().stream() + .map(e -> { + Map filled = new LinkedHashMap<>(); + for (String year : allYears) { + filled.put(year, e.getValue().yearsData.getOrDefault(year, BigDecimal.ZERO)); + } + return new SlopeItem(e.getKey(), filled, e.getValue().color); + }) + .toList(); + + return new SlopePayload(items); + } + + private static final class SlopeEntry { + private final String color; + private final Map yearsData = new LinkedHashMap<>(); + + SlopeEntry(String color) { + this.color = color; + } + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/AtLeastOneNotEmptyValidator.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/AtLeastOneNotEmptyValidator.java new file mode 100644 index 0000000..38d205c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/AtLeastOneNotEmptyValidator.java @@ -0,0 +1,22 @@ +package com.exence.finance.modules.statistics.validators; + +import com.exence.finance.modules.statistics.annotations.AtLeastOneNotEmpty; +import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class AtLeastOneNotEmptyValidator implements ConstraintValidator { + + @Override + public boolean isValid(UpdateLayoutRequest request, ConstraintValidatorContext context) { + if (request == null) { + return true; + } + + boolean hasStatCards = + request.statCards() != null && !request.statCards().isEmpty(); + boolean hasCharts = request.charts() != null && !request.charts().isEmpty(); + + return hasStatCards || hasCharts; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/WidgetLayoutValidator.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/WidgetLayoutValidator.java new file mode 100644 index 0000000..c44f74a --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/validators/WidgetLayoutValidator.java @@ -0,0 +1,92 @@ +package com.exence.finance.modules.statistics.validators; + +import com.exence.finance.modules.statistics.annotations.ValidWidgetLayout; +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.WidgetDTO; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.stereotype.Component; + +@Component +public class WidgetLayoutValidator implements ConstraintValidator { + + @Override + public boolean isValid(WidgetDTO dto, ConstraintValidatorContext context) { + if (dto == null || dto.type() == null) { + return true; + } + + context.disableDefaultConstraintViolation(); + + if (dto.type().isStatCard()) { + return validateStatCard(dto, context); + } + + if (dto.type().isGraph()) { + return validateGraph(dto, context); + } + + return true; + } + + private boolean validateStatCard(WidgetDTO dto, ConstraintValidatorContext context) { + boolean valid = true; + + if (dto.displayOrder() == null) { + context.buildConstraintViolationWithTemplate("StatCard widget requires displayOrder") + .addPropertyNode("displayOrder") + .addConstraintViolation(); + valid = false; + } + + if (dto.x() != null) { + context.buildConstraintViolationWithTemplate("StatCard widget must not have x") + .addPropertyNode("x") + .addConstraintViolation(); + valid = false; + } + + if (dto.y() != null) { + context.buildConstraintViolationWithTemplate("StatCard widget must not have y") + .addPropertyNode("y") + .addConstraintViolation(); + valid = false; + } + + return valid; + } + + private boolean validateGraph(WidgetDTO dto, ConstraintValidatorContext context) { + boolean valid = true; + + if (dto.x() == null) { + context.buildConstraintViolationWithTemplate("Graph widget requires x") + .addPropertyNode("x") + .addConstraintViolation(); + valid = false; + } + + if (dto.y() == null) { + context.buildConstraintViolationWithTemplate("Graph widget requires y") + .addPropertyNode("y") + .addConstraintViolation(); + valid = false; + } + + if (dto.displayOrder() != null) { + context.buildConstraintViolationWithTemplate("Graph widget must not have displayOrder") + .addPropertyNode("displayOrder") + .addConstraintViolation(); + valid = false; + } + + if (dto.type().isYtdOnly() && dto.timeframe() != null && dto.timeframe() != Timeframe.YTD) { + context.buildConstraintViolationWithTemplate(dto.type() + " widget only supports YTD timeframe") + .addPropertyNode("timeframe") + .addConstraintViolation(); + valid = false; + } + + return valid; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java index 2ea40f4..915de9e 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java @@ -1,8 +1,11 @@ package com.exence.finance.modules.transaction.repository; +import com.exence.finance.modules.statistics.dto.projection.ScatterProjection; import com.exence.finance.modules.transaction.dto.TransactionType; import com.exence.finance.modules.transaction.entity.Transaction; import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -19,4 +22,37 @@ public interface TransactionRepository @Query("SELECT COALESCE(SUM(t.amount), 0) FROM Transaction t WHERE t.type = :type") BigDecimal sumByType(@Param("type") TransactionType type); + + // --- Chart queries --- + + @Query(""" + SELECT t.date AS transactionDate, t.amount AS amount, + t.category.name AS categoryName, t.category.color AS categoryColor + FROM Transaction t + WHERE t.date BETWEEN :startDate AND :endDate + AND t.type = :type + ORDER BY t.date + """) + List findScatterData( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(value = """ + SELECT c.name AS "categoryName", c.color AS "categoryColor", + MIN(t.amount) AS "minVal", + PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY t.amount) AS "q1", + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY t.amount) AS "median", + PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY t.amount) AS "q3", + MAX(t.amount) AS "maxVal" + FROM "transaction" t + JOIN category c ON t.category_id = c.id + WHERE t.user_id = :userId + AND t.date BETWEEN :startDate AND :endDate + AND CAST(t.type AS TEXT) = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).EXPENSE.name()} + GROUP BY c.name, c.color + ORDER BY c.name + """, nativeQuery = true) + List findBoxplotByExpenseCategory( + @Param("userId") Long userId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); } diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml index 4860302..ac73a43 100644 --- a/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml @@ -14,8 +14,20 @@ databaseChangeLog: - include: file: change-transaction-type-size.yaml relativeToChangelogFile: true - + - include: file: migrate-from-emoji-to-icon.yaml relativeToChangelogFile: true + - include: + file: create-widget-table.yaml + relativeToChangelogFile: true + + - include: + file: create-mv-daily-category-stat.yaml + relativeToChangelogFile: true + + - include: + file: create-mv-refresh-trigger.yaml + relativeToChangelogFile: true + diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-daily-category-stat.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-daily-category-stat.yaml new file mode 100644 index 0000000..836205b --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-daily-category-stat.yaml @@ -0,0 +1,68 @@ +databaseChangeLog: + - changeSet: + id: create-mv-daily-category-stat + author: hptrk + comment: > + Create materialized view that pre-aggregates daily transaction data per user, category and type. + Eliminates the need to scan raw transaction rows for dashboard statistics. + dbms: postgresql + changes: + - sqlFile: + splitStatements: false + path: sql/create-mv-daily-category-stat.sql + relativeToChangelogFile: true + rollback: + - sql: + sql: DROP MATERIALIZED VIEW IF EXISTS mv_daily_category_stat + + - changeSet: + id: create-mv-daily-category-stat-unique-index + author: hptrk + comment: > + Unique index required for refresh materialized view concurrently + dbms: postgresql + changes: + - createIndex: + indexName: idx_mv_daily_cat_stat_unique + tableName: mv_daily_category_stat + unique: true + columns: + - column: + name: user_id + - column: + name: stat_date + - column: + name: category_id + - column: + name: type + + - changeSet: + id: create-mv-daily-category-stat-lookup-index + author: hptrk + comment: Composite index for fast user + date range lookups on the materialized view + dbms: postgresql + changes: + - createIndex: + indexName: idx_mv_daily_cat_stat_user_date + tableName: mv_daily_category_stat + columns: + - column: + name: user_id + - column: + name: stat_date + + - changeSet: + id: create-transaction-composite-index + author: hptrk + comment: > + Composite index on transaction(user_id, date) to accelerate + materialized view refresh and time-windowed statistics queries + changes: + - createIndex: + tableName: transaction + indexName: idx_transaction_user_id_date + columns: + - column: + name: user_id + - column: + name: date diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml new file mode 100644 index 0000000..a8a00c6 --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml @@ -0,0 +1,50 @@ +databaseChangeLog: + - changeSet: + id: create-mv-refresh-function + author: hptrk + comment: > + PL/pgSQL function that refreshes the mv_daily_category_stat materialized view concurrently. + Called by triggers on the transaction and category tables. + dbms: postgresql + changes: + - sqlFile: + splitStatements: false + path: sql/create-refresh-function.sql + relativeToChangelogFile: true + rollback: + - sql: + sql: DROP FUNCTION IF EXISTS refresh_mv_daily_category_stat() + + - changeSet: + id: create-mv-refresh-trigger-on-transaction + author: hptrk + comment: > + Fires after INSERT, UPDATE, or DELETE on the transaction table + to keep the materialized view in sync with the latest data. + Uses FOR EACH STATEMENT to avoid redundant refreshes during batch operations. + dbms: postgresql + changes: + - sqlFile: + splitStatements: false + path: sql/create-trigger-on-transaction.sql + relativeToChangelogFile: true + rollback: + - sql: + sql: DROP TRIGGER IF EXISTS trg_refresh_mv_on_transaction ON transaction + + - changeSet: + id: create-mv-refresh-trigger-on-category + author: hptrk + comment: > + Fires after UPDATE of name or color on the category table + to keep the materialized view in sync with the latest data. + Uses FOR EACH STATEMENT to avoid redundant refreshes during batch operations. + dbms: postgresql + changes: + - sqlFile: + splitStatements: false + path: sql/create-trigger-on-category.sql + relativeToChangelogFile: true + rollback: + - sql: + sql: DROP TRIGGER IF EXISTS trg_refresh_mv_on_category ON category diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-widget-table.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-widget-table.yaml new file mode 100644 index 0000000..7431dde --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-widget-table.yaml @@ -0,0 +1,74 @@ +databaseChangeLog: + - changeSet: + id: create-widget-id-seq + author: hptrk + comment: Create sequence for widget ID generation + changes: + - createSequence: + sequenceName: widget_id_seq + startValue: 1 + incrementBy: 1 + rollback: + - dropSequence: + sequenceName: widget_id_seq + + - changeSet: + id: create-widget-table + author: hptrk + comment: Create widget table for configuration of statistics widgets + changes: + - createTable: + tableName: widget + columns: + - column: + name: id + type: BIGINT + constraints: + primaryKey: true + primaryKeyName: pk_widget + nullable: false + - column: + name: type + type: VARCHAR(50) + constraints: + nullable: false + - column: + name: title + type: VARCHAR(100) + constraints: + nullable: false + - column: + name: timeframe + type: VARCHAR(30) + - column: + name: display_order + type: INTEGER + - column: + name: x + type: INTEGER + - column: + name: y + type: INTEGER + - column: + name: settings + type: JSONB + - column: + name: user_id + type: BIGINT + constraints: + nullable: false + + - addForeignKeyConstraint: + baseTableName: widget + baseColumnNames: user_id + constraintName: fk_widget_user + referencedTableName: _user + referencedColumnNames: id + onDelete: CASCADE + + - createIndex: + tableName: widget + indexName: idx_widget_user_id + columns: + - column: + name: user_id diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-mv-daily-category-stat.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-mv-daily-category-stat.sql new file mode 100644 index 0000000..b6dc187 --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-mv-daily-category-stat.sql @@ -0,0 +1,15 @@ +CREATE MATERIALIZED VIEW mv_daily_category_stat AS +SELECT + t.user_id, + date_trunc('day', t.date AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS stat_date, + t.category_id, + t.type, + c.name AS category_name, + c.color AS category_color, + SUM(t.amount) AS total_amount, + COUNT(t.id) AS transaction_count, + MAX(t.amount) AS max_amount +FROM transaction t +JOIN category c ON t.category_id = c.id +GROUP BY t.user_id, date_trunc('day', t.date AT TIME ZONE 'UTC') AT TIME ZONE 'UTC', t.category_id, t.type, c.name, c.color +WITH DATA; diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql new file mode 100644 index 0000000..1143e60 --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql @@ -0,0 +1,7 @@ +CREATE OR REPLACE FUNCTION refresh_mv_daily_category_stat() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_category_stat; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql new file mode 100644 index 0000000..5d83787 --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql @@ -0,0 +1,4 @@ +CREATE TRIGGER trg_refresh_mv_on_category +AFTER UPDATE OF name, color ON category +FOR EACH STATEMENT +EXECUTE FUNCTION refresh_mv_daily_category_stat(); diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql new file mode 100644 index 0000000..c5b5be9 --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql @@ -0,0 +1,4 @@ +CREATE TRIGGER trg_refresh_mv_on_transaction +AFTER INSERT OR UPDATE OR DELETE ON transaction +FOR EACH STATEMENT +EXECUTE FUNCTION refresh_mv_daily_category_stat(); From 9fd85adddba883082f5936f22d68c9d9851a3b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hor=C3=A1nszki=20Patrik?= Date: Wed, 4 Mar 2026 20:05:25 +0100 Subject: [PATCH 2/5] EX-262: Add constant colors and custom exception --- .../common/exception/GlobalExceptionHandler.java | 12 ++++++++++++ .../common/exception/WidgetNotFoundException.java | 11 +++++++++++ .../statistics/entity/DailyCategoryStat.java | 4 ---- .../statistics/service/impl/WidgetServiceImpl.java | 13 +++++++------ .../provider/ExpenseCategoryTrendProvider.java | 4 +++- .../provider/ExpenseSavingsComboProvider.java | 3 ++- .../provider/IncomeCategoryTrendProvider.java | 4 +++- .../provider/IncomeExpenseColumnProvider.java | 5 +++-- .../TransactionCountExpenseComboProvider.java | 3 ++- .../service/provider/WealthGrowthComboProvider.java | 3 ++- .../statistics/util/StatisticsConstants.java | 9 +++++++++ 11 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 backend/exence/src/main/java/com/exence/finance/common/exception/WidgetNotFoundException.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/util/StatisticsConstants.java diff --git a/backend/exence/src/main/java/com/exence/finance/common/exception/GlobalExceptionHandler.java b/backend/exence/src/main/java/com/exence/finance/common/exception/GlobalExceptionHandler.java index 1c4379a..a499d53 100644 --- a/backend/exence/src/main/java/com/exence/finance/common/exception/GlobalExceptionHandler.java +++ b/backend/exence/src/main/java/com/exence/finance/common/exception/GlobalExceptionHandler.java @@ -287,4 +287,16 @@ public ResponseEntity handleInvalidPasswordException( return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail); } + + @ExceptionHandler(WidgetNotFoundException.class) + public ResponseEntity handleWidgetNotFoundException(WidgetNotFoundException ex, WebRequest request) { + log.warn("Widget not found for request: {}", request.getDescription(false)); + + ProblemDetail problemDetail = + ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Widget could not be found."); + problemDetail.setType(URI.create(PROBLEM_BASE_URI + "widget-not-found")); + problemDetail.setTitle("Widget Not Found"); + problemDetail.setProperty("timestamp", Instant.now()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail); + } } diff --git a/backend/exence/src/main/java/com/exence/finance/common/exception/WidgetNotFoundException.java b/backend/exence/src/main/java/com/exence/finance/common/exception/WidgetNotFoundException.java new file mode 100644 index 0000000..1871289 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/exception/WidgetNotFoundException.java @@ -0,0 +1,11 @@ +package com.exence.finance.common.exception; + +public class WidgetNotFoundException extends RuntimeException { + public WidgetNotFoundException() { + super(); + } + + public WidgetNotFoundException(String context) { + super(context); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java index 46df5af..f97904f 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/entity/DailyCategoryStat.java @@ -45,22 +45,18 @@ public class DailyCategoryStat { @Column(name = "type", nullable = false) private TransactionType type; - // todo: redundáns az adat, de gyorsíthatja a lekérdezést, hogy ne kelljen joinolni a kategória nevéért?! @Column(name = "category_name", nullable = false) private String categoryName; @Column(name = "category_color", nullable = false) private String categoryColor; - // todo: az átlag kijön transactioncount/totalamount-ból @Column(name = "total_amount", nullable = false) private BigDecimal totalAmount; @Column(name = "transaction_count", nullable = false) private Long transactionCount; - // todo: ez is redundáns, de gyorsíthatja a lekérdezést, hogy ne kelljen újra iterálni a tranzakciókon a max - // megtalálásához @Column(name = "max_amount", nullable = false) private BigDecimal maxAmount; } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java index 4b30f0f..d74248d 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java @@ -1,5 +1,6 @@ package com.exence.finance.modules.statistics.service.impl; +import com.exence.finance.common.exception.WidgetNotFoundException; import com.exence.finance.modules.auth.service.UserService; import com.exence.finance.modules.statistics.dto.ChartLayoutItem; import com.exence.finance.modules.statistics.dto.StatCardLayoutItem; @@ -18,16 +19,17 @@ import com.exence.finance.modules.statistics.service.WidgetService; import com.exence.finance.modules.statistics.service.provider.WidgetDataProvider; import jakarta.annotation.PostConstruct; -import jakarta.persistence.EntityNotFoundException; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -79,7 +81,7 @@ public WidgetLayoutResponse updateLayout(UpdateLayoutRequest request) { for (StatCardLayoutItem item : request.statCards()) { Widget widget = existingWidgets.get(item.id()); if (widget == null) { - throw new EntityNotFoundException("Widget not found: " + item.id()); + throw new WidgetNotFoundException("Widget not found: " + item.id()); } widget.setDisplayOrder(item.displayOrder()); } @@ -89,7 +91,7 @@ public WidgetLayoutResponse updateLayout(UpdateLayoutRequest request) { for (ChartLayoutItem item : request.charts()) { Widget widget = existingWidgets.get(item.id()); if (widget == null) { - throw new EntityNotFoundException("Widget not found: " + item.id()); + throw new WidgetNotFoundException("Widget not found: " + item.id()); } widget.setX(item.x()); widget.setY(item.y()); @@ -101,10 +103,9 @@ public WidgetLayoutResponse updateLayout(UpdateLayoutRequest request) { @Override public WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe) { - // todo: exception Widget widget = widgetRepository .find(widgetId) - .orElseThrow(() -> new EntityNotFoundException("Widget not found: " + widgetId)); + .orElseThrow(() -> new WidgetNotFoundException("Widget not found: " + widgetId)); Timeframe resolvedTimeframe = resolveTimeframe(timeframe, widget); @@ -119,7 +120,7 @@ public WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe) { WidgetRequest request = new WidgetRequest(startDate, Instant.now(), widget.getSettings()); WidgetDataProvider provider = providerMap.get(widget.getType()); if (provider == null) { - // todo: exception + log.error("No data provider for widget: {}, type: {}", widget.getId(), widget.getType()); throw new UnsupportedOperationException("No data provider for widget type: " + widget.getType()); } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java index 1da37ee..3aab795 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseCategoryTrendProvider.java @@ -6,6 +6,7 @@ import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.util.StatisticsConstants; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; import java.util.List; @@ -30,6 +31,7 @@ public SeriesPayload getData(WidgetRequest request) { List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); - return ProviderHelper.buildMonthlyCategorySeriesPayload(results, months, "area", "todo: main piros szin kene"); + return ProviderHelper.buildMonthlyCategorySeriesPayload( + results, months, "area", StatisticsConstants.COLOR_EXPENSE_RED); } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java index a2ee92b..f31784c 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseSavingsComboProvider.java @@ -8,6 +8,7 @@ import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; import java.util.ArrayList; @@ -52,7 +53,7 @@ public SeriesPayload getData(WidgetRequest request) { }); return new SeriesPayload(List.of( - new SeriesItem("Expense", "column", "todo: piros szin", expensePoints), + new SeriesItem("Expense", "column", StatisticsConstants.COLOR_EXPENSE_RED, expensePoints), new SeriesItem("Savings Rate", "line", null, savingsRatePoints))); } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java index f106ca0..bba899d 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeCategoryTrendProvider.java @@ -6,6 +6,7 @@ import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.util.StatisticsConstants; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; import java.util.List; @@ -30,6 +31,7 @@ public SeriesPayload getData(WidgetRequest request) { List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); - return ProviderHelper.buildMonthlyCategorySeriesPayload(results, months, "area", "todo: main zöld szín kéne"); + return ProviderHelper.buildMonthlyCategorySeriesPayload( + results, months, "area", StatisticsConstants.COLOR_INCOME_GREEN); } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java index 7cb694c..083fbff 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeExpenseColumnProvider.java @@ -8,6 +8,7 @@ import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; import java.util.ArrayList; @@ -50,7 +51,7 @@ public SeriesPayload getData(WidgetRequest request) { }); return new SeriesPayload(List.of( - new SeriesItem("Income", "column", "todo: zold kene ide", incomePoints), - new SeriesItem("Expense", "column", "todo: piros kene ide", expensePoints))); + new SeriesItem("Income", "column", StatisticsConstants.COLOR_INCOME_GREEN, incomePoints), + new SeriesItem("Expense", "column", StatisticsConstants.COLOR_EXPENSE_RED, expensePoints))); } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java index 13faf01..dae74e6 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TransactionCountExpenseComboProvider.java @@ -8,6 +8,7 @@ import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; import com.exence.finance.modules.statistics.dto.projection.MonthlyIncomeExpenseProjection; import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; import java.util.ArrayList; @@ -51,7 +52,7 @@ public SeriesPayload getData(WidgetRequest request) { }); return new SeriesPayload(List.of( - new SeriesItem("Expense", "column", "todo: piros szin", expensePoints), + new SeriesItem("Expense", "column", StatisticsConstants.COLOR_EXPENSE_RED, expensePoints), new SeriesItem("Transaction Count", "line", null, countPoints))); } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java index 12ec786..ecd58b5 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WealthGrowthComboProvider.java @@ -8,6 +8,7 @@ import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; import com.exence.finance.modules.statistics.dto.projection.MonthlyBalanceProjection; import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; import java.util.ArrayList; @@ -47,7 +48,7 @@ public SeriesPayload getData(WidgetRequest request) { } return new SeriesPayload(List.of( - new SeriesItem("Profit", "column", "todo: zold szin", profitPoints), + new SeriesItem("Profit", "column", StatisticsConstants.COLOR_INCOME_GREEN, profitPoints), new SeriesItem("Cumulative Balance", "line", null, cumulativePoints))); } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/util/StatisticsConstants.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/util/StatisticsConstants.java new file mode 100644 index 0000000..0f60aca --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/util/StatisticsConstants.java @@ -0,0 +1,9 @@ +package com.exence.finance.modules.statistics.util; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class StatisticsConstants { + public static final String COLOR_INCOME_GREEN = "#48C774"; + public static final String COLOR_EXPENSE_RED = "#E55353"; +} From fc5dac1a631d71a5622e187ec39499978e5f3693 Mon Sep 17 00:00:00 2001 From: tamibalogh Date: Wed, 4 Mar 2026 20:34:21 +0100 Subject: [PATCH 3/5] EX-262: Fix user mapping --- .../java/com/exence/finance/modules/auth/mapper/UserMapper.java | 1 + .../modules/statistics/annotations/ValidWidgetLayout.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserMapper.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserMapper.java index 6c68da5..554fc55 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserMapper.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserMapper.java @@ -13,6 +13,7 @@ @Mapper(componentModel = "spring") public interface UserMapper { + @Mapping(target = "isVerified", source = "emailVerified") @Mapping(target = "username", source = "displayUsername") UserDTO mapToUserDto(User user); diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java index 1c50873..1c56735 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/annotations/ValidWidgetLayout.java @@ -15,7 +15,7 @@ @Documented public @interface ValidWidgetLayout { String message() default - "Invalid widget layout: StatCard requires displayOrder only, graph widgets require position and size" + "Invalid widget layout: StatCard requires displayOrder only, graph widgets require position (x, y)" + " fields"; Class[] groups() default {}; From 7e261b3be4f17ae192939e33f5985677284341e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hor=C3=A1nszki=20Patrik?= Date: Thu, 5 Mar 2026 21:39:27 +0100 Subject: [PATCH 4/5] EX-262: Create statcard providers --- .../exence/finance/common/util/DateUtils.java | 12 +++ .../modules/statistics/dto/Timeframe.java | 28 ++++++ .../modules/statistics/dto/WidgetRequest.java | 3 +- .../modules/statistics/dto/WidgetType.java | 21 +++- .../dto/payload/StatCardPayload.java | 2 +- .../projection/TopTransactionProjection.java | 11 +++ .../repository/StatisticsRepository.java | 51 ++++++++++ .../service/impl/WidgetServiceImpl.java | 2 +- .../provider/BurnRateStatCardProvider.java | 39 ++++++++ .../ExpenseFrequencyStatCardProvider.java | 29 ++++++ .../IncomeFrequencyStatCardProvider.java | 29 ++++++ .../provider/NoSpendDaysStatCardProvider.java | 44 +++++++++ .../service/provider/ProviderHelper.java | 97 +++++++++++++++++++ .../provider/SavingsRateStatCardProvider.java | 39 ++++++++ .../service/provider/StatCardProvider.java | 22 ----- .../TopExpenseCategoryStatCardProvider.java | 48 +++++++++ ...TopExpenseTransactionStatCardProvider.java | 43 ++++++++ .../TopIncomeCategoryStatCardProvider.java | 48 +++++++++ .../TopIncomeTransactionStatCardProvider.java | 43 ++++++++ .../service/provider/TrendResult.java | 8 ++ .../service/provider/WidgetDataProvider.java | 10 +- .../repository/TransactionRepository.java | 16 +++ 22 files changed, 617 insertions(+), 28 deletions(-) create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TopTransactionProjection.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BurnRateStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseFrequencyStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeFrequencyStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/NoSpendDaysStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsRateStatCardProvider.java delete mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseCategoryStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseTransactionStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeCategoryStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeTransactionStatCardProvider.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TrendResult.java diff --git a/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java b/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java index 2785ae9..5be1333 100644 --- a/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java +++ b/backend/exence/src/main/java/com/exence/finance/common/util/DateUtils.java @@ -6,7 +6,9 @@ import java.time.Month; import java.time.YearMonth; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.format.TextStyle; +import java.time.temporal.ChronoUnit; import java.time.temporal.IsoFields; import java.util.ArrayList; import java.util.List; @@ -54,4 +56,14 @@ public static int getIsoWeekCount(Instant instant) { int year = instant.atZone(DISPLAY_ZONE).getYear(); return LocalDate.of(year, DECEMBER, LAST_WEEK_DECEMBER_DAY).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); } + + public static long countMonths(Instant start, Instant end) { + YearMonth startMonth = YearMonth.from(start.atZone(ZoneOffset.UTC)); + YearMonth endMonth = YearMonth.from(end.atZone(ZoneOffset.UTC)); + return Math.max(1, startMonth.until(endMonth, ChronoUnit.MONTHS) + 1); + } + + public static long countDaysBetween(Instant start, Instant end) { + return Math.max(1, ChronoUnit.DAYS.between(start, end) + 1); + } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java index 5b10859..147482d 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/Timeframe.java @@ -47,4 +47,32 @@ public static Timeframe fromCode(String code) { return YTD; } + + /** + * Computes the start of the previous period for trend comparison. + * For YTD: same dates shifted back 1 year. + * For fixed durations: mirrors the duration before currentStart. + * All time has no previous period, so returns null. + */ + public Instant previousPeriodStart(Instant currentStart, Instant currentEnd) { + if (this == ALL_TIME) { + return null; + } + if (this == YTD) { + return currentStart.atZone(ZoneOffset.UTC).minusYears(1).toInstant(); + } + return ZonedDateTime.ofInstant(currentStart, ZoneOffset.UTC).minus(amount, unit).toInstant(); + } + + /** + * Computes the end of the previous period for trend comparison. + * For YTD: same end date shifted back 1 year. + * For others: the current period's start. + */ + public Instant previousPeriodEnd(Instant currentStart, Instant currentEnd) { + if (this == YTD) { + return currentEnd.atZone(ZoneOffset.UTC).minusYears(1).toInstant(); + } + return currentStart; + } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java index b357057..d60055f 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetRequest.java @@ -3,4 +3,5 @@ import java.time.Instant; import java.util.Map; -public record WidgetRequest(Instant startDate, Instant endDate, Map settings) {} +public record WidgetRequest( + Instant startDate, Instant endDate, Timeframe timeframe, Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java index cbc336f..93e5c58 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java @@ -6,7 +6,15 @@ public enum WidgetType { // --- Stat Cards --- - EXAMPLE_STATCARD, + EXPENSE_FREQUENCY_STATCARD, + INCOME_FREQUENCY_STATCARD, + NO_SPEND_DAYS_STATCARD, + TOP_EXPENSE_CATEGORY_STATCARD, + TOP_INCOME_CATEGORY_STATCARD, + TOP_EXPENSE_TRANSACTION_STATCARD, + TOP_INCOME_TRANSACTION_STATCARD, + BURN_RATE_STATCARD, + SAVINGS_RATE_STATCARD, // --- Charts --- @@ -65,7 +73,16 @@ public enum WidgetType { // Sankey CATEGORY_SANKEY; - public static final Set STAT_CARD_TYPES = EnumSet.of(WidgetType.EXAMPLE_STATCARD); + public static final Set STAT_CARD_TYPES = EnumSet.of( + WidgetType.EXPENSE_FREQUENCY_STATCARD, + WidgetType.INCOME_FREQUENCY_STATCARD, + WidgetType.NO_SPEND_DAYS_STATCARD, + WidgetType.TOP_EXPENSE_CATEGORY_STATCARD, + WidgetType.TOP_INCOME_CATEGORY_STATCARD, + WidgetType.TOP_EXPENSE_TRANSACTION_STATCARD, + WidgetType.TOP_INCOME_TRANSACTION_STATCARD, + WidgetType.BURN_RATE_STATCARD, + WidgetType.SAVINGS_RATE_STATCARD); public static final Set GRAPH_TYPES = Collections.unmodifiableSet(EnumSet.complementOf(EnumSet.copyOf(STAT_CARD_TYPES))); diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java index 5fa5fb5..b328923 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/payload/StatCardPayload.java @@ -5,9 +5,9 @@ public record StatCardPayload( BigDecimal value, String unit, + String contextLabel, BigDecimal changePercentage, Trend trend, - String contextLabel, String icon, String iconColor) implements WidgetDataPayload {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TopTransactionProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TopTransactionProjection.java new file mode 100644 index 0000000..edf1898 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TopTransactionProjection.java @@ -0,0 +1,11 @@ +package com.exence.finance.modules.statistics.dto.projection; + +import java.math.BigDecimal; + +public interface TopTransactionProjection { + BigDecimal getAmount(); + + String getTitle(); + + String getCategoryColor(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java index 20b633b..9192a40 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java @@ -14,6 +14,7 @@ import com.exence.finance.modules.statistics.entity.DailyCategoryStat; import com.exence.finance.modules.statistics.entity.DailyCategoryStatId; import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; import java.time.Instant; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -233,6 +234,56 @@ List findYearlyCategoryTotals( @Param("endDate") Instant endDate, @Param("type") TransactionType type); + // --- Stat card queries --- + + @Query(""" + SELECT COALESCE(SUM(s.totalAmount), 0) + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + """) + BigDecimal sumAmountByType( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(""" + SELECT COALESCE(SUM(s.transactionCount), 0) AS transactionCount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + """) + Long countTransactionsByType( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + + @Query(value = """ + SELECT COUNT(d.day)::bigint + FROM generate_series(CAST(:startDate AS date), CAST(:endDate AS date), '1 day'::interval) d(day) + LEFT JOIN mv_daily_category_stat s ON CAST(s.stat_date AS date) = d.day + AND s.user_id = :userId + AND CAST(s.type AS TEXT) = :#{T(com.exence.finance.modules.transaction.dto.TransactionType).EXPENSE.name()} + WHERE s.stat_date IS NULL + """, nativeQuery = true) + Long countNoSpendDays( + @Param("userId") Long userId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query(""" + SELECT s.categoryName AS categoryName, s.categoryColor AS categoryColor, + COALESCE(SUM(s.totalAmount), 0) AS totalAmount + FROM DailyCategoryStat s + WHERE s.statDate BETWEEN :startDate AND :endDate + AND s.type = :type + GROUP BY s.categoryName, s.categoryColor + ORDER BY SUM(s.totalAmount) DESC + LIMIT 1 + """) + CategoryAmountProjection findTopCategoryByType( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); + // --- Native queries (heatmap, boxplot) --- @Query(value = """ diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java index d74248d..d0ba5b2 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java @@ -117,7 +117,7 @@ public WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe) { } } - WidgetRequest request = new WidgetRequest(startDate, Instant.now(), widget.getSettings()); + WidgetRequest request = new WidgetRequest(startDate, Instant.now(), resolvedTimeframe, widget.getSettings()); WidgetDataProvider provider = providerMap.get(widget.getType()); if (provider == null) { log.error("No data provider for widget: {}, type: {}", widget.getId(), widget.getType()); diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BurnRateStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BurnRateStatCardProvider.java new file mode 100644 index 0000000..1cdf12f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/BurnRateStatCardProvider.java @@ -0,0 +1,39 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class BurnRateStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.BURN_RATE_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + BigDecimal dailyBurnRate = calculateBurnRate(request.startDate(), request.endDate()); + TrendResult trend = ProviderHelper.computeTrend(request, dailyBurnRate, this::calculateBurnRate); + return new StatCardPayload( + dailyBurnRate, "Ft", "/day", trend.changePercentage(), trend.trend(), null, null); + } + + private BigDecimal calculateBurnRate(Instant start, Instant end) { + BigDecimal expense = statisticsRepository.sumAmountByType(start, end, TransactionType.EXPENSE); + long days = DateUtils.countDaysBetween(start, end); + return expense.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseFrequencyStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseFrequencyStatCardProvider.java new file mode 100644 index 0000000..a1256f8 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ExpenseFrequencyStatCardProvider.java @@ -0,0 +1,29 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class ExpenseFrequencyStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.EXPENSE_FREQUENCY_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + long currentCount = statisticsRepository.countTransactionsByType( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + return ProviderHelper.buildFrequencyStatCard(request, currentCount, + (s, e) -> statisticsRepository.countTransactionsByType(s, e, TransactionType.EXPENSE)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeFrequencyStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeFrequencyStatCardProvider.java new file mode 100644 index 0000000..39d363e --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/IncomeFrequencyStatCardProvider.java @@ -0,0 +1,29 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class IncomeFrequencyStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.INCOME_FREQUENCY_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + long currentCount = statisticsRepository.countTransactionsByType( + request.startDate(), request.endDate(), TransactionType.INCOME); + return ProviderHelper.buildFrequencyStatCard(request, currentCount, + (s, e) -> statisticsRepository.countTransactionsByType(s, e, TransactionType.INCOME)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/NoSpendDaysStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/NoSpendDaysStatCardProvider.java new file mode 100644 index 0000000..5923990 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/NoSpendDaysStatCardProvider.java @@ -0,0 +1,44 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class NoSpendDaysStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + private final UserService userService; + + @Override + public WidgetType getSupportedType() { + return WidgetType.NO_SPEND_DAYS_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + Long userId = userService.getCurrentUserId(); + Long noSpendDays = statisticsRepository.countNoSpendDays(userId, request.startDate(), request.endDate()); + long totalDays = DateUtils.countDaysBetween(request.startDate(), request.endDate()); + + TrendResult trend = ProviderHelper.computeTrend(request, BigDecimal.valueOf(noSpendDays), + (s, e) -> BigDecimal.valueOf(statisticsRepository.countNoSpendDays(userId, s, e))); + + String unitLabel = ProviderHelper.getUnitLabel(noSpendDays, "day", "days"); + return new StatCardPayload( + BigDecimal.valueOf(noSpendDays), + unitLabel, + "/" + totalDays + " days", + trend.changePercentage(), + trend.trend(), + null, + null); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java index 1f818a8..d31702b 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/ProviderHelper.java @@ -1,16 +1,24 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.WidgetRequest; import com.exence.finance.modules.statistics.dto.payload.DataPoint; import com.exence.finance.modules.statistics.dto.payload.DistributionItem; import com.exence.finance.modules.statistics.dto.payload.DistributionPayload; import com.exence.finance.modules.statistics.dto.payload.SeriesItem; import com.exence.finance.modules.statistics.dto.payload.SeriesPayload; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.dto.payload.Trend; import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; import com.exence.finance.modules.statistics.dto.projection.MonthlyCategoryProjection; +import com.exence.finance.modules.statistics.dto.projection.TypeAmountProjection; import com.exence.finance.modules.statistics.dto.projection.base.CategoryProjection; import com.exence.finance.modules.statistics.dto.projection.base.MonthlyProjection; +import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.Instant; import java.time.YearMonth; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -18,6 +26,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; import lombok.experimental.UtilityClass; @@ -128,4 +137,92 @@ public BigDecimal toBigDecimal(Object value) { } return BigDecimal.ZERO; } + + // --- STAT CARD HELPERS --- + + public TrendResult computeTrend( + WidgetRequest request, + BigDecimal currentValue, + BiFunction valueCalculator) { + return doComputeTrend(request, currentValue, valueCalculator, ProviderHelper::calculateChangePercentage); + } + + public TrendResult computeTrendByDifference( + WidgetRequest request, + BigDecimal currentValue, + BiFunction valueCalculator) { + return doComputeTrend(request, currentValue, valueCalculator, (prev, curr) -> curr.subtract(prev)); + } + + private TrendResult doComputeTrend( + WidgetRequest request, + BigDecimal currentValue, + BiFunction valueCalculator, + BiFunction changeCalculator) { + Timeframe timeframe = request.timeframe(); + Instant prevStart = timeframe.previousPeriodStart(request.startDate(), request.endDate()); + if (prevStart == null) { + return TrendResult.NEUTRAL; + } + Instant prevEnd = timeframe.previousPeriodEnd(request.startDate(), request.endDate()); + BigDecimal prevValue = valueCalculator.apply(prevStart, prevEnd); + BigDecimal change = changeCalculator.apply(prevValue, currentValue); + return new TrendResult(change, determineTrend(change)); + } + + public StatCardPayload buildFrequencyStatCard( + WidgetRequest request, long currentCount, BiFunction countCalculator) { + long currentMonths = DateUtils.countMonths(request.startDate(), request.endDate()); + BigDecimal currentAvg = divideAsAvg(currentCount, currentMonths); + String unitLabel = getUnitLabel(currentAvg, "transaction", "transactions"); + + TrendResult trend = computeTrend(request, currentAvg, (s, e) -> { + long prevCount = countCalculator.apply(s, e); + long prevMonths = DateUtils.countMonths(s, e); + return divideAsAvg(prevCount, prevMonths); + }); + + return new StatCardPayload( + currentAvg, unitLabel, "/month", trend.changePercentage(), trend.trend(), null, null); + } + + public BigDecimal calculateChangePercentage(BigDecimal previous, BigDecimal current) { + if (previous.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + return current.subtract(previous) + .divide(previous, DIVISION_SCALE, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(PERCENTAGE_MULTIPLIER)) + .setScale(DISPLAY_SCALE, RoundingMode.HALF_UP); + } + + public Trend determineTrend(BigDecimal changePercentage) { + int cmp = changePercentage.compareTo(BigDecimal.ZERO); + if (cmp > 0) return Trend.UP; + if (cmp < 0) return Trend.DOWN; + return Trend.NEUTRAL; + } + + public Map toTypeAmountMap(List projections) { + return projections.stream() + .collect(Collectors.toMap(TypeAmountProjection::getType, TypeAmountProjection::getTotalAmount)); + } + + public BigDecimal calculatePercentage(BigDecimal part, BigDecimal total) { + if (total.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + return part.divide(total, DIVISION_SCALE, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(PERCENTAGE_MULTIPLIER)) + .setScale(1, RoundingMode.HALF_UP); + } + + public String getUnitLabel(Number value, String singular, String plural) { + BigDecimal bd = value instanceof BigDecimal bigDecimal ? bigDecimal : BigDecimal.valueOf(value.doubleValue()); + return bd.compareTo(BigDecimal.ONE) == 0 ? singular : plural; + } + + private BigDecimal divideAsAvg(long count, long months) { + return BigDecimal.valueOf(count).divide(BigDecimal.valueOf(months), 1, RoundingMode.HALF_UP); + } } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsRateStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsRateStatCardProvider.java new file mode 100644 index 0000000..f75be1a --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SavingsRateStatCardProvider.java @@ -0,0 +1,39 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class SavingsRateStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.SAVINGS_RATE_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + BigDecimal savingsRate = calculateSavingsRate(request.startDate(), request.endDate()); + TrendResult trend = ProviderHelper.computeTrendByDifference(request, savingsRate, this::calculateSavingsRate); + return new StatCardPayload(savingsRate, "%", null, trend.changePercentage(), trend.trend(), null, null); + } + + private BigDecimal calculateSavingsRate(Instant start, Instant end) { + Map sums = + ProviderHelper.toTypeAmountMap(statisticsRepository.sumByType(start, end)); + return ProviderHelper.calculateSavingsRate( + sums.getOrDefault(TransactionType.INCOME, BigDecimal.ZERO), + sums.getOrDefault(TransactionType.EXPENSE, BigDecimal.ZERO)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java deleted file mode 100644 index 42166ab..0000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/StatCardProvider.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.exence.finance.modules.statistics.service.provider; - -import com.exence.finance.modules.statistics.dto.WidgetRequest; -import com.exence.finance.modules.statistics.dto.WidgetType; -import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public final class StatCardProvider implements WidgetDataProvider { - - @Override - public WidgetType getSupportedType() { - return WidgetType.EXAMPLE_STATCARD; - } - - @Override - public StatCardPayload getData(WidgetRequest request) { - return null; - } -} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseCategoryStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseCategoryStatCardProvider.java new file mode 100644 index 0000000..aa21f5c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseCategoryStatCardProvider.java @@ -0,0 +1,48 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class TopExpenseCategoryStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.TOP_EXPENSE_CATEGORY_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + CategoryAmountProjection top = statisticsRepository.findTopCategoryByType( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + if (top == null) { + return new StatCardPayload(BigDecimal.ZERO, null, "No transactions", null, null, null, null); + } + + TrendResult trend = ProviderHelper.computeTrend(request, top.getTotalAmount(), (s, e) -> { + CategoryAmountProjection prev = + statisticsRepository.findTopCategoryByType(s, e, TransactionType.EXPENSE); + return prev != null ? prev.getTotalAmount() : BigDecimal.ZERO; + }); + + return new StatCardPayload( + top.getTotalAmount(), + null, + top.getCategoryName(), + trend.changePercentage(), + trend.trend(), + null, + top.getCategoryColor()); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseTransactionStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseTransactionStatCardProvider.java new file mode 100644 index 0000000..a9f889a --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopExpenseTransactionStatCardProvider.java @@ -0,0 +1,43 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.dto.projection.TopTransactionProjection; +import com.exence.finance.modules.transaction.dto.TransactionType; +import com.exence.finance.modules.transaction.repository.TransactionRepository; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class TopExpenseTransactionStatCardProvider implements WidgetDataProvider { + + private final TransactionRepository transactionRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.TOP_EXPENSE_TRANSACTION_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + TopTransactionProjection current = transactionRepository.findTopTransactionByType( + request.startDate(), request.endDate(), TransactionType.EXPENSE); + + if (current == null) { + return new StatCardPayload(BigDecimal.ZERO, null, "No transactions", null, null, null, null); + } + + TrendResult trend = ProviderHelper.computeTrend(request, current.getAmount(), (s, e) -> { + TopTransactionProjection prev = + transactionRepository.findTopTransactionByType(s, e, TransactionType.EXPENSE); + return prev != null ? prev.getAmount() : BigDecimal.ZERO; + }); + + return new StatCardPayload( + current.getAmount(), null, current.getTitle(), + trend.changePercentage(), trend.trend(), null, current.getCategoryColor()); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeCategoryStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeCategoryStatCardProvider.java new file mode 100644 index 0000000..cf93aeb --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeCategoryStatCardProvider.java @@ -0,0 +1,48 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.dto.projection.CategoryAmountProjection; +import com.exence.finance.modules.statistics.repository.StatisticsRepository; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class TopIncomeCategoryStatCardProvider implements WidgetDataProvider { + + private final StatisticsRepository statisticsRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.TOP_INCOME_CATEGORY_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + CategoryAmountProjection top = statisticsRepository.findTopCategoryByType( + request.startDate(), request.endDate(), TransactionType.INCOME); + + if (top == null) { + return new StatCardPayload(BigDecimal.ZERO, null, "No transactions", null, null, null, null); + } + + TrendResult trend = ProviderHelper.computeTrend(request, top.getTotalAmount(), (s, e) -> { + CategoryAmountProjection prev = + statisticsRepository.findTopCategoryByType(s, e, TransactionType.INCOME); + return prev != null ? prev.getTotalAmount() : BigDecimal.ZERO; + }); + + return new StatCardPayload( + top.getTotalAmount(), + null, + top.getCategoryName(), + trend.changePercentage(), + trend.trend(), + null, + top.getCategoryColor()); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeTransactionStatCardProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeTransactionStatCardProvider.java new file mode 100644 index 0000000..46c9e98 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TopIncomeTransactionStatCardProvider.java @@ -0,0 +1,43 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetType; +import com.exence.finance.modules.statistics.dto.payload.StatCardPayload; +import com.exence.finance.modules.statistics.dto.projection.TopTransactionProjection; +import com.exence.finance.modules.transaction.dto.TransactionType; +import com.exence.finance.modules.transaction.repository.TransactionRepository; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public final class TopIncomeTransactionStatCardProvider implements WidgetDataProvider { + + private final TransactionRepository transactionRepository; + + @Override + public WidgetType getSupportedType() { + return WidgetType.TOP_INCOME_TRANSACTION_STATCARD; + } + + @Override + public StatCardPayload getData(WidgetRequest request) { + TopTransactionProjection current = transactionRepository.findTopTransactionByType( + request.startDate(), request.endDate(), TransactionType.INCOME); + + if (current == null) { + return new StatCardPayload(BigDecimal.ZERO, null, "No transactions", null, null, null, null); + } + + TrendResult trend = ProviderHelper.computeTrend(request, current.getAmount(), (s, e) -> { + TopTransactionProjection prev = + transactionRepository.findTopTransactionByType(s, e, TransactionType.INCOME); + return prev != null ? prev.getAmount() : BigDecimal.ZERO; + }); + + return new StatCardPayload( + current.getAmount(), null, current.getTitle(), + trend.changePercentage(), trend.trend(), null, current.getCategoryColor()); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TrendResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TrendResult.java new file mode 100644 index 0000000..56a37ee --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/TrendResult.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.statistics.service.provider; + +import com.exence.finance.modules.statistics.dto.payload.Trend; +import java.math.BigDecimal; + +public record TrendResult(BigDecimal changePercentage, Trend trend) { + public static final TrendResult NEUTRAL = new TrendResult(null, Trend.NEUTRAL); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java index 2f3b5f8..54213cd 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/WidgetDataProvider.java @@ -7,6 +7,7 @@ public sealed interface WidgetDataProvider permits BalanceTrendProvider, BalanceYearComparisonProvider, + BurnRateStatCardProvider, CategoryAvgPolarProvider, CategoryBoxplotProvider, CategoryBubbleProvider, @@ -16,6 +17,8 @@ public sealed interface WidgetDataProvider ExpensePieProvider, ExpenseSavingsComboProvider, ExpenseTrendProvider, + ExpenseFrequencyStatCardProvider, + IncomeFrequencyStatCardProvider, IncomeExpenseColumnProvider, IncomeCategoryTrendProvider, IncomePieProvider, @@ -24,11 +27,16 @@ public sealed interface WidgetDataProvider MonthlyBoxplotProvider, MonthlyCategoryRadarProvider, MonthlyPeakPolarProvider, + NoSpendDaysStatCardProvider, SankeyProvider, SavingsGaugeProvider, + SavingsRateStatCardProvider, SpendingHeatmapProvider, SpendingRadarProvider, - StatCardProvider, + TopExpenseCategoryStatCardProvider, + TopIncomeCategoryStatCardProvider, + TopExpenseTransactionStatCardProvider, + TopIncomeTransactionStatCardProvider, TransactionScatterProvider, TransactionCountExpenseComboProvider, WealthGrowthComboProvider, diff --git a/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java index 915de9e..9744b98 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/transaction/repository/TransactionRepository.java @@ -1,6 +1,7 @@ package com.exence.finance.modules.transaction.repository; import com.exence.finance.modules.statistics.dto.projection.ScatterProjection; +import com.exence.finance.modules.statistics.dto.projection.TopTransactionProjection; import com.exence.finance.modules.transaction.dto.TransactionType; import com.exence.finance.modules.transaction.entity.Transaction; import java.math.BigDecimal; @@ -55,4 +56,19 @@ AND CAST(t.type AS TEXT) = :#{T(com.exence.finance.modules.transaction.dto.Trans """, nativeQuery = true) List findBoxplotByExpenseCategory( @Param("userId") Long userId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + // --- Stat card queries --- + + @Query(""" + SELECT t.amount AS amount, t.title AS title, t.category.color AS categoryColor + FROM Transaction t + WHERE t.date BETWEEN :startDate AND :endDate + AND t.type = :type + ORDER BY t.amount DESC + LIMIT 1 + """) + TopTransactionProjection findTopTransactionByType( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate, + @Param("type") TransactionType type); } From 632be1faaf99d47e1ccf509e292daf1ad68d3ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hor=C3=A1nszki=20Patrik?= Date: Sun, 8 Mar 2026 17:20:29 +0100 Subject: [PATCH 5/5] EX-262: create async concurrnet job for view refreshing --- .../exence/finance/config/AsyncConfig.java | 11 ++++ .../service/impl/CategoryServiceImpl.java | 4 ++ .../dto/response/StatCardWidgetDTO.java | 2 +- .../event/MaterializedViewRefreshEvent.java | 7 +++ .../MaterializedViewRefreshService.java | 48 ++++++++++++++++++ .../service/impl/TransactionServiceImpl.java | 6 +++ .../db/changelog/v1.1.0/changelog-v1.1.0.yaml | 4 -- .../v1.1.0/create-mv-refresh-trigger.yaml | 50 ------------------- .../v1.1.0/drop-mv-refresh-triggers.yaml | 33 ++++++++++++ .../v1.1.0/sql/create-refresh-function.sql | 7 --- .../v1.1.0/sql/create-trigger-on-category.sql | 4 -- .../sql/create-trigger-on-transaction.sql | 4 -- 12 files changed, 110 insertions(+), 70 deletions(-) create mode 100644 backend/exence/src/main/java/com/exence/finance/config/AsyncConfig.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/event/MaterializedViewRefreshEvent.java create mode 100644 backend/exence/src/main/java/com/exence/finance/modules/statistics/service/MaterializedViewRefreshService.java delete mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml create mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/drop-mv-refresh-triggers.yaml delete mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql delete mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql delete mode 100644 backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql diff --git a/backend/exence/src/main/java/com/exence/finance/config/AsyncConfig.java b/backend/exence/src/main/java/com/exence/finance/config/AsyncConfig.java new file mode 100644 index 0000000..4cd26a3 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/config/AsyncConfig.java @@ -0,0 +1,11 @@ +package com.exence.finance.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { + // Virtual thread executor is auto-configured by Spring Boot + // via spring.threads.virtual.enabled=true in application.yml +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java index f18c381..92d3f6c 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java @@ -10,9 +10,11 @@ import com.exence.finance.modules.category.mapper.CategoryMapper; import com.exence.finance.modules.category.repository.CategoryRepository; import com.exence.finance.modules.category.service.CategoryService; +import com.exence.finance.modules.statistics.event.MaterializedViewRefreshEvent; import com.exence.finance.modules.transaction.dto.request.CategoryFilter; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +25,7 @@ public class CategoryServiceImpl implements CategoryService { private final CategoryRepository categoryRepository; private final CategoryMapper categoryMapper; private final UserService userService; + private final ApplicationEventPublisher eventPublisher; public CategoryDTO getCategoryById(Long id) { Category category = categoryRepository.find(id).orElseThrow(CategoryNotFoundException::new); @@ -58,6 +61,7 @@ public CategoryDTO updateCategory(CategoryDTO categoryDTO) { categoryMapper.updateCategoryFromDto(categoryDTO, category); Category updatedCategory = categoryRepository.save(category); + eventPublisher.publishEvent(new MaterializedViewRefreshEvent()); return categoryMapper.mapToCategoryDTO(updatedCategory); } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java index 552530b..16ff83a 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/StatCardWidgetDTO.java @@ -10,5 +10,5 @@ public record StatCardWidgetDTO( WidgetType type, String title, Timeframe timeframe, - int displayOrder, + Integer displayOrder, Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/event/MaterializedViewRefreshEvent.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/event/MaterializedViewRefreshEvent.java new file mode 100644 index 0000000..c4e3df5 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/event/MaterializedViewRefreshEvent.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.event; + +/** + * Marker event published after a transaction or category mutation commits, + * signaling that the mv_daily_category_stat materialized view needs refresh. + */ +public record MaterializedViewRefreshEvent() {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/MaterializedViewRefreshService.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/MaterializedViewRefreshService.java new file mode 100644 index 0000000..d398809 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/MaterializedViewRefreshService.java @@ -0,0 +1,48 @@ +package com.exence.finance.modules.statistics.service; + +import com.exence.finance.modules.statistics.event.MaterializedViewRefreshEvent; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MaterializedViewRefreshService { + + private static final String REFRESH_SQL = "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_category_stat"; + + private final JdbcClient jdbcClient; + + private final ReentrantLock refreshLock = new ReentrantLock(); + private final AtomicBoolean refreshPending = new AtomicBoolean(false); + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRefreshRequired(MaterializedViewRefreshEvent event) { + refreshPending.set(true); + + if (refreshLock.tryLock()) { + try { + // if another thread requested refresh while we were refreshing, we need to do it again + while (refreshPending.compareAndSet(true, false)) { + log.debug("Refreshing materialized view mv_daily_category_stat"); + jdbcClient.sql(REFRESH_SQL).update(); + log.debug("Materialized view mv_daily_category_stat refreshed successfully"); + } + } catch (Exception e) { + log.error("Failed to refresh materialized view mv_daily_category_stat", e); + } finally { + refreshLock.unlock(); + } + } else { + log.debug("Materialized view refresh already in progress, request coalesced"); + } + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/transaction/service/impl/TransactionServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/transaction/service/impl/TransactionServiceImpl.java index 88462e9..84ec5a2 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/transaction/service/impl/TransactionServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/transaction/service/impl/TransactionServiceImpl.java @@ -6,6 +6,7 @@ import com.exence.finance.modules.auth.service.UserService; import com.exence.finance.modules.category.entity.Category; import com.exence.finance.modules.category.repository.CategoryRepository; +import com.exence.finance.modules.statistics.event.MaterializedViewRefreshEvent; import com.exence.finance.modules.transaction.dto.TransactionDTO; import com.exence.finance.modules.transaction.dto.TransactionType; import com.exence.finance.modules.transaction.dto.request.TransactionFilter; @@ -18,6 +19,7 @@ import com.querydsl.core.types.Predicate; import java.math.BigDecimal; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -31,6 +33,7 @@ public class TransactionServiceImpl implements TransactionService { private final CategoryRepository categoryRepository; private final UserService userService; private final TransactionMapper transactionMapper; + private final ApplicationEventPublisher eventPublisher; public TransactionDTO getTransactionById(Long id) { Transaction transaction = transactionRepository.find(id).orElseThrow(TransactionNotFoundException::new); @@ -63,6 +66,7 @@ public TransactionDTO createTransaction(TransactionDTO transactionDTO) { transaction.setUser(user); Transaction savedTransaction = transactionRepository.save(transaction); + eventPublisher.publishEvent(new MaterializedViewRefreshEvent()); return transactionMapper.mapToTransactionDTO(savedTransaction); } @@ -85,6 +89,7 @@ public TransactionDTO updateTransaction(TransactionDTO transactionDTO) { transactionMapper.updateTransactionFromDto(transactionDTO, transaction); Transaction savedTransaction = transactionRepository.save(transaction); + eventPublisher.publishEvent(new MaterializedViewRefreshEvent()); return transactionMapper.mapToTransactionDTO(savedTransaction); } @@ -93,6 +98,7 @@ public void deleteTransaction(Long id) { Transaction transaction = transactionRepository.find(id).orElseThrow(TransactionNotFoundException::new); transactionRepository.delete(transaction); + eventPublisher.publishEvent(new MaterializedViewRefreshEvent()); } public TransactionTotalsResponse getTransactionTotals() { diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml index ac73a43..99753c9 100644 --- a/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/changelog-v1.1.0.yaml @@ -27,7 +27,3 @@ databaseChangeLog: file: create-mv-daily-category-stat.yaml relativeToChangelogFile: true - - include: - file: create-mv-refresh-trigger.yaml - relativeToChangelogFile: true - diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml deleted file mode 100644 index a8a00c6..0000000 --- a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-mv-refresh-trigger.yaml +++ /dev/null @@ -1,50 +0,0 @@ -databaseChangeLog: - - changeSet: - id: create-mv-refresh-function - author: hptrk - comment: > - PL/pgSQL function that refreshes the mv_daily_category_stat materialized view concurrently. - Called by triggers on the transaction and category tables. - dbms: postgresql - changes: - - sqlFile: - splitStatements: false - path: sql/create-refresh-function.sql - relativeToChangelogFile: true - rollback: - - sql: - sql: DROP FUNCTION IF EXISTS refresh_mv_daily_category_stat() - - - changeSet: - id: create-mv-refresh-trigger-on-transaction - author: hptrk - comment: > - Fires after INSERT, UPDATE, or DELETE on the transaction table - to keep the materialized view in sync with the latest data. - Uses FOR EACH STATEMENT to avoid redundant refreshes during batch operations. - dbms: postgresql - changes: - - sqlFile: - splitStatements: false - path: sql/create-trigger-on-transaction.sql - relativeToChangelogFile: true - rollback: - - sql: - sql: DROP TRIGGER IF EXISTS trg_refresh_mv_on_transaction ON transaction - - - changeSet: - id: create-mv-refresh-trigger-on-category - author: hptrk - comment: > - Fires after UPDATE of name or color on the category table - to keep the materialized view in sync with the latest data. - Uses FOR EACH STATEMENT to avoid redundant refreshes during batch operations. - dbms: postgresql - changes: - - sqlFile: - splitStatements: false - path: sql/create-trigger-on-category.sql - relativeToChangelogFile: true - rollback: - - sql: - sql: DROP TRIGGER IF EXISTS trg_refresh_mv_on_category ON category diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/drop-mv-refresh-triggers.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/drop-mv-refresh-triggers.yaml new file mode 100644 index 0000000..9eebaf0 --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/drop-mv-refresh-triggers.yaml @@ -0,0 +1,33 @@ +databaseChangeLog: + - changeSet: + id: drop-mv-refresh-trigger-on-transaction + author: hptrk + comment: > + Remove database trigger that refreshed the materialized view on transaction changes. + MV refresh is now handled asynchronously by the application layer. + dbms: postgresql + changes: + - sql: + sql: DROP TRIGGER IF EXISTS trg_refresh_mv_on_transaction ON transaction + + - changeSet: + id: drop-mv-refresh-trigger-on-category + author: hptrk + comment: > + Remove database trigger that refreshed the materialized view on category changes. + MV refresh is now handled asynchronously by the application layer. + dbms: postgresql + changes: + - sql: + sql: DROP TRIGGER IF EXISTS trg_refresh_mv_on_category ON category + + - changeSet: + id: drop-mv-refresh-function + author: hptrk + comment: > + Remove the PL/pgSQL function that was called by the now-removed triggers. + No longer needed since MV refresh is handled by the application. + dbms: postgresql + changes: + - sql: + sql: DROP FUNCTION IF EXISTS refresh_mv_daily_category_stat() diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql deleted file mode 100644 index 1143e60..0000000 --- a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-refresh-function.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE OR REPLACE FUNCTION refresh_mv_daily_category_stat() -RETURNS TRIGGER AS $$ -BEGIN - REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_category_stat; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql deleted file mode 100644 index 5d83787..0000000 --- a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-category.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TRIGGER trg_refresh_mv_on_category -AFTER UPDATE OF name, color ON category -FOR EACH STATEMENT -EXECUTE FUNCTION refresh_mv_daily_category_stat(); diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql b/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql deleted file mode 100644 index c5b5be9..0000000 --- a/backend/exence/src/main/resources/db/changelog/v1.1.0/sql/create-trigger-on-transaction.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TRIGGER trg_refresh_mv_on_transaction -AFTER INSERT OR UPDATE OR DELETE ON transaction -FOR EACH STATEMENT -EXECUTE FUNCTION refresh_mv_daily_category_stat();