diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..68fe1786 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(find /T/coding/projects/PERSONAL/Exence -type f \\\\\\(-name *.sql -o -name V*.sql \\\\\\))", + "Bash(find /T/coding/projects/PERSONAL/Exence/backend/exence/src/main/java -type f -name *Setting* -o -name *Preference* -o -name *Configuration*)", + "Bash(./gradlew spotlessApply)", + "Bash(./gradlew build:*)", + "Bash(./gradlew test:*)" + ] + } +} diff --git a/backend/exence/CLAUDE.md b/backend/exence/CLAUDE.md new file mode 100644 index 00000000..ea0a6318 --- /dev/null +++ b/backend/exence/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Build +./gradlew build + +# Run application +./gradlew bootRun + +# Run all tests +./gradlew test + +# Run a single test class +./gradlew test --tests "com.exence.finance.validators.PasswordValidatorTest" + +# Format code (Palantir Java Format via Spotless) +./gradlew spotlessApply + +# Check formatting without applying +./gradlew spotlessCheck + +# Run Checkstyle +./gradlew checkstyleMain +``` + +The Docker Compose file for the PostgreSQL database is at `technical/exence-docker/docker-compose.yml`. + +## Architecture + +This is a Spring Boot 3.4.1 / Java 23 REST API for a personal finance application. The codebase uses virtual threads, QueryDSL for dynamic queries, Liquibase for migrations, and MapStruct for DTO mapping. + +### Module structure + +All code lives under `com.exence.finance`, split into: + +- **`modules/`** — Feature modules, each self-contained: + - `auth` — Authentication, sessions, users, JWT tokens, password management + - `category` — Transaction categories + - `transaction` — Financial transactions with dynamic filtering via QueryDSL + - `statistics` — Dashboard widgets and data providers (35+ widget types) + - `email` — Email sending, templating, and logging +- **`common/`** — Shared: annotations, validators, exceptions, DTOs, aspects, converters, utils +- **`config/`** — Spring configuration classes and `@ConfigurationProperties` +- **`security/`** — JWT filter, auth entry point, email verification interceptor + +### Layering pattern + +Each module follows: `Controller (interface) → ControllerImpl → Service (interface) → ServiceImpl → Repository → Entity` + +Controllers are defined as interfaces; implementations are in an `impl/` subpackage. This pattern applies to both controllers and services. + +### Key conventions + +- **Entities** extend `BaseAuditableEntity` (createdAt, updatedAt, createdBy, updatedBy via Spring Data auditing). +- **Mappers** use MapStruct with `componentModel = "spring"`. +- **Validation** uses custom constraint annotations (e.g., `@ValidPassword`, `@UniqueEmail`, `@ValidColor`) backed by validators in `common/validators/`. Regex patterns and length limits live in `ValidationConstants`. +- **Exceptions** are custom classes that produce `ProblemDetail` responses, handled centrally in `GlobalExceptionHandler`. +- **Transactions**: services annotate class-level `@Transactional(readOnly = true)` and override write methods with `@Transactional`. +- **Logging**: AOP-based via `ServiceLoggingAspect` using `@ServiceLogDocument`. + +### Statistics / Widget system + +`statistics` is the most complex module. Widgets are stored in the DB and each has a `WidgetType` enum value. Data is computed by `WidgetDataProvider` implementations (one per widget type, ~35 providers in `service/provider/`). Providers query `DailyCategoryStat`, a PostgreSQL materialized view refreshed via Spring events (`MaterializedViewRefreshEvent`). Dynamic filtering uses `StatisticsPredicateBuilder` (QueryDSL). + +### Security + +- JWT-based auth with access + refresh tokens stored in HTTP-only cookies. +- Password hashing with Argon2id (custom `Argon2PasswordEncoder`). +- `JwtAuthenticationFilter` validates tokens on each request. +- `EmailVerificationInterceptor` gates endpoints requiring verified email. +- Token types: `ACCESS`, `REFRESH`, `PASSWORD_RESET`, `EMAIL_VERIFICATION`. + +### Database + +- PostgreSQL 16 via Docker. +- Liquibase changelogs in `src/main/resources/db/changelog/`, versioned (`v1.0.0/`, `v1.1.0/`), YAML format. +- Dev profile loads test data from `data/test-data.yaml`. +- Native PostgreSQL enums are used for `transaction_type` and `category_type`. + +### Code quality + +- **Formatting**: Palantir Java Format enforced via Spotless — run `spotlessApply` before committing. +- **Checkstyle**: Max line length 120, max cyclomatic complexity 20. +- **Lombok** is used extensively (`@Slf4j`, `@SuperBuilder`, `@Data`, `@RequiredArgsConstructor`). +- **QueryDSL** requires APT code generation (happens during build via `annotationProcessor`). diff --git a/backend/exence/build.gradle.kts b/backend/exence/build.gradle.kts index ae8f1a3a..d1bbae4a 100644 --- a/backend/exence/build.gradle.kts +++ b/backend/exence/build.gradle.kts @@ -109,9 +109,6 @@ dependencies { annotationProcessor("jakarta.annotation:jakarta.annotation-api") annotationProcessor("jakarta.persistence:jakarta.persistence-api") - // Development tools - developmentOnly(libs.spring.boot.devtools) - // Testing testImplementation(libs.spring.boot.starter.test) testImplementation(libs.spring.security.test) @@ -120,6 +117,7 @@ dependencies { tasks.withType { options.compilerArgs.add("-Amapstruct.defaultComponentModel=spring") + options.generatedSourceOutputDirectory.set(file("build/generated/sources/annotationProcessor/java/main")) } tasks.withType { diff --git a/backend/exence/config/checkstyle/checkstyle.xml b/backend/exence/config/checkstyle/checkstyle.xml index eb45129e..b30ab6c1 100644 --- a/backend/exence/config/checkstyle/checkstyle.xml +++ b/backend/exence/config/checkstyle/checkstyle.xml @@ -84,6 +84,17 @@ + + + + + + + + + + + diff --git a/backend/exence/gradle/libs.versions.toml b/backend/exence/gradle/libs.versions.toml index 52a9dbb7..85169b76 100644 --- a/backend/exence/gradle/libs.versions.toml +++ b/backend/exence/gradle/libs.versions.toml @@ -10,7 +10,6 @@ liquibase = "4.31.0" mapstruct = "1.6.3" lombok = "1.18.38" lombok-mapstruct-binding = "0.2.0" -emoji-java = "5.1.1" postgresql = "42.7.7" mockito = "5.15.2" querydsl = "5.1.0" @@ -24,7 +23,6 @@ spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-start spring-boot-starter-mail = { module = "org.springframework.boot:spring-boot-starter-mail" } spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" } -spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools" } spring-security-test = { module = "org.springframework.security:spring-security-test" } jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwt" } jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwt" } @@ -34,7 +32,6 @@ mapstruct = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" } mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } lombok-mapstruct-binding = { module = "org.projectlombok:lombok-mapstruct-binding", version.ref = "lombok-mapstruct-binding" } -emoji-java = { module = "com.vdurmont:emoji-java", version.ref = "emoji-java" } postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } querydsl-jpa = { module = "com.querydsl:querydsl-jpa", version.ref = "querydsl" } diff --git a/backend/exence/src/main/java/com/exence/finance/common/annotations/ValidCurrency.java b/backend/exence/src/main/java/com/exence/finance/common/annotations/ValidCurrency.java new file mode 100644 index 00000000..89e4f67e --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/annotations/ValidCurrency.java @@ -0,0 +1,20 @@ +package com.exence.finance.common.annotations; + +import com.exence.finance.common.validators.ValidCurrencyValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = ValidCurrencyValidator.class) +@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidCurrency { + String message() default "Invalid currency code. Must be a valid ISO 4217 code (e.g., HUF, EUR)"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/exence/src/main/java/com/exence/finance/common/annotations/ValidLanguage.java b/backend/exence/src/main/java/com/exence/finance/common/annotations/ValidLanguage.java new file mode 100644 index 00000000..467dc09f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/annotations/ValidLanguage.java @@ -0,0 +1,20 @@ +package com.exence.finance.common.annotations; + +import com.exence.finance.common.validators.ValidLanguageValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = ValidLanguageValidator.class) +@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidLanguage { + String message() default "Invalid language code. Must be a valid ISO 639-1 code (e.g., en, hu)"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} 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 37003b95..bcef8aca 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 @@ -311,4 +311,16 @@ public ResponseEntity handleWidgetTypeMismatchException( problemDetail.setProperty("timestamp", Instant.now()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail); } + + @ExceptionHandler(InvalidWidgetSettingException.class) + public ResponseEntity handleInvalidWidgetSettingException( + InvalidWidgetSettingException ex, WebRequest request) { + log.warn("Invalid widget setting: {}", ex.getMessage()); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setType(URI.create(PROBLEM_BASE_URI + "invalid-widget-setting")); + problemDetail.setTitle("Invalid Widget Setting"); + problemDetail.setProperty("timestamp", Instant.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail); + } } diff --git a/backend/exence/src/main/java/com/exence/finance/common/exception/InvalidWidgetSettingException.java b/backend/exence/src/main/java/com/exence/finance/common/exception/InvalidWidgetSettingException.java new file mode 100644 index 00000000..b2ed328e --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/exception/InvalidWidgetSettingException.java @@ -0,0 +1,11 @@ +package com.exence.finance.common.exception; + +public class InvalidWidgetSettingException extends RuntimeException { + public InvalidWidgetSettingException() { + super(); + } + + public InvalidWidgetSettingException(String message) { + super(message); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/common/util/ValidationConstants.java b/backend/exence/src/main/java/com/exence/finance/common/util/ValidationConstants.java index cfc6bd3e..c6b92b65 100644 --- a/backend/exence/src/main/java/com/exence/finance/common/util/ValidationConstants.java +++ b/backend/exence/src/main/java/com/exence/finance/common/util/ValidationConstants.java @@ -32,6 +32,10 @@ public final class ValidationConstants { public static final String PASSWORD_DIGIT_PATTERN = ".*\\d.*"; public static final String PASSWORD_SPECIAL_CHAR_PATTERN = ".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*"; + // User settings validations + public static final int LANGUAGE_CODE_LENGTH = 2; + public static final int CURRENCY_CODE_LENGTH = 3; + // Category validations public static final int CATEGORY_NAME_MIN_LENGTH = 1; public static final int CATEGORY_NAME_MAX_LENGTH = 25; diff --git a/backend/exence/src/main/java/com/exence/finance/common/validators/ValidCurrencyValidator.java b/backend/exence/src/main/java/com/exence/finance/common/validators/ValidCurrencyValidator.java new file mode 100644 index 00000000..9245ef37 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/validators/ValidCurrencyValidator.java @@ -0,0 +1,26 @@ +package com.exence.finance.common.validators; + +import com.exence.finance.common.annotations.ValidCurrency; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Currency; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class ValidCurrencyValidator implements ConstraintValidator { + + private static final Set ISO_CURRENCIES = Currency.getAvailableCurrencies().stream() + .map(Currency::getCurrencyCode) + .collect(Collectors.toUnmodifiableSet()); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + return ISO_CURRENCIES.contains(value.toUpperCase(Locale.ROOT)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/common/validators/ValidLanguageValidator.java b/backend/exence/src/main/java/com/exence/finance/common/validators/ValidLanguageValidator.java new file mode 100644 index 00000000..5f1b2b48 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/common/validators/ValidLanguageValidator.java @@ -0,0 +1,22 @@ +package com.exence.finance.common.validators; + +import com.exence.finance.common.annotations.ValidLanguage; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Locale; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class ValidLanguageValidator implements ConstraintValidator { + + private static final Set ISO_LANGUAGES = Set.of(Locale.getISOLanguages()); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + return ISO_LANGUAGES.contains(value.toLowerCase(Locale.ROOT)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/config/QueryDslConfig.java b/backend/exence/src/main/java/com/exence/finance/config/QueryDslConfig.java new file mode 100644 index 00000000..b16716c0 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.exence.finance.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/controller/UserSettingsController.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/controller/UserSettingsController.java new file mode 100644 index 00000000..a2c7ad70 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/controller/UserSettingsController.java @@ -0,0 +1,11 @@ +package com.exence.finance.modules.auth.controller; + +import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest; +import com.exence.finance.modules.auth.dto.response.UserSettingsResponse; +import org.springframework.http.ResponseEntity; + +public interface UserSettingsController { + ResponseEntity getUserSettings(); + + ResponseEntity updateUserSettings(UpdateUserSettingsRequest request); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/controller/impl/UserSettingsControllerImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/controller/impl/UserSettingsControllerImpl.java new file mode 100644 index 00000000..c267dc68 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/controller/impl/UserSettingsControllerImpl.java @@ -0,0 +1,37 @@ +package com.exence.finance.modules.auth.controller.impl; + +import com.exence.finance.common.util.ResponseFactory; +import com.exence.finance.modules.auth.controller.UserSettingsController; +import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest; +import com.exence.finance.modules.auth.dto.response.UserSettingsResponse; +import com.exence.finance.modules.auth.service.UserSettingsService; +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.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/user/settings") +@CrossOrigin(origins = "http://localhost:4200") +@RequiredArgsConstructor +public class UserSettingsControllerImpl implements UserSettingsController { + private final UserSettingsService userSettingsService; + + @Override + @GetMapping + public ResponseEntity getUserSettings() { + return ResponseFactory.ok(userSettingsService.getCurrentUserSettings()); + } + + @Override + @PatchMapping + public ResponseEntity updateUserSettings( + @Valid @RequestBody UpdateUserSettingsRequest request) { + return ResponseFactory.ok(userSettingsService.updateCurrentUserSettings(request)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/Theme.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/Theme.java new file mode 100644 index 00000000..3fc28f55 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/Theme.java @@ -0,0 +1,22 @@ +package com.exence.finance.modules.auth.dto; + +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Theme { + LIGHT("LIGHT"), + DARK("DARK"), + BLUE_DOLPHIN("BLUE_DOLPHIN"); + + private final String value; + + public static Theme fromValue(String v) { + return Arrays.stream(Theme.values()) + .filter(x -> x.value.equals(v)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(v)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/request/UpdateUserSettingsRequest.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/request/UpdateUserSettingsRequest.java new file mode 100644 index 00000000..ebc91ff0 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/request/UpdateUserSettingsRequest.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.auth.dto.request; + +import com.exence.finance.common.annotations.ValidCurrency; +import com.exence.finance.common.annotations.ValidLanguage; +import com.exence.finance.modules.auth.dto.Theme; + +public record UpdateUserSettingsRequest( + @ValidLanguage String language, Theme primaryTheme, Theme secondaryTheme, @ValidCurrency String baseCurrency) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/response/UserSettingsResponse.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/response/UserSettingsResponse.java new file mode 100644 index 00000000..684ebcbd --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/dto/response/UserSettingsResponse.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.auth.dto.response; + +import com.exence.finance.modules.auth.dto.Theme; + +public record UserSettingsResponse(String language, Theme primaryTheme, Theme secondaryTheme, String baseCurrency) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/User.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/User.java index 7c324d2e..95c6ea54 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/User.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/User.java @@ -14,6 +14,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.SequenceGenerator; import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; @@ -38,10 +39,10 @@ @AllArgsConstructor @EqualsAndHashCode( callSuper = false, - exclude = {"transactions", "categories", "tokens", "emailLogs", "passwordHistories"}) + exclude = {"transactions", "categories", "tokens", "emailLogs", "passwordHistories", "settings"}) @ToString( callSuper = true, - exclude = {"transactions", "categories", "tokens", "password", "emailLogs", "passwordHistories"}) + exclude = {"transactions", "categories", "tokens", "password", "emailLogs", "passwordHistories", "settings"}) @Table(name = "_user") public class User implements UserDetails { @@ -85,6 +86,9 @@ public class User implements UserDetails { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) private List passwordHistories; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) + private UserSettings settings; + // Spring Security UserDetails implementation @Override public Collection getAuthorities() { diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/UserSettings.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/UserSettings.java new file mode 100644 index 00000000..e2fc8c7e --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/entity/UserSettings.java @@ -0,0 +1,68 @@ +package com.exence.finance.modules.auth.entity; + +import static com.exence.finance.common.util.ValidationConstants.CURRENCY_CODE_LENGTH; +import static com.exence.finance.common.util.ValidationConstants.LANGUAGE_CODE_LENGTH; + +import com.exence.finance.modules.auth.dto.Theme; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@SuperBuilder +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode( + callSuper = false, + exclude = {"user"}) +@ToString( + callSuper = true, + exclude = {"user"}) +@Table(name = "user_settings") +public class UserSettings { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_settings_id_seq") + @SequenceGenerator(name = "user_settings_id_seq", sequenceName = "user_settings_id_seq", allocationSize = 1) + @Column(name = "id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @NotNull + @Column(name = "language", nullable = false, length = LANGUAGE_CODE_LENGTH) + private String language; + + @NotNull + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(name = "primary_theme", nullable = false) + private Theme primaryTheme; + + @NotNull + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(name = "secondary_theme", nullable = false) + private Theme secondaryTheme; + + @NotNull + @Column(name = "base_currency", nullable = false, length = CURRENCY_CODE_LENGTH) + private String baseCurrency; +} 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 554fc554..41e062f6 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 @@ -24,6 +24,7 @@ public interface UserMapper { @Mapping(target = "transactions", ignore = true) @Mapping(target = "categories", ignore = true) @Mapping(target = "tokens", ignore = true) + @Mapping(target = "settings", ignore = true) User mapToUser(UserDTO userDTO); @Mapping(target = "id", ignore = true) @@ -33,6 +34,7 @@ public interface UserMapper { @Mapping(target = "transactions", ignore = true) @Mapping(target = "categories", ignore = true) @Mapping(target = "tokens", ignore = true) + @Mapping(target = "settings", ignore = true) void updateUserFromDto(UserDTO userDTO, @MappingTarget User user); List mapToUserDtoList(List users); @@ -43,6 +45,7 @@ public interface UserMapper { @Mapping(target = "email", ignore = true) @Mapping(target = "password", ignore = true) @Mapping(target = "lastLoginAt", ignore = true) + @Mapping(target = "settings", ignore = true) @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) void updateUserFromRequest(UpdateUserRequest request, @MappingTarget User user); } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserSettingsMapper.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserSettingsMapper.java new file mode 100644 index 00000000..43b0f7cc --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/mapper/UserSettingsMapper.java @@ -0,0 +1,21 @@ +package com.exence.finance.modules.auth.mapper; + +import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest; +import com.exence.finance.modules.auth.dto.response.UserSettingsResponse; +import com.exence.finance.modules.auth.entity.UserSettings; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; + +@Mapper(componentModel = "spring") +public interface UserSettingsMapper { + + UserSettingsResponse toResponse(UserSettings entity); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "user", ignore = true) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateFromRequest(UpdateUserSettingsRequest request, @MappingTarget UserSettings entity); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/repository/UserSettingsRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/repository/UserSettingsRepository.java new file mode 100644 index 00000000..f731e59e --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/repository/UserSettingsRepository.java @@ -0,0 +1,9 @@ +package com.exence.finance.modules.auth.repository; + +import com.exence.finance.modules.auth.entity.UserSettings; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserSettingsRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/service/UserSettingsService.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/service/UserSettingsService.java new file mode 100644 index 00000000..bfd2cde4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/service/UserSettingsService.java @@ -0,0 +1,10 @@ +package com.exence.finance.modules.auth.service; + +import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest; +import com.exence.finance.modules.auth.dto.response.UserSettingsResponse; + +public interface UserSettingsService { + UserSettingsResponse getCurrentUserSettings(); + + UserSettingsResponse updateCurrentUserSettings(UpdateUserSettingsRequest request); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/AuthServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/AuthServiceImpl.java index 8cd2da7b..fdbd24a1 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/AuthServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/AuthServiceImpl.java @@ -6,6 +6,7 @@ import com.exence.finance.common.exception.UserNotFoundException; import com.exence.finance.config.properties.EmailBusinessProperties; import com.exence.finance.modules.auth.dto.EmailType; +import com.exence.finance.modules.auth.dto.Theme; import com.exence.finance.modules.auth.dto.TokenType; import com.exence.finance.modules.auth.dto.request.EmailVerificationRequest; import com.exence.finance.modules.auth.dto.request.ForgotPasswordRequest; @@ -16,8 +17,10 @@ import com.exence.finance.modules.auth.dto.response.TokenPair; import com.exence.finance.modules.auth.entity.Token; import com.exence.finance.modules.auth.entity.User; +import com.exence.finance.modules.auth.entity.UserSettings; import com.exence.finance.modules.auth.mapper.UserMapper; import com.exence.finance.modules.auth.repository.UserRepository; +import com.exence.finance.modules.auth.repository.UserSettingsRepository; import com.exence.finance.modules.auth.service.AuthService; import com.exence.finance.modules.auth.service.CookieService; import com.exence.finance.modules.auth.service.PasswordHistoryService; @@ -59,6 +62,7 @@ public class AuthServiceImpl implements AuthService { private final PasswordHistoryService passwordHistoryService; private final EmailBusinessProperties emailBusinessProperties; private final CookieService cookieService; + private final UserSettingsRepository userSettingsRepository; @Override @Transactional @@ -66,6 +70,7 @@ public AuthenticationResponse register(RegisterRequest request) { User user = buildNewUser(request); user = userRepository.save(user); + createDefaultSettings(user); sendEmailVerification(user); return createAuthenticationResponse(user); @@ -166,6 +171,17 @@ public void resetPassword(PasswordResetRequest request) { log.info("Password reset successful for user: {}", user.getEmail()); } + private void createDefaultSettings(User user) { + UserSettings settings = UserSettings.builder() + .user(user) + .language("en") + .primaryTheme(Theme.DARK) + .secondaryTheme(Theme.BLUE_DOLPHIN) + .baseCurrency("HUF") + .build(); + userSettingsRepository.save(settings); + } + private User buildNewUser(RegisterRequest request) { return User.builder() .username(request.getUsername()) diff --git a/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/UserSettingsServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/UserSettingsServiceImpl.java new file mode 100644 index 00000000..bb86255a --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/auth/service/impl/UserSettingsServiceImpl.java @@ -0,0 +1,44 @@ +package com.exence.finance.modules.auth.service.impl; + +import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest; +import com.exence.finance.modules.auth.dto.response.UserSettingsResponse; +import com.exence.finance.modules.auth.entity.UserSettings; +import com.exence.finance.modules.auth.mapper.UserSettingsMapper; +import com.exence.finance.modules.auth.repository.UserSettingsRepository; +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.auth.service.UserSettingsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class UserSettingsServiceImpl implements UserSettingsService { + private final UserSettingsRepository userSettingsRepository; + private final UserSettingsMapper userSettingsMapper; + private final UserService userService; + + @Override + public UserSettingsResponse getCurrentUserSettings() { + return userSettingsMapper.toResponse(getCurrentSettings()); + } + + @Override + @Transactional + public UserSettingsResponse updateCurrentUserSettings(UpdateUserSettingsRequest request) { + UserSettings settings = getCurrentSettings(); + userSettingsMapper.updateFromRequest(request, settings); + settings = userSettingsRepository.save(settings); + return userSettingsMapper.toResponse(settings); + } + + private UserSettings getCurrentSettings() { + Long userId = userService.getCurrentUserId(); + return userSettingsRepository + .findByUserId(userId) + .orElseThrow(() -> new IllegalStateException("Settings not found for user " + userId)); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java index d14b70e7..dda9d08c 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java @@ -3,8 +3,10 @@ import com.exence.finance.modules.category.dto.CategorySummaryResponse; import com.exence.finance.modules.category.dto.CategoryType; import com.exence.finance.modules.category.entity.Category; +import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,6 +17,9 @@ public interface CategoryRepository extends JpaRepository { @Query("SELECT c FROM Category c WHERE c.id = :id") Optional find(Long id); + @Query("SELECT c.id FROM Category c WHERE c.id IN :ids") + Set findExistingIds(@Param("ids") Collection ids); + @Query( """ SELECT new com.exence.finance.modules.category.dto.CategorySummaryResponse( 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 deleted file mode 100644 index d37cc1e3..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItem.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.exence.finance.modules.statistics.dto; - -import jakarta.validation.constraints.NotNull; - -public record ChartLayoutItem( - @NotNull Long id, @NotNull Integer x, @NotNull Integer y, @NotNull Integer cols, @NotNull Integer rows) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItemRequest.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItemRequest.java new file mode 100644 index 00000000..d9d0ba35 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/ChartLayoutItemRequest.java @@ -0,0 +1,13 @@ +package com.exence.finance.modules.statistics.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +public record ChartLayoutItemRequest( + @NotNull Long id, + @NotNull Integer x, + @NotNull Integer y, + @NotNull Integer cols, + @NotNull Integer rows, + Map settings, + String title) {} 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 deleted file mode 100644 index 16954682..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItem.java +++ /dev/null @@ -1,5 +0,0 @@ -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/StatCardLayoutItemRequest.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItemRequest.java new file mode 100644 index 00000000..d620b853 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatCardLayoutItemRequest.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +public record StatCardLayoutItemRequest( + @NotNull Long id, @NotNull Integer displayOrder, Map settings, String title) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatisticsFilter.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatisticsFilter.java new file mode 100644 index 00000000..cdc6a9d4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/StatisticsFilter.java @@ -0,0 +1,14 @@ +package com.exence.finance.modules.statistics.dto; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.Instant; +import java.util.List; +import lombok.Builder; + +@Builder +public record StatisticsFilter(Instant startDate, Instant endDate, TransactionType type, List categoryIds) { + + public boolean hasCategoryFilter() { + return categoryIds != null && !categoryIds.isEmpty(); + } +} 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 index b0dd81b2..e790596d 100644 --- 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 @@ -3,4 +3,5 @@ import jakarta.validation.Valid; import java.util.List; -public record UpdateLayoutRequest(@Valid List statCards, @Valid List charts) {} +public record UpdateLayoutRequest( + @Valid List statCards, @Valid List charts) {} 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 d60055ff..4d6918fb 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 @@ -4,4 +4,9 @@ import java.util.Map; public record WidgetRequest( - Instant startDate, Instant endDate, Timeframe timeframe, Map settings) {} + Instant startDate, Instant endDate, Timeframe timeframe, Map settings) { + + public WidgetRequest withDates(Instant startDate, Instant endDate) { + return new WidgetRequest(startDate, endDate, this.timeframe, this.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 index ea7d9438..9baa6434 100644 --- 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 @@ -6,7 +6,8 @@ public enum WidgetSetting { ICON("icon"), ICON_COLOR("iconColor"), - CONTEXT_LABEL("contextLabel"); + CONTEXT_LABEL("contextLabel"), + CATEGORY_IDS("categoryIds"); private final String 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 index 93e5c58e..8da86082 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 @@ -89,6 +89,34 @@ public enum WidgetType { public static final Set YTD_ONLY_TYPES = EnumSet.of(WidgetType.SPENDING_HEATMAP); + public static final Set CATEGORY_FILTERABLE_TYPES = Collections.unmodifiableSet(EnumSet.of( + INCOME_TREND, + EXPENSE_TREND, + BALANCE_TREND, + INCOME_CATEGORY_TREND, + EXPENSE_CATEGORY_TREND, + BALANCE_YEAR_COMPARISON, + INCOME_EXPENSE_COLUMN, + EXPENSE_CATEGORY_COLUMN, + MONTHLY_BALANCE_COLUMN, + EXPENSE_SAVINGS_COMBO, + TRANSACTION_COUNT_EXPENSE_COMBO, + WEALTH_GROWTH_COMBO, + EXPENSE_PIE, + INCOME_PIE, + SPENDING_RADAR, + MONTHLY_CATEGORY_RADAR, + CATEGORY_AVG_POLAR, + MONTHLY_PEAK_POLAR, + CATEGORY_BUBBLE, + TRANSACTION_SCATTER, + SPENDING_HEATMAP, + CATEGORY_TREEMAP, + CATEGORY_BOXPLOT, + MONTHLY_BOXPLOT, + YEARLY_SLOPE, + CATEGORY_SANKEY)); + public boolean isStatCard() { return STAT_CARD_TYPES.contains(this); } 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 deleted file mode 100644 index 02d7afab..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAmountProjection.java +++ /dev/null @@ -1,10 +0,0 @@ -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(); - - String getCategoryIcon(); -} 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 deleted file mode 100644 index 10d0536a..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryAverageProjection.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 3e772361..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryFlowProjection.java +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index f6b60c47..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/CategoryStatsProjection.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 43580974..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/DailyTrendProjection.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index d4d489f5..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/HeatmapProjection.java +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 96f01e40..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyBalanceProjection.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 8c951247..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyCategoryProjection.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 35872afe..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyIncomeExpenseProjection.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index de1c5ab8..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/MonthlyTrendProjection.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 419336c4..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/ScatterProjection.java +++ /dev/null @@ -1,11 +0,0 @@ -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/TopTransactionProjection.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TopTransactionProjection.java deleted file mode 100644 index 1ef0b43f..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TopTransactionProjection.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.exence.finance.modules.statistics.dto.projection; - -import java.math.BigDecimal; - -public interface TopTransactionProjection { - BigDecimal getAmount(); - - String getTitle(); - - String getCategoryColor(); - - String getCategoryIcon(); -} 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 deleted file mode 100644 index bbff1c4a..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/TypeAmountProjection.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 323d30f3..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/YearlyCategoryProjection.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index eec65a25..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/CategoryProjection.java +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index a661c2db..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/projection/base/MonthlyProjection.java +++ /dev/null @@ -1,7 +0,0 @@ -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/result/CategoryAmountResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryAmountResult.java new file mode 100644 index 00000000..58253d4c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryAmountResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record CategoryAmountResult( + String categoryName, String categoryColor, String categoryIcon, BigDecimal totalAmount) + implements CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryAverageResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryAverageResult.java new file mode 100644 index 00000000..be31fb0c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryAverageResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record CategoryAverageResult(String categoryName, String categoryColor, BigDecimal avgAmount) + implements CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryBoxplotResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryBoxplotResult.java new file mode 100644 index 00000000..82cffb24 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryBoxplotResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record CategoryBoxplotResult( + String name, String color, BigDecimal min, BigDecimal q1, BigDecimal median, BigDecimal q3, BigDecimal max) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryFlowResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryFlowResult.java new file mode 100644 index 00000000..b3796c8b --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryFlowResult.java @@ -0,0 +1,8 @@ +package com.exence.finance.modules.statistics.dto.result; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; + +public record CategoryFlowResult( + TransactionType type, String categoryName, String categoryColor, BigDecimal totalAmount) + implements CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryResult.java new file mode 100644 index 00000000..6111b019 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +public interface CategoryResult { + String categoryName(); + + String categoryColor(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryStatsResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryStatsResult.java new file mode 100644 index 00000000..7c7cfb1c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/CategoryStatsResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record CategoryStatsResult( + String categoryName, String categoryColor, BigDecimal totalAmount, Long transactionCount, BigDecimal avgAmount) + implements CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/DailyTrendResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/DailyTrendResult.java new file mode 100644 index 00000000..cc2189ae --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/DailyTrendResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; +import java.time.Instant; + +public record DailyTrendResult(Instant statDate, BigDecimal totalAmount) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/HeatmapResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/HeatmapResult.java new file mode 100644 index 00000000..c4319a40 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/HeatmapResult.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record HeatmapResult(Integer dayOfWeek, Integer weekNumber, BigDecimal totalAmount) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyBalanceResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyBalanceResult.java new file mode 100644 index 00000000..f81828f4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyBalanceResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record MonthlyBalanceResult(Integer statYear, Integer statMonth, BigDecimal totalAmount) + implements MonthlyResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyBoxplotResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyBoxplotResult.java new file mode 100644 index 00000000..9b88e59c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyBoxplotResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record MonthlyBoxplotResult( + int year, int month, BigDecimal min, BigDecimal q1, BigDecimal median, BigDecimal q3, BigDecimal max) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyCategoryResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyCategoryResult.java new file mode 100644 index 00000000..f0f0fb1f --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyCategoryResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record MonthlyCategoryResult( + String categoryName, String categoryColor, Integer statYear, Integer statMonth, BigDecimal totalAmount) + implements MonthlyResult, CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyIncomeExpenseResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyIncomeExpenseResult.java new file mode 100644 index 00000000..404ecdf2 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyIncomeExpenseResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record MonthlyIncomeExpenseResult( + Integer statYear, Integer statMonth, BigDecimal incomeAmount, BigDecimal expenseAmount, Long transactionCount) + implements MonthlyResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyResult.java new file mode 100644 index 00000000..c53c6ce4 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/MonthlyResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +public interface MonthlyResult { + Integer statYear(); + + Integer statMonth(); +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/ScatterResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/ScatterResult.java new file mode 100644 index 00000000..b105df1c --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/ScatterResult.java @@ -0,0 +1,7 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; +import java.time.Instant; + +public record ScatterResult(Instant transactionDate, BigDecimal amount, String categoryName, String categoryColor) + implements CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/TopTransactionResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/TopTransactionResult.java new file mode 100644 index 00000000..d9c0af91 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/TopTransactionResult.java @@ -0,0 +1,5 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record TopTransactionResult(BigDecimal amount, String title, String categoryColor, String categoryIcon) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/TypeAmountResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/TypeAmountResult.java new file mode 100644 index 00000000..3d74f4ef --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/TypeAmountResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.math.BigDecimal; + +public record TypeAmountResult(TransactionType type, BigDecimal totalAmount) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/YearlyCategoryResult.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/YearlyCategoryResult.java new file mode 100644 index 00000000..a0c36ca8 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/result/YearlyCategoryResult.java @@ -0,0 +1,6 @@ +package com.exence.finance.modules.statistics.dto.result; + +import java.math.BigDecimal; + +public record YearlyCategoryResult(String categoryName, String categoryColor, Integer statYear, BigDecimal totalAmount) + implements CategoryResult {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsPredicateBuilder.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsPredicateBuilder.java new file mode 100644 index 00000000..bbd182f0 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsPredicateBuilder.java @@ -0,0 +1,55 @@ +package com.exence.finance.modules.statistics.repository; + +import com.exence.finance.modules.statistics.dto.StatisticsFilter; +import com.exence.finance.modules.statistics.entity.QDailyCategoryStat; +import com.exence.finance.modules.transaction.entity.QTransaction; +import com.querydsl.core.BooleanBuilder; +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class StatisticsPredicateBuilder { + + @SuppressWarnings("checkstyle:ConstantName") + private static final QDailyCategoryStat dailyCategoryStat = QDailyCategoryStat.dailyCategoryStat; + + @SuppressWarnings("checkstyle:ConstantName") + private static final QTransaction transaction = QTransaction.transaction; + + public static BooleanBuilder buildStatPredicate(StatisticsFilter filter) { + BooleanBuilder builder = new BooleanBuilder(); + + if (filter.startDate() != null) { + builder.and(dailyCategoryStat.id.statDate.goe(filter.startDate())); + } + if (filter.endDate() != null) { + builder.and(dailyCategoryStat.id.statDate.loe(filter.endDate())); + } + if (filter.type() != null) { + builder.and(dailyCategoryStat.id.type.eq(filter.type())); + } + if (filter.hasCategoryFilter()) { + builder.and(dailyCategoryStat.id.categoryId.in(filter.categoryIds())); + } + + return builder; + } + + public static BooleanBuilder buildTransactionPredicate(StatisticsFilter filter) { + BooleanBuilder builder = new BooleanBuilder(); + + if (filter.startDate() != null) { + builder.and(transaction.date.goe(filter.startDate())); + } + if (filter.endDate() != null) { + builder.and(transaction.date.loe(filter.endDate())); + } + if (filter.type() != null) { + builder.and(transaction.type.eq(filter.type())); + } + if (filter.hasCategoryFilter()) { + builder.and(transaction.category.id.in(filter.categoryIds())); + } + + return builder; + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsQueryService.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsQueryService.java new file mode 100644 index 00000000..e297cc11 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsQueryService.java @@ -0,0 +1,557 @@ +package com.exence.finance.modules.statistics.repository; + +import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; +import com.exence.finance.modules.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.dto.result.CategoryAverageResult; +import com.exence.finance.modules.statistics.dto.result.CategoryBoxplotResult; +import com.exence.finance.modules.statistics.dto.result.CategoryFlowResult; +import com.exence.finance.modules.statistics.dto.result.CategoryStatsResult; +import com.exence.finance.modules.statistics.dto.result.DailyTrendResult; +import com.exence.finance.modules.statistics.dto.result.HeatmapResult; +import com.exence.finance.modules.statistics.dto.result.MonthlyBalanceResult; +import com.exence.finance.modules.statistics.dto.result.MonthlyBoxplotResult; +import com.exence.finance.modules.statistics.dto.result.MonthlyCategoryResult; +import com.exence.finance.modules.statistics.dto.result.MonthlyIncomeExpenseResult; +import com.exence.finance.modules.statistics.dto.result.ScatterResult; +import com.exence.finance.modules.statistics.dto.result.TopTransactionResult; +import com.exence.finance.modules.statistics.dto.result.TypeAmountResult; +import com.exence.finance.modules.statistics.dto.result.YearlyCategoryResult; +import com.exence.finance.modules.statistics.entity.QDailyCategoryStat; +import com.exence.finance.modules.transaction.dto.TransactionType; +import com.exence.finance.modules.transaction.entity.QTransaction; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StatisticsQueryService { + + private static final int AVG_SCALE = 2; + + private static final QDailyCategoryStat dailyCategoryStat = QDailyCategoryStat.dailyCategoryStat; + private static final QTransaction transaction = QTransaction.transaction; + + private final JPAQueryFactory queryFactory; + private final JdbcClient jdbcClient; + private final UserService userService; + + // --- General --- + + public Instant findEarliestStatDate() { + return queryFactory + .select(dailyCategoryStat.id.statDate.min()) + .from(dailyCategoryStat) + .fetchOne(); + } + + // --- Type-level totals --- + + public List sumByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + TypeAmountResult.class, + dailyCategoryStat.id.type, + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.type) + .fetch(); + } + + // --- Daily trends --- + + public List findDailyTrendByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + DailyTrendResult.class, + dailyCategoryStat.id.statDate, + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.statDate) + .orderBy(dailyCategoryStat.id.statDate.asc()) + .fetch(); + } + + public List findCumulativeDailyBalance(StatisticsFilter filter) { + StringBuilder sql = + new StringBuilder("SELECT daily.stat_date, SUM(daily.daily_balance) OVER (ORDER BY daily.stat_date)" + + " FROM (SELECT stat_date," + + " SUM(CASE WHEN CAST(type AS TEXT) = 'INCOME'" + + " THEN total_amount ELSE -total_amount END) AS daily_balance" + + " FROM mv_daily_category_stat" + + " WHERE user_id = :userId"); + appendDateFilter(sql, filter, "stat_date"); + appendCategoryFilter(sql, filter, "category_id"); + sql.append(" GROUP BY stat_date) daily ORDER BY daily.stat_date"); + + JdbcClient.StatementSpec spec = jdbcClient.sql(sql.toString()).param("userId", userService.getCurrentUserId()); + spec = applyDateParams(spec, filter); + spec = applyCategoryParams(spec, filter); + + return spec.query( + (rs, rowNum) -> new DailyTrendResult(rs.getTimestamp(1).toInstant(), rs.getBigDecimal(2))) + .list(); + } + + // --- Monthly aggregations --- + + public List findMonthlyBalance(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + NumberExpression balanceExpr = new CaseBuilder() + .when(dailyCategoryStat.id.type.eq(TransactionType.INCOME)) + .then(dailyCategoryStat.totalAmount) + .otherwise(dailyCategoryStat.totalAmount.negate()); + + return queryFactory + .select(Projections.constructor( + MonthlyBalanceResult.class, + dailyCategoryStat.id.statDate.year(), + dailyCategoryStat.id.statDate.month(), + balanceExpr.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.statDate.year(), dailyCategoryStat.id.statDate.month()) + .orderBy( + dailyCategoryStat.id.statDate.year().asc(), + dailyCategoryStat.id.statDate.month().asc()) + .fetch(); + } + + public List findMonthlyIncomeExpense(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + NumberExpression incomeExpr = new CaseBuilder() + .when(dailyCategoryStat.id.type.eq(TransactionType.INCOME)) + .then(dailyCategoryStat.totalAmount) + .otherwise(BigDecimal.ZERO); + NumberExpression expenseExpr = new CaseBuilder() + .when(dailyCategoryStat.id.type.ne(TransactionType.INCOME)) + .then(dailyCategoryStat.totalAmount) + .otherwise(BigDecimal.ZERO); + + return queryFactory + .select(Projections.constructor( + MonthlyIncomeExpenseResult.class, + dailyCategoryStat.id.statDate.year(), + dailyCategoryStat.id.statDate.month(), + incomeExpr.sum().coalesce(BigDecimal.ZERO), + expenseExpr.sum().coalesce(BigDecimal.ZERO), + dailyCategoryStat.transactionCount.sum().coalesce(0L))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.statDate.year(), dailyCategoryStat.id.statDate.month()) + .orderBy( + dailyCategoryStat.id.statDate.year().asc(), + dailyCategoryStat.id.statDate.month().asc()) + .fetch(); + } + + public List findMonthlyPeakByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + MonthlyBalanceResult.class, + dailyCategoryStat.id.statDate.year(), + dailyCategoryStat.id.statDate.month(), + dailyCategoryStat.maxAmount.max().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.statDate.year(), dailyCategoryStat.id.statDate.month()) + .orderBy( + dailyCategoryStat.id.statDate.year().asc(), + dailyCategoryStat.id.statDate.month().asc()) + .fetch(); + } + + // --- Category totals --- + + public List findCategoryFlow(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + CategoryFlowResult.class, + dailyCategoryStat.id.type, + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.type, dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor) + .fetch(); + } + + public List findCategoryTotalsGroupedByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + CategoryFlowResult.class, + dailyCategoryStat.id.type, + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.id.type, dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor) + .orderBy( + dailyCategoryStat.id.type.asc(), + dailyCategoryStat.totalAmount.sum().desc()) + .fetch(); + } + + public List findCategoryStatsAmount(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + CategoryAmountResult.class, + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.categoryIcon, + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy( + dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor, dailyCategoryStat.categoryIcon) + .orderBy(dailyCategoryStat.categoryName.asc()) + .fetch(); + } + + public List findCategoryStatsAverage(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + var totalExpr = dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO); + var countExpr = dailyCategoryStat.transactionCount.sum().coalesce(0L); + + return queryFactory + .select(dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor, totalExpr, countExpr) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor) + .orderBy(dailyCategoryStat.categoryName.asc()) + .fetch() + .stream() + .map(row -> { + long count = row.get(countExpr); + BigDecimal total = row.get(totalExpr); + BigDecimal avg = count > 0 + ? total.divide(BigDecimal.valueOf(count), AVG_SCALE, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + return new CategoryAverageResult( + row.get(dailyCategoryStat.categoryName), row.get(dailyCategoryStat.categoryColor), avg); + }) + .toList(); + } + + public List findCategoryStatsAmountCountAverage(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + var totalExpr = dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO); + var countExpr = dailyCategoryStat.transactionCount.sum().coalesce(0L); + + return queryFactory + .select(dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor, totalExpr, countExpr) + .from(dailyCategoryStat) + .where(predicate) + .groupBy(dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor) + .orderBy(dailyCategoryStat.categoryName.asc()) + .fetch() + .stream() + .map(row -> { + long count = row.get(countExpr); + BigDecimal total = row.get(totalExpr); + BigDecimal avg = count > 0 + ? total.divide(BigDecimal.valueOf(count), AVG_SCALE, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + return new CategoryStatsResult( + row.get(dailyCategoryStat.categoryName), + row.get(dailyCategoryStat.categoryColor), + total, + count, + avg); + }) + .toList(); + } + + // --- Category trends (monthly / yearly) --- + + public List findMonthlyCategoryTotals(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + MonthlyCategoryResult.class, + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.id.statDate.year(), + dailyCategoryStat.id.statDate.month(), + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy( + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.id.statDate.year(), + dailyCategoryStat.id.statDate.month()) + .orderBy( + dailyCategoryStat.id.statDate.year().asc(), + dailyCategoryStat.id.statDate.month().asc(), + dailyCategoryStat.categoryName.asc()) + .fetch(); + } + + public List findYearlyCategoryTotals(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + YearlyCategoryResult.class, + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.id.statDate.year(), + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy( + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.id.statDate.year()) + .orderBy(dailyCategoryStat.id.statDate.year().asc(), dailyCategoryStat.categoryName.asc()) + .fetch(); + } + + // --- Stat card queries --- + + public BigDecimal sumAmountByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + BigDecimal result = queryFactory + .select(dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO)) + .from(dailyCategoryStat) + .where(predicate) + .fetchOne(); + return result != null ? result : BigDecimal.ZERO; + } + + public Long countTransactionsByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + Long result = queryFactory + .select(dailyCategoryStat.transactionCount.sum().coalesce(0L)) + .from(dailyCategoryStat) + .where(predicate) + .fetchOne(); + return result != null ? result : 0L; + } + + public CategoryAmountResult findTopCategoryByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildStatPredicate(filter); + return queryFactory + .select(Projections.constructor( + CategoryAmountResult.class, + dailyCategoryStat.categoryName, + dailyCategoryStat.categoryColor, + dailyCategoryStat.categoryIcon, + dailyCategoryStat.totalAmount.sum().coalesce(BigDecimal.ZERO))) + .from(dailyCategoryStat) + .where(predicate) + .groupBy( + dailyCategoryStat.categoryName, dailyCategoryStat.categoryColor, dailyCategoryStat.categoryIcon) + .orderBy(dailyCategoryStat.totalAmount.sum().desc()) + .limit(1) + .fetchOne(); + } + + public Long countNoSpendDays(StatisticsFilter filter) { + String endBound = filter.endDate() != null ? "CAST(:endDate AS date)" : "CAST(NOW() AS date)"; + + StringBuilder sql = new StringBuilder("SELECT COUNT(d.day)::bigint" + + " FROM generate_series(CAST(:startDate AS date), " + endBound + "," + + " '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) = 'EXPENSE'"); + appendCategoryFilter(sql, filter, "s.category_id"); + sql.append(" WHERE s.stat_date IS NULL"); + + JdbcClient.StatementSpec spec = jdbcClient + .sql(sql.toString()) + .param("userId", userService.getCurrentUserId()) + .param("startDate", toTimestamp(filter.startDate())); + if (filter.endDate() != null) { + spec = spec.param("endDate", toTimestamp(filter.endDate())); + } + spec = applyCategoryParams(spec, filter); + + return spec.query((rs, rowNum) -> rs.getLong(1)).single(); + } + + // --- Native queries (heatmap, boxplot) --- + + public List findWeeklyHeatmapExpense(StatisticsFilter filter) { + StringBuilder sql = new StringBuilder("SELECT EXTRACT(ISODOW FROM stat_date)::int," + + " EXTRACT(WEEK FROM stat_date)::int," + + " COALESCE(SUM(total_amount), 0)" + + " FROM mv_daily_category_stat" + + " WHERE user_id = :userId" + + " AND CAST(type AS TEXT) = 'EXPENSE'"); + appendDateFilter(sql, filter, "stat_date"); + appendCategoryFilter(sql, filter, "category_id"); + sql.append(" GROUP BY EXTRACT(ISODOW FROM stat_date), EXTRACT(WEEK FROM stat_date)"); + sql.append(" ORDER BY 2, 1"); + + JdbcClient.StatementSpec spec = jdbcClient.sql(sql.toString()).param("userId", userService.getCurrentUserId()); + spec = applyDateParams(spec, filter); + spec = applyCategoryParams(spec, filter); + + return spec.query((rs, rowNum) -> new HeatmapResult(rs.getInt(1), rs.getInt(2), rs.getBigDecimal(3))) + .list(); + } + + public List findBoxplotByMonthExpense(StatisticsFilter filter) { + StringBuilder sql = new StringBuilder("SELECT sub.yr, sub.mn," + + " MIN(sub.daily_total)," + + " PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY sub.daily_total)," + + " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sub.daily_total)," + + " PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY sub.daily_total)," + + " MAX(sub.daily_total)" + + " 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 CAST(type AS TEXT) = 'EXPENSE'"); + appendDateFilter(sql, filter, "stat_date"); + appendCategoryFilter(sql, filter, "category_id"); + sql.append(" GROUP BY stat_date) sub"); + sql.append(" GROUP BY sub.yr, sub.mn"); + sql.append(" ORDER BY sub.yr, sub.mn"); + + JdbcClient.StatementSpec spec = jdbcClient.sql(sql.toString()).param("userId", userService.getCurrentUserId()); + spec = applyDateParams(spec, filter); + spec = applyCategoryParams(spec, filter); + + return spec.query((rs, rowNum) -> new MonthlyBoxplotResult( + rs.getInt(1), + rs.getInt(2), + rs.getBigDecimal(3), + rs.getBigDecimal(4), + rs.getBigDecimal(5), + rs.getBigDecimal(6), + rs.getBigDecimal(7))) + .list(); + } + + // --- Transaction table queries --- + + public List findScatterData(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildTransactionPredicate(filter); + return queryFactory + .select(Projections.constructor( + ScatterResult.class, + transaction.date, + transaction.amount, + transaction.category.name, + transaction.category.color)) + .from(transaction) + .join(transaction.category) + .where(predicate) + .orderBy(transaction.date.asc()) + .fetch(); + } + + public List findBoxplotByExpenseCategory(StatisticsFilter filter) { + StringBuilder sql = new StringBuilder("SELECT c.name, c.color," + + " MIN(t.amount)," + + " PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY t.amount)," + + " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY t.amount)," + + " PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY t.amount)," + + " MAX(t.amount)" + + " FROM \"transaction\" t" + + " JOIN category c ON t.category_id = c.id" + + " WHERE t.user_id = :userId" + + " AND CAST(t.type AS TEXT) = 'EXPENSE'"); + appendDateFilter(sql, filter, "t.date"); + appendCategoryFilter(sql, filter, "t.category_id"); + sql.append(" GROUP BY c.name, c.color"); + sql.append(" ORDER BY c.name"); + + JdbcClient.StatementSpec spec = jdbcClient.sql(sql.toString()).param("userId", userService.getCurrentUserId()); + spec = applyDateParams(spec, filter); + spec = applyCategoryParams(spec, filter); + + return spec.query((rs, rowNum) -> new CategoryBoxplotResult( + rs.getString(1), + rs.getString(2), + rs.getBigDecimal(3), + rs.getBigDecimal(4), + rs.getBigDecimal(5), + rs.getBigDecimal(6), + rs.getBigDecimal(7))) + .list(); + } + + public TopTransactionResult findTopTransactionByType(StatisticsFilter filter) { + BooleanBuilder predicate = StatisticsPredicateBuilder.buildTransactionPredicate(filter); + return queryFactory + .select(Projections.constructor( + TopTransactionResult.class, + transaction.amount, + transaction.title, + transaction.category.color, + transaction.category.icon.stringValue())) + .from(transaction) + .join(transaction.category) + .where(predicate) + .orderBy(transaction.amount.desc()) + .limit(1) + .fetchOne(); + } + + // --- helpers --- + + private static void appendDateFilter(StringBuilder sql, StatisticsFilter filter, String column) { + if (filter.startDate() != null) { + sql.append(" AND ").append(column).append(" >= :startDate"); + } + if (filter.endDate() != null) { + sql.append(" AND ").append(column).append(" <= :endDate"); + } + } + + private static void appendCategoryFilter(StringBuilder sql, StatisticsFilter filter, String column) { + if (filter.hasCategoryFilter()) { + sql.append(" AND ").append(column).append(" IN (:categoryIds)"); + } + } + + private static JdbcClient.StatementSpec applyDateParams(JdbcClient.StatementSpec spec, StatisticsFilter filter) { + if (filter.startDate() != null) { + spec = spec.param("startDate", toTimestamp(filter.startDate())); + } + if (filter.endDate() != null) { + spec = spec.param("endDate", toTimestamp(filter.endDate())); + } + return spec; + } + + private static JdbcClient.StatementSpec applyCategoryParams( + JdbcClient.StatementSpec spec, StatisticsFilter filter) { + if (filter.hasCategoryFilter()) { + spec = spec.param("categoryIds", filter.categoryIds()); + } + return spec; + } + + private static Timestamp toTimestamp(Instant instant) { + return instant != null ? Timestamp.from(instant) : null; + } +} 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 deleted file mode 100644 index 4af07f26..00000000 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/repository/StatisticsRepository.java +++ /dev/null @@ -1,359 +0,0 @@ -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.math.BigDecimal; -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.id.statDate) - FROM DailyCategoryStat s - """) - Instant findEarliestStatDate(); - - // --- Type-level totals --- - - @Query( - """ - SELECT s.id.type AS type, COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - GROUP BY s.id.type - """) - List sumByType(@Param("startDate") Instant startDate, @Param("endDate") Instant endDate); - - // --- Daily trends --- - - @Query( - """ - SELECT s.id.statDate AS statDate, COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - AND s.id.type = :type - GROUP BY s.id.statDate - ORDER BY s.id.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.id.statDate) AS statYear, month(s.id.statDate) AS statMonth, - COALESCE(SUM(CASE WHEN s.id.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.id.statDate BETWEEN :startDate AND :endDate - GROUP BY year(s.id.statDate), month(s.id.statDate) - ORDER BY year(s.id.statDate), month(s.id.statDate) - """) - List findMonthlyBalance( - @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); - - @Query( - """ - SELECT year(s.id.statDate) AS statYear, month(s.id.statDate) AS statMonth, - COALESCE(SUM(CASE WHEN s.id.type = - :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} - THEN s.totalAmount ELSE 0.0 END), 0) AS incomeAmount, - COALESCE(SUM(CASE WHEN s.id.type <> - :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} - THEN s.totalAmount ELSE 0.0 END), 0) AS expenseAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - GROUP BY year(s.id.statDate), month(s.id.statDate) - ORDER BY year(s.id.statDate), month(s.id.statDate) - """) - List findMonthlyIncomeExpense( - @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); - - @Query( - """ - SELECT year(s.id.statDate) AS statYear, month(s.id.statDate) AS statMonth, - COALESCE(SUM(CASE WHEN s.id.type = - :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} - THEN s.totalAmount ELSE 0.0 END), 0) AS incomeAmount, - COALESCE(SUM(CASE WHEN s.id.type <> - :#{T(com.exence.finance.modules.transaction.dto.TransactionType).INCOME} - THEN s.totalAmount ELSE 0.0 END), 0) AS expenseAmount, - COALESCE(SUM(s.transactionCount), 0) AS transactionCount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - GROUP BY year(s.id.statDate), month(s.id.statDate) - ORDER BY year(s.id.statDate), month(s.id.statDate) - """) - List findMonthlyIncomeExpenseWithTransactionCount( - @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); - - @Query( - """ - SELECT year(s.id.statDate) AS statYear, month(s.id.statDate) AS statMonth, - COALESCE(MAX(s.maxAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - AND s.id.type = :type - GROUP BY year(s.id.statDate), month(s.id.statDate) - ORDER BY year(s.id.statDate), month(s.id.statDate) - """) - List findMonthlyPeakByType( - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - @Param("type") TransactionType type); - - // --- Category totals --- - - @Query( - """ - SELECT s.id.type AS type, s.categoryName AS categoryName, - s.categoryColor AS categoryColor, COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - GROUP BY s.id.type, s.categoryName, s.categoryColor - """) - List findCategoryFlow( - @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); - - @Query( - """ - SELECT s.id.type AS type, s.categoryName AS categoryName, - s.categoryColor AS categoryColor, COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - GROUP BY s.id.type, s.categoryName, s.categoryColor - ORDER BY s.id.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.id.statDate BETWEEN :startDate AND :endDate - AND s.id.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.id.statDate BETWEEN :startDate AND :endDate - AND s.id.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.id.statDate BETWEEN :startDate AND :endDate - AND s.id.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.id.statDate) AS statYear, month(s.id.statDate) AS statMonth, - COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - AND s.id.type = :type - GROUP BY s.categoryName, s.categoryColor, year(s.id.statDate), month(s.id.statDate) - ORDER BY year(s.id.statDate), month(s.id.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.id.statDate) AS statYear, - COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - AND s.id.type = :type - GROUP BY s.categoryName, s.categoryColor, year(s.id.statDate) - ORDER BY year(s.id.statDate), s.categoryName - """) - List findYearlyCategoryTotals( - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - @Param("type") TransactionType type); - - // --- Stat card queries --- - - @Query( - """ - SELECT COALESCE(SUM(s.totalAmount), 0) - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - AND s.id.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.id.statDate BETWEEN :startDate AND :endDate - AND s.id.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, - s.categoryIcon AS categoryIcon, COALESCE(SUM(s.totalAmount), 0) AS totalAmount - FROM DailyCategoryStat s - WHERE s.id.statDate BETWEEN :startDate AND :endDate - AND s.id.type = :type - GROUP BY s.categoryName, s.categoryColor, s.categoryIcon - 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 = - """ - 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/service/StatisticsFilterFactory.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/StatisticsFilterFactory.java new file mode 100644 index 00000000..91ec5816 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/StatisticsFilterFactory.java @@ -0,0 +1,58 @@ +package com.exence.finance.modules.statistics.service; + +import com.exence.finance.modules.statistics.dto.StatisticsFilter; +import com.exence.finance.modules.statistics.dto.WidgetRequest; +import com.exence.finance.modules.statistics.dto.WidgetSetting; +import com.exence.finance.modules.transaction.dto.TransactionType; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class StatisticsFilterFactory { + + public StatisticsFilter fromRequest(WidgetRequest request) { + return StatisticsFilter.builder() + .startDate(request.startDate()) + .endDate(request.endDate()) + .categoryIds(extractCategoryIds(request.settings())) + .build(); + } + + public StatisticsFilter fromRequest(WidgetRequest request, TransactionType type) { + return StatisticsFilter.builder() + .startDate(request.startDate()) + .endDate(request.endDate()) + .categoryIds(extractCategoryIds(request.settings())) + .type(type) + .build(); + } + + public StatisticsFilter forPeriod(Instant start, Instant end) { + return StatisticsFilter.builder().startDate(start).endDate(end).build(); + } + + public StatisticsFilter forPeriod(Instant start, Instant end, TransactionType type) { + return StatisticsFilter.builder() + .startDate(start) + .endDate(end) + .type(type) + .build(); + } + + private List extractCategoryIds(Map settings) { + if (settings == null) { + return Collections.emptyList(); + } + Object value = settings.get(WidgetSetting.CATEGORY_IDS); + if (!(value instanceof List list) || list.isEmpty()) { + return Collections.emptyList(); + } + return list.stream() + .filter(item -> item instanceof Number) + .map(item -> ((Number) item).longValue()) + .toList(); + } +} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetSettingsValidator.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetSettingsValidator.java new file mode 100644 index 00000000..87eea115 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/WidgetSettingsValidator.java @@ -0,0 +1,51 @@ +package com.exence.finance.modules.statistics.service; + +import com.exence.finance.common.exception.InvalidWidgetSettingException; +import com.exence.finance.modules.category.repository.CategoryRepository; +import com.exence.finance.modules.statistics.dto.WidgetSetting; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WidgetSettingsValidator { + + private final CategoryRepository categoryRepository; + + public void validate(Map settings) { + if (settings == null) { + return; + } + validateCategoryIds(settings); + } + + private void validateCategoryIds(Map settings) { + Object value = settings.get(WidgetSetting.CATEGORY_IDS); + if (value == null) { + return; + } + if (!(value instanceof List list)) { + throw new InvalidWidgetSettingException("categoryIds must be an array"); + } + if (list.isEmpty()) { + return; + } + boolean hasNonNumber = list.stream().anyMatch(item -> !(item instanceof Number)); + if (hasNonNumber) { + throw new InvalidWidgetSettingException("categoryIds must contain only numbers"); + } + List ids = list.stream() + .map(item -> ((Number) item).longValue()) + .distinct() + .toList(); + Set foundIds = categoryRepository.findExistingIds(ids); + if (foundIds.size() < ids.size()) { + List missing = + ids.stream().filter(id -> !foundIds.contains(id)).toList(); + throw new InvalidWidgetSettingException("Category IDs not found: " + missing); + } + } +} 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 c7f41f3b..2df01f15 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 @@ -12,9 +12,10 @@ 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.StatisticsQueryService; import com.exence.finance.modules.statistics.repository.WidgetRepository; import com.exence.finance.modules.statistics.service.WidgetService; +import com.exence.finance.modules.statistics.service.WidgetSettingsValidator; import com.exence.finance.modules.statistics.service.provider.WidgetDataProvider; import jakarta.annotation.PostConstruct; import java.time.Instant; @@ -37,8 +38,9 @@ public class WidgetServiceImpl implements WidgetService { private final WidgetMapper widgetMapper; private final WidgetRepository widgetRepository; - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; private final UserService userService; + private final WidgetSettingsValidator widgetSettingsValidator; private final List providers; private Map providerMap; @@ -63,6 +65,7 @@ public WidgetLayoutResponse getLayout() { @Override @Transactional public WidgetLayoutResponse createWidget(WidgetDTO widgetDTO) { + widgetSettingsValidator.validate(widgetDTO.settings()); Widget widget = widgetMapper.mapToWidget(widgetDTO); widget.setUser(userService.getCurrentUser()); @@ -86,6 +89,13 @@ public WidgetLayoutResponse updateLayout(UpdateLayoutRequest request) { throw new WidgetNotFoundException("Widget not found: " + item.id()); } widget.setDisplayOrder(item.displayOrder()); + if (item.settings() != null) { + widgetSettingsValidator.validate(item.settings()); + widget.setSettings(item.settings()); + } + if (item.title() != null) { + widget.setTitle(item.title()); + } incomingIds.add(item.id()); }); } @@ -100,6 +110,13 @@ public WidgetLayoutResponse updateLayout(UpdateLayoutRequest request) { widget.setY(item.y()); widget.setCols(item.cols()); widget.setRows(item.rows()); + if (item.settings() != null) { + widgetSettingsValidator.validate(item.settings()); + widget.setSettings(item.settings()); + } + if (item.title() != null) { + widget.setTitle(item.title()); + } incomingIds.add(item.id()); }); } @@ -125,7 +142,7 @@ public WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe) { Instant startDate = resolvedTimeframe.toStartDate(); if (resolvedTimeframe == Timeframe.ALL_TIME) { - Instant earliest = statisticsRepository.findEarliestStatDate(); + Instant earliest = statisticsQueryService.findEarliestStatDate(); if (earliest != null) { startDate = earliest; } 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 index 54283978..9fef9c8b 100644 --- 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 @@ -2,13 +2,14 @@ import static com.exence.finance.common.util.DateUtils.toDisplayDate; -import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,8 +18,8 @@ @RequiredArgsConstructor public final class BalanceTrendProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; - private final UserService userService; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -27,10 +28,9 @@ public WidgetType getSupportedType() { @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)) + StatisticsFilter filter = filterFactory.fromRequest(request); + List dataPoints = statisticsQueryService.findCumulativeDailyBalance(filter).stream() + .map(p -> new DataPoint(toDisplayDate(p.statDate()), p.totalAmount(), null)) .toList(); SeriesItem series = new SeriesItem("Balance", "area", null, dataPoints); 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 index ae5c6f56..09244706 100644 --- 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyBalanceResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.time.YearMonth; import java.util.LinkedHashSet; import java.util.List; @@ -23,7 +25,8 @@ public final class BalanceYearComparisonProvider implements WidgetDataProvider { private static final int MONTHS_IN_YEAR = 12; - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -32,11 +35,11 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findMonthlyBalance(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findMonthlyBalance(filter); Set years = results.stream() - .map(MonthlyBalanceProjection::getStatYear) + .map(MonthlyBalanceResult::statYear) .collect(Collectors.toCollection(LinkedHashSet::new)); List series = years.stream() @@ -47,7 +50,7 @@ public SeriesPayload getData(WidgetRequest request) { ProviderHelper.getAmount( results, YearMonth.of(year, monthNumber), - MonthlyBalanceProjection::getTotalAmount), + MonthlyBalanceResult::totalAmount), null)) .toList(); return new SeriesItem(String.valueOf(year), "line", null, points); 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 index 2b9dad6a..0fe2bccb 100644 --- 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 @@ -4,7 +4,8 @@ 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import java.math.RoundingMode; @@ -16,7 +17,8 @@ @RequiredArgsConstructor public final class BurnRateStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -31,7 +33,8 @@ public StatCardPayload getData(WidgetRequest request) { } private BigDecimal calculateBurnRate(Instant start, Instant end) { - BigDecimal expense = statisticsRepository.sumAmountByType(start, end, TransactionType.EXPENSE); + BigDecimal expense = + statisticsQueryService.sumAmountByType(filterFactory.forPeriod(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/CategoryAvgPolarProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/CategoryAvgPolarProvider.java index e2560302..0e99cc61 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class CategoryAvgPolarProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,12 +26,10 @@ public WidgetType getSupportedType() { @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(); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List items = statisticsQueryService.findCategoryStatsAverage(filter).stream() + .map(r -> new DistributionItem(r.categoryName(), r.avgAmount(), r.categoryColor())) + .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 index f83ac1f2..f532f917 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; -import com.exence.finance.modules.auth.service.UserService; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.CategoryBoxplotResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,16 +16,8 @@ @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; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -32,19 +26,12 @@ public WidgetType getSupportedType() { @Override public BoxplotPayload getData(WidgetRequest request) { - List results = transactionRepository.findBoxplotByExpenseCategory( - userService.getCurrentUserId(), request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findBoxplotByExpenseCategory(filter); 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])) + row.name(), List.of(row.min(), row.q1(), row.median(), row.q3(), row.max()), row.color())) .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 index c5496544..5bcc6e81 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,7 +17,8 @@ @RequiredArgsConstructor public final class CategoryBubbleProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -24,13 +27,13 @@ public WidgetType getSupportedType() { @Override public BubblePayload getData(WidgetRequest request) { - List allSeries = statisticsRepository - .findCategoryStatsAmountCountAverage(request.startDate(), request.endDate(), TransactionType.EXPENSE) - .stream() + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + + List allSeries = statisticsQueryService.findCategoryStatsAmountCountAverage(filter).stream() .map(result -> { BubblePoint point = new BubblePoint( - result.getTransactionCount().intValue(), result.getAvgAmount(), result.getTotalAmount()); - return new BubbleSeries(result.getCategoryName(), List.of(point), result.getCategoryColor()); + result.transactionCount().intValue(), result.avgAmount(), result.totalAmount()); + return new BubbleSeries(result.categoryName(), List.of(point), result.categoryColor()); }) .toList(); 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 index 2f9ec6df..b55ebfd5 100644 --- 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 @@ -1,12 +1,14 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryFlowResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import java.util.Map; @@ -18,7 +20,8 @@ @RequiredArgsConstructor public final class CategoryTreemapProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -27,11 +30,11 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findCategoryTotalsGroupedByType(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findCategoryTotalsGroupedByType(filter); - Map> byType = - results.stream().collect(Collectors.groupingBy(CategoryFlowProjection::getType)); + Map> byType = + results.stream().collect(Collectors.groupingBy(CategoryFlowResult::type)); List series = List.of( toSeriesItem("Expense", byType.getOrDefault(TransactionType.EXPENSE, List.of())), @@ -40,9 +43,9 @@ public SeriesPayload getData(WidgetRequest request) { return new SeriesPayload(series); } - private SeriesItem toSeriesItem(String name, List data) { + private SeriesItem toSeriesItem(String name, List data) { List points = data.stream() - .map(r -> new DataPoint(r.getCategoryName(), r.getTotalAmount(), r.getCategoryColor())) + .map(r -> new DataPoint(r.categoryName(), r.totalAmount(), r.categoryColor())) .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 index 33da4e0e..5d255f9a 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.MonthlyCategoryResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; import java.util.List; @@ -16,7 +18,8 @@ @RequiredArgsConstructor public final class ExpenseCategoryColumnProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -25,8 +28,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = statisticsRepository.findMonthlyCategoryTotals( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findMonthlyCategoryTotals(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); 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 3aab7957..419a4b06 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.MonthlyCategoryResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.statistics.util.StatisticsConstants; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; @@ -17,7 +19,8 @@ @RequiredArgsConstructor public final class ExpenseCategoryTrendProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,8 +29,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = statisticsRepository.findMonthlyCategoryTotals( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findMonthlyCategoryTotals(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); 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 index c48e5d6b..359fa0ca 100644 --- 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 @@ -1,9 +1,11 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,7 +14,8 @@ @RequiredArgsConstructor public final class ExpenseFrequencyStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -21,11 +24,13 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - long currentCount = statisticsRepository.countTransactionsByType( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter currentFilter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + long currentCount = statisticsQueryService.countTransactionsByType(currentFilter); + return ProviderHelper.buildFrequencyStatCard( request, currentCount, - (s, e) -> statisticsRepository.countTransactionsByType(s, e, TransactionType.EXPENSE)); + (s, e) -> statisticsQueryService.countTransactionsByType( + filterFactory.fromRequest(request.withDates(s, e), TransactionType.EXPENSE))); } } 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 index 4fffc3dc..65221c64 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class ExpensePieProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,8 +26,8 @@ public WidgetType getSupportedType() { @Override public DistributionPayload getData(WidgetRequest request) { - List results = statisticsRepository.findCategoryStatsAmount( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findCategoryStatsAmount(filter); 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 index f31784cf..43cffc88 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyIncomeExpenseResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; @@ -21,7 +23,8 @@ @RequiredArgsConstructor public final class ExpenseSavingsComboProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -30,8 +33,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findMonthlyIncomeExpense(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findMonthlyIncomeExpense(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); @@ -39,12 +42,12 @@ public SeriesPayload getData(WidgetRequest request) { List savingsRatePoints = new ArrayList<>(); months.forEach(month -> { - Optional value = ProviderHelper.findByMonth(results, month); + Optional value = ProviderHelper.findByMonth(results, month); BigDecimal income = - value.map(MonthlyIncomeExpenseProjection::getIncomeAmount).orElse(BigDecimal.ZERO); + value.map(MonthlyIncomeExpenseResult::incomeAmount).orElse(BigDecimal.ZERO); BigDecimal expense = - value.map(MonthlyIncomeExpenseProjection::getExpenseAmount).orElse(BigDecimal.ZERO); + value.map(MonthlyIncomeExpenseResult::expenseAmount).orElse(BigDecimal.ZERO); expensePoints.add(new DataPoint(month.toString(), expense, null)); 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 index 4f53b069..cd80d5c8 100644 --- 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 @@ -2,12 +2,14 @@ import static com.exence.finance.common.util.DateUtils.toDisplayDate; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,7 +19,8 @@ @RequiredArgsConstructor public final class ExpenseTrendProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,12 +29,10 @@ public WidgetType getSupportedType() { @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(); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List dataPoints = statisticsQueryService.findDailyTrendByType(filter).stream() + .map(p -> new DataPoint(toDisplayDate(p.statDate()), p.totalAmount(), 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 index bba899de..f6c8c128 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.MonthlyCategoryResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.statistics.util.StatisticsConstants; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; @@ -17,7 +19,8 @@ @RequiredArgsConstructor public final class IncomeCategoryTrendProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,8 +29,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = statisticsRepository.findMonthlyCategoryTotals( - request.startDate(), request.endDate(), TransactionType.INCOME); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.INCOME); + List results = statisticsQueryService.findMonthlyCategoryTotals(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); 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 083fbff0..46f8158c 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyIncomeExpenseResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; @@ -21,7 +23,8 @@ @RequiredArgsConstructor public final class IncomeExpenseColumnProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -30,8 +33,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findMonthlyIncomeExpense(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findMonthlyIncomeExpense(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); @@ -39,12 +42,12 @@ public SeriesPayload getData(WidgetRequest request) { List expensePoints = new ArrayList<>(); months.forEach(month -> { - Optional value = ProviderHelper.findByMonth(results, month); + Optional value = ProviderHelper.findByMonth(results, month); BigDecimal income = - value.map(MonthlyIncomeExpenseProjection::getIncomeAmount).orElse(BigDecimal.ZERO); + value.map(MonthlyIncomeExpenseResult::incomeAmount).orElse(BigDecimal.ZERO); BigDecimal expense = - value.map(MonthlyIncomeExpenseProjection::getExpenseAmount).orElse(BigDecimal.ZERO); + value.map(MonthlyIncomeExpenseResult::expenseAmount).orElse(BigDecimal.ZERO); incomePoints.add(new DataPoint(month.toString(), income, null)); expensePoints.add(new DataPoint(month.toString(), expense, null)); 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 index 88ba65da..b283497d 100644 --- 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 @@ -1,9 +1,11 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,7 +14,8 @@ @RequiredArgsConstructor public final class IncomeFrequencyStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -21,11 +24,13 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - long currentCount = statisticsRepository.countTransactionsByType( - request.startDate(), request.endDate(), TransactionType.INCOME); + StatisticsFilter currentFilter = filterFactory.fromRequest(request, TransactionType.INCOME); + long currentCount = statisticsQueryService.countTransactionsByType(currentFilter); + return ProviderHelper.buildFrequencyStatCard( request, currentCount, - (s, e) -> statisticsRepository.countTransactionsByType(s, e, TransactionType.INCOME)); + (s, e) -> statisticsQueryService.countTransactionsByType( + filterFactory.fromRequest(request.withDates(s, e), TransactionType.INCOME))); } } 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 index 891092ab..4c42c899 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class IncomePieProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,8 +26,8 @@ public WidgetType getSupportedType() { @Override public DistributionPayload getData(WidgetRequest request) { - List results = statisticsRepository.findCategoryStatsAmount( - request.startDate(), request.endDate(), TransactionType.INCOME); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.INCOME); + List results = statisticsQueryService.findCategoryStatsAmount(filter); 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 index 9cca6949..10b96e7d 100644 --- 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 @@ -2,12 +2,14 @@ import static com.exence.finance.common.util.DateUtils.toDisplayDate; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,7 +19,8 @@ @RequiredArgsConstructor public final class IncomeTrendProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,12 +29,10 @@ public WidgetType getSupportedType() { @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(); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.INCOME); + List dataPoints = statisticsQueryService.findDailyTrendByType(filter).stream() + .map(p -> new DataPoint(toDisplayDate(p.statDate()), p.totalAmount(), 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 index e5af1a4e..223a45ff 100644 --- 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyBalanceResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.time.YearMonth; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,7 +19,8 @@ @RequiredArgsConstructor public final class MonthlyBalanceColumnProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,15 +29,15 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findMonthlyBalance(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findMonthlyBalance(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); List points = months.stream() .map(month -> new DataPoint( month.toString(), - ProviderHelper.getAmount(results, month, MonthlyBalanceProjection::getTotalAmount), + ProviderHelper.getAmount(results, month, MonthlyBalanceResult::totalAmount), null)) .toList(); 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 index 8472ffb8..803a6896 100644 --- 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 @@ -1,12 +1,14 @@ 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.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyBoxplotResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.math.BigDecimal; import java.time.YearMonth; import java.util.Collections; @@ -18,17 +20,10 @@ @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; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -37,8 +32,8 @@ public WidgetType getSupportedType() { @Override public BoxplotPayload getData(WidgetRequest request) { - List results = statisticsRepository.findBoxplotByMonthExpense( - userService.getCurrentUserId(), request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findBoxplotByMonthExpense(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); @@ -46,17 +41,11 @@ public BoxplotPayload getData(WidgetRequest request) { List points = months.stream() .map(month -> results.stream() - .filter(row -> ((Number) row[YEAR_INDEX]).intValue() == month.getYear() - && ((Number) row[MONTH_INDEX]).intValue() == month.getMonthValue()) + .filter(row -> row.year() == month.getYear() && row.month() == 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])), + List.of(row.min(), row.q1(), row.median(), row.q3(), row.max()), null)) .orElse(new BoxplotPoint(month.toString(), zeroValues, null))) .toList(); 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 index ebd3d736..93d479bf 100644 --- 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.MonthlyCategoryResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; import java.util.List; @@ -19,7 +21,8 @@ @RequiredArgsConstructor public final class MonthlyCategoryRadarProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -28,8 +31,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = statisticsRepository.findMonthlyCategoryTotals( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findMonthlyCategoryTotals(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); Set categories = ProviderHelper.getCategories(results); 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 index 8323e99e..bcd156c5 100644 --- 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 @@ -1,12 +1,14 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.MonthlyBalanceResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.time.YearMonth; import java.util.List; @@ -17,7 +19,8 @@ @RequiredArgsConstructor public final class MonthlyPeakPolarProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,15 +29,15 @@ public WidgetType getSupportedType() { @Override public DistributionPayload getData(WidgetRequest request) { - List results = statisticsRepository.findMonthlyPeakByType( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findMonthlyPeakByType(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); List items = months.stream() .map(month -> new DistributionItem( month.toString(), - ProviderHelper.getAmount(results, month, MonthlyBalanceProjection::getTotalAmount), + ProviderHelper.getAmount(results, month, MonthlyBalanceResult::totalAmount), null)) .toList(); 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 index 58f0aaca..426c01e9 100644 --- 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 @@ -1,11 +1,12 @@ 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.StatisticsFilter; 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.math.BigDecimal; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,8 +15,8 @@ @RequiredArgsConstructor public final class NoSpendDaysStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; - private final UserService userService; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -24,14 +25,14 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - Long userId = userService.getCurrentUserId(); - Long noSpendDays = statisticsRepository.countNoSpendDays(userId, request.startDate(), request.endDate()); + StatisticsFilter currentFilter = filterFactory.fromRequest(request); + Long noSpendDays = statisticsQueryService.countNoSpendDays(currentFilter); 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))); + TrendResult trend = ProviderHelper.computeTrend(request, BigDecimal.valueOf(noSpendDays), (s, e) -> { + StatisticsFilter filter = filterFactory.fromRequest(request.withDates(s, e)); + return BigDecimal.valueOf(statisticsQueryService.countNoSpendDays(filter)); + }); String unitLabel = ProviderHelper.getUnitLabel(noSpendDays, "day", "days"); return new StatCardPayload( 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 5b424fad..063e373e 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 @@ -10,11 +10,11 @@ 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.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.dto.result.CategoryResult; +import com.exence.finance.modules.statistics.dto.result.MonthlyCategoryResult; +import com.exence.finance.modules.statistics.dto.result.MonthlyResult; +import com.exence.finance.modules.statistics.dto.result.TypeAmountResult; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import java.math.RoundingMode; @@ -41,7 +41,7 @@ public class ProviderHelper { // --- BUILDERS --- public SeriesPayload buildMonthlyCategorySeriesPayload( - List results, List months, String seriesType, String totalColor) { + List results, List months, String seriesType, String totalColor) { Map colors = getCategoryColorMap(results); // main series for each category @@ -60,9 +60,8 @@ public SeriesPayload buildMonthlyCategorySeriesPayload( List totalPoints = months.stream() .map(month -> { BigDecimal total = results.stream() - .filter(r -> - r.getStatYear() == month.getYear() && r.getStatMonth() == month.getMonthValue()) - .map(MonthlyCategoryProjection::getTotalAmount) + .filter(r -> r.statYear() == month.getYear() && r.statMonth() == month.getMonthValue()) + .map(MonthlyCategoryResult::totalAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); return new DataPoint(month.toString(), total, null); }) @@ -73,46 +72,42 @@ public SeriesPayload buildMonthlyCategorySeriesPayload( return new SeriesPayload(series); } - public DistributionPayload buildCategoryAmountDistributionPayload(List results) { + public DistributionPayload buildCategoryAmountDistributionPayload(List results) { List items = results.stream() - .map(r -> new DistributionItem(r.getCategoryName(), r.getTotalAmount(), r.getCategoryColor())) + .map(r -> new DistributionItem(r.categoryName(), r.totalAmount(), r.categoryColor())) .toList(); return new DistributionPayload(items); } // --- UTILITIES --- - public Optional findByMonth(List results, YearMonth month) { + public Optional findByMonth(List results, YearMonth month) { return results.stream() - .filter(r -> r.getStatYear() == month.getYear() && r.getStatMonth() == month.getMonthValue()) + .filter(r -> r.statYear() == month.getYear() && r.statMonth() == month.getMonthValue()) .findFirst(); } - public BigDecimal getAmount( + 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 Set getCategories(List results) { + return results.stream().map(CategoryResult::categoryName).collect(Collectors.toSet()); } - public Map getCategoryColorMap(List results) { + public Map getCategoryColorMap(List results) { return results.stream() .collect(Collectors.toMap( - CategoryProjection::getCategoryName, - CategoryProjection::getCategoryColor, - (a, b) -> a, - LinkedHashMap::new)); + CategoryResult::categoryName, CategoryResult::categoryColor, (a, b) -> a, LinkedHashMap::new)); } - public BigDecimal getCategoryAmountForMonth( - List results, String category, YearMonth month) { + 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) + .filter(r -> r.categoryName().equals(category) + && r.statYear() == month.getYear() + && r.statMonth() == month.getMonthValue()) + .map(MonthlyCategoryResult::totalAmount) .findFirst() .orElse(BigDecimal.ZERO); } @@ -199,18 +194,8 @@ public Trend determineTrend(BigDecimal changePercentage) { 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 Map toTypeAmountMap(List projections) { + return projections.stream().collect(Collectors.toMap(TypeAmountResult::type, TypeAmountResult::totalAmount)); } public String getUnitLabel(Number value, String singular, String plural) { 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 index 0f71b2f8..5d6855a2 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryFlowResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,7 +19,8 @@ public final class SankeyProvider implements WidgetDataProvider { private static final String CENTRAL_NODE = "Wallet"; - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -26,17 +29,15 @@ public WidgetType getSupportedType() { @Override public SankeyPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findCategoryFlow(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findCategoryFlow(filter); List links = results.stream() .map(p -> { - if (p.getType() == TransactionType.INCOME) { - return new SankeyLink( - p.getCategoryName(), CENTRAL_NODE, p.getTotalAmount(), p.getCategoryColor()); + if (p.type() == TransactionType.INCOME) { + return new SankeyLink(p.categoryName(), CENTRAL_NODE, p.totalAmount(), p.categoryColor()); } else { - return new SankeyLink( - CENTRAL_NODE, p.getCategoryName(), p.getTotalAmount(), p.getCategoryColor()); + return new SankeyLink(CENTRAL_NODE, p.categoryName(), p.totalAmount(), p.categoryColor()); } }) .toList(); 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 index 00802d8c..3e671478 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.TypeAmountResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import java.util.Map; @@ -16,7 +18,8 @@ @RequiredArgsConstructor public final class SavingsGaugeProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -25,9 +28,10 @@ public WidgetType getSupportedType() { @Override public GaugePayload getData(WidgetRequest request) { - Map sums = - statisticsRepository.sumByType(request.startDate(), request.endDate()).stream() - .collect(Collectors.toMap(TypeAmountProjection::getType, TypeAmountProjection::getTotalAmount)); + StatisticsFilter filter = filterFactory.fromRequest(request); + + Map sums = statisticsQueryService.sumByType(filter).stream() + .collect(Collectors.toMap(TypeAmountResult::type, TypeAmountResult::totalAmount)); BigDecimal income = sums.getOrDefault(TransactionType.INCOME, BigDecimal.ZERO); BigDecimal expense = sums.getOrDefault(TransactionType.EXPENSE, BigDecimal.ZERO); 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 index f75be1ad..063b95dc 100644 --- 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 @@ -3,7 +3,8 @@ 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.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import java.time.Instant; @@ -15,7 +16,8 @@ @RequiredArgsConstructor public final class SavingsRateStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -31,7 +33,7 @@ public StatCardPayload getData(WidgetRequest request) { private BigDecimal calculateSavingsRate(Instant start, Instant end) { Map sums = - ProviderHelper.toTypeAmountMap(statisticsRepository.sumByType(start, end)); + ProviderHelper.toTypeAmountMap(statisticsQueryService.sumByType(filterFactory.forPeriod(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/SpendingHeatmapProvider.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/provider/SpendingHeatmapProvider.java index 90e027f4..8548dfb4 100644 --- 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 @@ -1,14 +1,15 @@ 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.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.HeatmapResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -21,8 +22,8 @@ @RequiredArgsConstructor public final class SpendingHeatmapProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; - private final UserService userService; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -31,15 +32,15 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = statisticsRepository.findWeeklyHeatmapExpense( - userService.getCurrentUserId(), request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findWeeklyHeatmapExpense(filter); int totalWeeks = DateUtils.getIsoWeekCount(request.startDate()); Map> byDay = results.stream() .collect(Collectors.groupingBy( - HeatmapProjection::getDayOfWeek, - Collectors.toMap(HeatmapProjection::getWeekNumber, HeatmapProjection::getTotalAmount))); + HeatmapResult::dayOfWeek, + Collectors.toMap(HeatmapResult::weekNumber, HeatmapResult::totalAmount))); List series = new ArrayList<>(); for (int day = 1; day <= DateUtils.DAYS_PER_WEEK; day++) { 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 index 74730a7c..3f566363 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.util.List; import lombok.RequiredArgsConstructor; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class SpendingRadarProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,8 +26,8 @@ public WidgetType getSupportedType() { @Override public DistributionPayload getData(WidgetRequest request) { - List results = statisticsRepository.findCategoryStatsAmount( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findCategoryStatsAmount(filter); return ProviderHelper.buildCategoryAmountDistributionPayload(results); } 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 index 293e873e..53dd5b70 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import lombok.RequiredArgsConstructor; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class TopExpenseCategoryStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,25 +26,26 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - CategoryAmountProjection top = statisticsRepository.findTopCategoryByType( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + CategoryAmountResult top = statisticsQueryService.findTopCategoryByType(filter); 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; + TrendResult trend = ProviderHelper.computeTrend(request, top.totalAmount(), (s, e) -> { + CategoryAmountResult prev = statisticsQueryService.findTopCategoryByType( + filterFactory.fromRequest(request.withDates(s, e), TransactionType.EXPENSE)); + return prev != null ? prev.totalAmount() : BigDecimal.ZERO; }); return new StatCardPayload( - top.getTotalAmount(), + top.totalAmount(), null, - top.getCategoryName(), + top.categoryName(), trend.changePercentage(), trend.trend(), - top.getCategoryIcon(), - top.getCategoryColor()); + top.categoryIcon(), + top.categoryColor()); } } 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 index cb893a28..db36c322 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.TopTransactionResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; 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; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class TopExpenseTransactionStatCardProvider implements WidgetDataProvider { - private final TransactionRepository transactionRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,26 +26,26 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - TopTransactionProjection current = transactionRepository.findTopTransactionByType( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + TopTransactionResult current = statisticsQueryService.findTopTransactionByType(filter); 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; + TrendResult trend = ProviderHelper.computeTrend(request, current.amount(), (s, e) -> { + TopTransactionResult prev = statisticsQueryService.findTopTransactionByType( + filterFactory.fromRequest(request.withDates(s, e), TransactionType.EXPENSE)); + return prev != null ? prev.amount() : BigDecimal.ZERO; }); return new StatCardPayload( - current.getAmount(), + current.amount(), null, - current.getTitle(), + current.title(), trend.changePercentage(), trend.trend(), - current.getCategoryIcon(), - current.getCategoryColor()); + current.categoryIcon(), + current.categoryColor()); } } 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 index c9f24ff8..14bc93e1 100644 --- 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 @@ -1,10 +1,12 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.CategoryAmountResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import lombok.RequiredArgsConstructor; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class TopIncomeCategoryStatCardProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,25 +26,26 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - CategoryAmountProjection top = statisticsRepository.findTopCategoryByType( - request.startDate(), request.endDate(), TransactionType.INCOME); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.INCOME); + CategoryAmountResult top = statisticsQueryService.findTopCategoryByType(filter); 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; + TrendResult trend = ProviderHelper.computeTrend(request, top.totalAmount(), (s, e) -> { + CategoryAmountResult prev = statisticsQueryService.findTopCategoryByType( + filterFactory.fromRequest(request.withDates(s, e), TransactionType.INCOME)); + return prev != null ? prev.totalAmount() : BigDecimal.ZERO; }); return new StatCardPayload( - top.getTotalAmount(), + top.totalAmount(), null, - top.getCategoryName(), + top.categoryName(), trend.changePercentage(), trend.trend(), - top.getCategoryIcon(), - top.getCategoryColor()); + top.categoryIcon(), + top.categoryColor()); } } 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 index 7ea367a6..04390073 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.TopTransactionResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; 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; @@ -14,7 +16,8 @@ @RequiredArgsConstructor public final class TopIncomeTransactionStatCardProvider implements WidgetDataProvider { - private final TransactionRepository transactionRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -23,26 +26,26 @@ public WidgetType getSupportedType() { @Override public StatCardPayload getData(WidgetRequest request) { - TopTransactionProjection current = transactionRepository.findTopTransactionByType( - request.startDate(), request.endDate(), TransactionType.INCOME); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.INCOME); + TopTransactionResult current = statisticsQueryService.findTopTransactionByType(filter); 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; + TrendResult trend = ProviderHelper.computeTrend(request, current.amount(), (s, e) -> { + TopTransactionResult prev = statisticsQueryService.findTopTransactionByType( + filterFactory.fromRequest(request.withDates(s, e), TransactionType.INCOME)); + return prev != null ? prev.amount() : BigDecimal.ZERO; }); return new StatCardPayload( - current.getAmount(), + current.amount(), null, - current.getTitle(), + current.title(), trend.changePercentage(), trend.trend(), - current.getCategoryIcon(), - current.getCategoryColor()); + current.categoryIcon(), + current.categoryColor()); } } 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 dae74e69..e77f74fc 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyIncomeExpenseResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; @@ -21,7 +23,8 @@ @RequiredArgsConstructor public final class TransactionCountExpenseComboProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -30,9 +33,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findMonthlyIncomeExpenseWithTransactionCount( - request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findMonthlyIncomeExpense(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); @@ -40,12 +42,11 @@ public SeriesPayload getData(WidgetRequest request) { List countPoints = new ArrayList<>(); months.forEach(month -> { - Optional value = ProviderHelper.findByMonth(results, month); + Optional value = ProviderHelper.findByMonth(results, month); BigDecimal expense = - value.map(MonthlyIncomeExpenseProjection::getExpenseAmount).orElse(BigDecimal.ZERO); - long count = value.map(MonthlyIncomeExpenseProjection::getTransactionCount) - .orElse(0L); + value.map(MonthlyIncomeExpenseResult::expenseAmount).orElse(BigDecimal.ZERO); + long count = value.map(MonthlyIncomeExpenseResult::transactionCount).orElse(0L); expensePoints.add(new DataPoint(month.toString(), expense, null)); countPoints.add(new DataPoint(month.toString(), BigDecimal.valueOf(count), null)); 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 index 79644e51..107ce3fc 100644 --- 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 @@ -2,14 +2,16 @@ import static com.exence.finance.common.util.DateUtils.toDisplayDate; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.ScatterResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; 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; @@ -21,7 +23,8 @@ @RequiredArgsConstructor public final class TransactionScatterProvider implements WidgetDataProvider { - private final TransactionRepository transactionRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -30,18 +33,18 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - transactionRepository.findScatterData(request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findScatterData(filter); Map categoryColorMap = ProviderHelper.getCategoryColorMap(results); Map> categoryPoints = new LinkedHashMap<>(); categoryColorMap.keySet().forEach(cat -> categoryPoints.put(cat, new ArrayList<>())); - for (ScatterProjection p : results) { + for (ScatterResult p : results) { categoryPoints - .get(p.getCategoryName()) - .add(new DataPoint(toDisplayDate(p.getTransactionDate()), p.getAmount(), null)); + .get(p.categoryName()) + .add(new DataPoint(toDisplayDate(p.transactionDate()), p.amount(), null)); } List series = categoryPoints.entrySet().stream() 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 ecd58b50..f1278e49 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 @@ -1,13 +1,15 @@ package com.exence.finance.modules.statistics.service.provider; import com.exence.finance.common.util.DateUtils; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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 com.exence.finance.modules.statistics.dto.result.MonthlyBalanceResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.statistics.util.StatisticsConstants; import java.math.BigDecimal; import java.time.YearMonth; @@ -20,7 +22,8 @@ @RequiredArgsConstructor public final class WealthGrowthComboProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -29,8 +32,8 @@ public WidgetType getSupportedType() { @Override public SeriesPayload getData(WidgetRequest request) { - List results = - statisticsRepository.findMonthlyBalance(request.startDate(), request.endDate()); + StatisticsFilter filter = filterFactory.fromRequest(request); + List results = statisticsQueryService.findMonthlyBalance(filter); List months = DateUtils.getMonthsInRange(request.startDate(), request.endDate()); @@ -39,7 +42,7 @@ public SeriesPayload getData(WidgetRequest request) { BigDecimal cumulative = BigDecimal.ZERO; for (YearMonth month : months) { - BigDecimal balance = ProviderHelper.getAmount(results, month, MonthlyBalanceProjection::getTotalAmount); + BigDecimal balance = ProviderHelper.getAmount(results, month, MonthlyBalanceResult::totalAmount); cumulative = cumulative.add(balance); 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 index d956e7d9..c264ff6d 100644 --- 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 @@ -1,11 +1,13 @@ package com.exence.finance.modules.statistics.service.provider; +import com.exence.finance.modules.statistics.dto.StatisticsFilter; 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.statistics.dto.result.YearlyCategoryResult; +import com.exence.finance.modules.statistics.repository.StatisticsQueryService; +import com.exence.finance.modules.statistics.service.StatisticsFilterFactory; import com.exence.finance.modules.transaction.dto.TransactionType; import java.math.BigDecimal; import java.util.LinkedHashMap; @@ -19,7 +21,8 @@ @RequiredArgsConstructor public final class YearlySlopeProvider implements WidgetDataProvider { - private final StatisticsRepository statisticsRepository; + private final StatisticsQueryService statisticsQueryService; + private final StatisticsFilterFactory filterFactory; @Override public WidgetType getSupportedType() { @@ -28,18 +31,17 @@ public WidgetType getSupportedType() { @Override public SlopePayload getData(WidgetRequest request) { - List results = statisticsRepository.findYearlyCategoryTotals( - request.startDate(), request.endDate(), TransactionType.EXPENSE); + StatisticsFilter filter = filterFactory.fromRequest(request, TransactionType.EXPENSE); + List results = statisticsQueryService.findYearlyCategoryTotals(filter); Map categoryMap = new LinkedHashMap<>(); TreeSet allYears = new TreeSet<>(); - for (YearlyCategoryProjection p : results) { - String year = String.valueOf(p.getStatYear()); + for (YearlyCategoryResult p : results) { + String year = String.valueOf(p.statYear()); allYears.add(year); - SlopeEntry entry = - categoryMap.computeIfAbsent(p.getCategoryName(), k -> new SlopeEntry(p.getCategoryColor())); - entry.yearsData.put(year, p.getTotalAmount()); + SlopeEntry entry = categoryMap.computeIfAbsent(p.categoryName(), k -> new SlopeEntry(p.categoryColor())); + entry.yearsData.put(year, p.totalAmount()); } List items = categoryMap.entrySet().stream() 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 9238f4e5..2ea40f49 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,12 +1,8 @@ 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; -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; @@ -23,58 +19,4 @@ 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); - - // --- Stat card queries --- - - @Query( - """ - SELECT t.amount AS amount, t.title AS title, - t.category.color AS categoryColor, t.category.icon AS categoryIcon - 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); } diff --git a/backend/exence/src/main/resources/db/changelog/data/test-data.yaml b/backend/exence/src/main/resources/db/changelog/data/test-data.yaml index 12eb6cfa..a6d3b7b3 100644 --- a/backend/exence/src/main/resources/db/changelog/data/test-data.yaml +++ b/backend/exence/src/main/resources/db/changelog/data/test-data.yaml @@ -5298,6 +5298,17 @@ databaseChangeLog: - column: { name: created_at, valueComputed: "${now}" } - column: { name: created_by, value: 'captainwinnie@exence.com' } + # User Settings + - insert: + tableName: user_settings + columns: + - column: { name: id, valueNumeric: 1 } + - column: { name: user_id, valueNumeric: 1 } + - column: { name: language, value: 'en' } + - column: { name: primary_theme, value: 'DARK' } + - column: { name: secondary_theme, value: 'BLUE_DOLPHIN' } + - column: { name: base_currency, value: 'HUF' } + # Reset sequences to continue from the max ID in test data - sql: sql: SELECT setval('user_id_seq', (SELECT COALESCE(MAX(id), 1) FROM _user)); @@ -5311,4 +5322,6 @@ databaseChangeLog: sql: SELECT setval('email_log_id_seq', (SELECT COALESCE(MAX(id), 1) FROM email_log)); - sql: sql: SELECT setval('password_history_id_seq', (SELECT COALESCE(MAX(id), 1) FROM password_history)); + - sql: + sql: SELECT setval('user_settings_id_seq', (SELECT COALESCE(MAX(id), 1) FROM user_settings)); 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 99753c96..660d4d26 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,3 +27,11 @@ databaseChangeLog: file: create-mv-daily-category-stat.yaml relativeToChangelogFile: true + - include: + file: create-theme-enum.yaml + relativeToChangelogFile: true + + - include: + file: create-user-settings-table.yaml + relativeToChangelogFile: true + diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-theme-enum.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-theme-enum.yaml new file mode 100644 index 00000000..04953fec --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-theme-enum.yaml @@ -0,0 +1,9 @@ +databaseChangeLog: + - changeSet: + id: create-theme-enum + author: hptrk + comment: Create native PostgreSQL enum type for theme + dbms: postgresql + changes: + - sql: + sql: CREATE TYPE theme AS ENUM ('LIGHT', 'DARK', 'BLUE_DOLPHIN') diff --git a/backend/exence/src/main/resources/db/changelog/v1.1.0/create-user-settings-table.yaml b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-user-settings-table.yaml new file mode 100644 index 00000000..1e2ed42a --- /dev/null +++ b/backend/exence/src/main/resources/db/changelog/v1.1.0/create-user-settings-table.yaml @@ -0,0 +1,62 @@ +databaseChangeLog: + - changeSet: + id: create-user-settings-sequence + author: hptrk + comment: Create sequence for user_settings ID generation + changes: + - createSequence: + sequenceName: user_settings_id_seq + startValue: 1 + incrementBy: 1 + rollback: + - dropSequence: + sequenceName: user_settings_id_seq + + - changeSet: + id: create-user-settings-table + author: hptrk + comment: Create user_settings table for user preferences + changes: + - createTable: + tableName: user_settings + columns: + - column: + name: id + type: BIGINT + constraints: + primaryKey: true + primaryKeyName: pk_user_settings + nullable: false + - column: + name: user_id + type: BIGINT + constraints: + nullable: false + unique: true + uniqueConstraintName: uk_user_settings_user_id + foreignKeyName: fk_user_settings_user + referencedTableName: _user + referencedColumnNames: id + deleteCascade: true + - column: + name: language + type: VARCHAR(2) + defaultValue: en + constraints: + nullable: false + - column: + name: primary_theme + type: theme + constraints: + nullable: false + - column: + name: secondary_theme + type: theme + constraints: + nullable: false + - column: + name: base_currency + type: VARCHAR(3) + defaultValue: HUF + constraints: + nullable: false