mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-19 14:19:52 +03:00
Categorization, TransactionCategories, TransactionMapping
This commit is contained in:
parent
46ea767eef
commit
ad0bce4eba
51 changed files with 1267 additions and 95 deletions
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
|
@ -5,7 +5,7 @@
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleHome" value="" />
|
<option name="gradleJvm" value="openjdk-21" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
|
@ -4,7 +4,7 @@
|
||||||
<component name="FrameworkDetectionExcludesConfiguration">
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
<file type="web" url="file://$PROJECT_DIR$" />
|
<file type="web" url="file://$PROJECT_DIR$" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="openjdk-21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
7
.idea/sqldialects.xml
generated
Normal file
7
.idea/sqldialects.xml
generated
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueGroupRepository.java" dialect="GenericSQL" />
|
||||||
|
<file url="PROJECT" dialect="PostgreSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -11,6 +11,7 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
|
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
|
||||||
|
implementation 'org.springframework:spring-web:6.1.1'
|
||||||
|
|
||||||
testImplementation platform('org.junit:junit-bom:5.9.1')
|
testImplementation platform('org.junit:junit-bom:5.9.1')
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
|
|
|
@ -2,21 +2,40 @@ package dev.mvvasilev.common.controller;
|
||||||
|
|
||||||
import dev.mvvasilev.common.web.APIErrorDTO;
|
import dev.mvvasilev.common.web.APIErrorDTO;
|
||||||
import dev.mvvasilev.common.web.APIResponseDTO;
|
import dev.mvvasilev.common.web.APIResponseDTO;
|
||||||
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class AbstractRestController {
|
public abstract class AbstractRestController {
|
||||||
|
|
||||||
protected <T> APIResponseDTO<T> withStatus(int statusCode, String statusText, T body) {
|
protected <T> ResponseEntity<APIResponseDTO<T>> withStatus(HttpStatus status, T body) {
|
||||||
return new APIResponseDTO<>(body, null, statusCode, statusText);
|
return ResponseEntity.status(status).body(new APIResponseDTO<>(body, null, status.value(), status.getReasonPhrase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected <T> APIResponseDTO<T> withSingleError(int statusCode, String statusText, String errorMessage, String errorCode, String stacktrace) {
|
protected <T> ResponseEntity<APIResponseDTO<T>> withSingleError(HttpStatus status, String errorMessage, String errorCode, String stacktrace) {
|
||||||
return new APIResponseDTO<>(null, List.of(new APIErrorDTO(errorMessage, errorCode, stacktrace)), statusCode, statusText);
|
return ResponseEntity.status(status).body(new APIResponseDTO<>(null, List.of(new APIErrorDTO(errorMessage, errorCode, stacktrace)), status.value(), status.getReasonPhrase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected <T> APIResponseDTO<T> ok(T body) {
|
protected <T> ResponseEntity<APIResponseDTO<T>> ok(T body) {
|
||||||
return withStatus(200, "ok", body);
|
return withStatus(HttpStatus.OK, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<APIResponseDTO<Object>> emptySuccess() {
|
||||||
|
return withStatus(HttpStatus.OK, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> created(Long id) {
|
||||||
|
return withStatus(HttpStatus.CREATED, new CrudResponseDTO(id, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> updated(Integer affectedRows) {
|
||||||
|
return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> deleted(Integer affectedRows) {
|
||||||
|
return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
package dev.mvvasilev.common.data;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
@Converter
|
||||||
|
public abstract class AbstractEnumConverter<T extends Enum<T> & PersistableEnum<E>, E> implements AttributeConverter<T, E> {
|
||||||
|
private final Class<T> clazz;
|
||||||
|
|
||||||
|
public AbstractEnumConverter(Class<T> clazz) {
|
||||||
|
this.clazz = clazz;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public E convertToDatabaseColumn(T attribute) {
|
||||||
|
return attribute != null ? attribute.value() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public T convertToEntityAttribute(E dbData) {
|
||||||
|
T[] enums = clazz.getEnumConstants();
|
||||||
|
|
||||||
|
for (T e : enums) {
|
||||||
|
if (e.value().equals(dbData)) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package dev.mvvasilev.common.data;
|
||||||
|
|
||||||
|
public interface PersistableEnum<T> {
|
||||||
|
|
||||||
|
T value();
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package dev.mvvasilev.common.data;
|
||||||
|
|
||||||
|
public interface UserOwned extends DatabaseStorable {
|
||||||
|
|
||||||
|
Integer getUserId();
|
||||||
|
|
||||||
|
}
|
|
@ -1,4 +1,12 @@
|
||||||
package dev.mvvasilev.common.exceptions;
|
package dev.mvvasilev.common.exceptions;
|
||||||
|
|
||||||
public class CommonFinancesException extends RuntimeException {
|
public class CommonFinancesException extends RuntimeException {
|
||||||
|
|
||||||
|
public CommonFinancesException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommonFinancesException(String messageTemplate, Object... replacements) {
|
||||||
|
super(String.format(messageTemplate, replacements));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package dev.mvvasilev.common.web;
|
||||||
|
|
||||||
|
public record CrudResponseDTO (
|
||||||
|
Long createdId,
|
||||||
|
Integer affectedRows
|
||||||
|
|
||||||
|
) {
|
||||||
|
}
|
|
@ -24,9 +24,9 @@ ext['spring-security.version']='6.2.0'
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
// implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
|
||||||
|
|
||||||
implementation 'org.flywaydb:flyway-core'
|
implementation 'org.flywaydb:flyway-core'
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-common:2.3.0'
|
implementation 'org.springdoc:springdoc-openapi-starter-common:2.3.0'
|
||||||
|
|
|
@ -11,9 +11,11 @@ import org.springframework.security.oauth2.jwt.JwtDecoders;
|
||||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
|
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
|
||||||
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
|
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
import org.springframework.web.filter.CommonsRequestLoggingFilter;
|
import org.springframework.web.filter.CommonsRequestLoggingFilter;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@EnableTransactionManagement
|
||||||
public class SecurityConfiguration {
|
public class SecurityConfiguration {
|
||||||
|
|
||||||
@Value("${jwt.issuer-url}")
|
@Value("${jwt.issuer-url}")
|
||||||
|
@ -23,8 +25,7 @@ public class SecurityConfiguration {
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
"/swagger-ui/**",
|
"/swagger-ui/**",
|
||||||
"/v2/api-docs/**",
|
"/v2/api-docs/**",
|
||||||
"/swagger-resources/**",
|
"/swagger-resources/**"
|
||||||
"/actuator/**"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
package dev.mvvasilev.finances.controllers;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.controller.AbstractRestController;
|
||||||
|
import dev.mvvasilev.common.web.APIResponseDTO;
|
||||||
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
|
import dev.mvvasilev.finances.services.CategoryService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/categories")
|
||||||
|
public class CategoriesController extends AbstractRestController {
|
||||||
|
|
||||||
|
final private CategoryService categoryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CategoriesController(CategoryService categoryService) {
|
||||||
|
this.categoryService = categoryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> createCategory(
|
||||||
|
@RequestBody CreateCategoryDTO dto,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
return created(categoryService.createForUser(dto, Integer.parseInt(authentication.getName())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<APIResponseDTO<Collection<CategoryDTO>>> listCategories(Authentication authentication) {
|
||||||
|
return ok(categoryService.listForUser(Integer.parseInt(authentication.getName())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{categoryId}")
|
||||||
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
|
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> updateCategory(
|
||||||
|
@PathVariable("categoryId") Long categoryId,
|
||||||
|
@RequestBody UpdateCategoryDTO dto
|
||||||
|
) {
|
||||||
|
return updated(categoryService.update(categoryId, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{categoryId}")
|
||||||
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
|
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> deleteCategory(@PathVariable("categoryId") Long categoryId) {
|
||||||
|
return deleted(categoryService.delete(categoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{categoryId}/rules")
|
||||||
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
|
public ResponseEntity<APIResponseDTO<Collection<CategorizationDTO>>> fetchCategorizationRules(@PathVariable("categoryId") Long categoryId) {
|
||||||
|
return ok(categoryService.fetchCategorizationRules(categoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{categoryId}/rules")
|
||||||
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
|
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> createCategorizationRule(
|
||||||
|
@PathVariable("categoryId") Long categoryId,
|
||||||
|
@RequestBody Collection<CreateCategorizationDTO> dto
|
||||||
|
) {
|
||||||
|
return created(categoryService.createCategorizationRule(categoryId, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{categoryId}/rules/{ruleId}")
|
||||||
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
|
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> deleteCategorizationRule(
|
||||||
|
@PathVariable("categoryId") Long categoryId,
|
||||||
|
@PathVariable("ruleId") Long ruleId
|
||||||
|
) {
|
||||||
|
return deleted(categoryService.deleteCategorizationRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/categorize")
|
||||||
|
public ResponseEntity<APIResponseDTO<Object>> categorizeTransactions(Authentication authentication) {
|
||||||
|
categoryService.categorizeForUser(Integer.parseInt(authentication.getName()));
|
||||||
|
return emptySuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -13,6 +13,6 @@ import org.springframework.web.bind.annotation.RestController;
|
||||||
public class DebugController extends AbstractRestController {
|
public class DebugController extends AbstractRestController {
|
||||||
@GetMapping("/token")
|
@GetMapping("/token")
|
||||||
public ResponseEntity<APIResponseDTO<String>> fetchToken(@RequestHeader("Authorization") String authHeader) {
|
public ResponseEntity<APIResponseDTO<String>> fetchToken(@RequestHeader("Authorization") String authHeader) {
|
||||||
return ResponseEntity.ofNullable(ok(authHeader));
|
return ok(authHeader);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package dev.mvvasilev.finances.controllers;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.controller.AbstractRestController;
|
||||||
|
import dev.mvvasilev.common.web.APIResponseDTO;
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/processed-transactions")
|
||||||
|
public class ProcessedTransactionsController extends AbstractRestController {
|
||||||
|
|
||||||
|
@GetMapping("/fields")
|
||||||
|
public ResponseEntity<APIResponseDTO<ProcessedTransactionField[]>> fetchFields() {
|
||||||
|
return ok(ProcessedTransactionField.values());
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,14 +2,16 @@ package dev.mvvasilev.finances.controllers;
|
||||||
|
|
||||||
import dev.mvvasilev.common.controller.AbstractRestController;
|
import dev.mvvasilev.common.controller.AbstractRestController;
|
||||||
import dev.mvvasilev.common.web.APIResponseDTO;
|
import dev.mvvasilev.common.web.APIResponseDTO;
|
||||||
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
|
import dev.mvvasilev.finances.dtos.CreateTransactionMappingDTO;
|
||||||
|
import dev.mvvasilev.finances.dtos.TransactionMappingDTO;
|
||||||
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
|
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
|
||||||
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
|
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
|
||||||
import dev.mvvasilev.finances.services.StatementsService;
|
import dev.mvvasilev.finances.services.StatementsService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.HttpStatusCode;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
@ -21,7 +23,7 @@ import java.util.Collection;
|
||||||
@RequestMapping("/statements")
|
@RequestMapping("/statements")
|
||||||
public class StatementsController extends AbstractRestController {
|
public class StatementsController extends AbstractRestController {
|
||||||
|
|
||||||
private StatementsService statementsService;
|
final private StatementsService statementsService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public StatementsController(StatementsService statementsService) {
|
public StatementsController(StatementsService statementsService) {
|
||||||
|
@ -30,33 +32,60 @@ public class StatementsController extends AbstractRestController {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<APIResponseDTO<Collection<UploadedStatementDTO>>> fetchStatements(Authentication authentication) {
|
public ResponseEntity<APIResponseDTO<Collection<UploadedStatementDTO>>> fetchStatements(Authentication authentication) {
|
||||||
return ResponseEntity.ofNullable(
|
return ok(statementsService.fetchStatementsForUser(Integer.parseInt(authentication.getName())));
|
||||||
ok(statementsService.fetchStatementsForUser(Integer.parseInt(authentication.getName())))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{statementId}/transactionValueGroups")
|
@GetMapping("/{statementId}/transactionValueGroups")
|
||||||
public ResponseEntity<APIResponseDTO<Collection<TransactionValueGroupDTO>>> fetchTransactionValueGroups(@PathVariable("statementId") Long statementId, Authentication authentication) {
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
return ResponseEntity.ofNullable(ok(
|
public ResponseEntity<APIResponseDTO<Collection<TransactionValueGroupDTO>>> fetchTransactionValueGroups(
|
||||||
statementsService.fetchTransactionValueGroupsForUserStatement(statementId, Integer.parseInt(authentication.getName()))
|
@PathVariable("statementId") Long statementId
|
||||||
));
|
) {
|
||||||
|
return ok(statementsService.fetchTransactionValueGroupsForUserStatement(statementId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{statementId}/mappings")
|
||||||
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
|
public ResponseEntity<APIResponseDTO<Collection<TransactionMappingDTO>>> fetchTransactionMappings(
|
||||||
|
@PathVariable("statementId") Long statementId
|
||||||
|
) {
|
||||||
|
return ok(statementsService.fetchMappingsForStatement(statementId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{statementId}/mappings")
|
||||||
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
|
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createTransactionMappings(
|
||||||
|
@PathVariable("statementId") Long statementId,
|
||||||
|
@RequestBody Collection<CreateTransactionMappingDTO> body
|
||||||
|
) {
|
||||||
|
return ok(statementsService.createTransactionMappingsForStatement(statementId, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@PostMapping("/{statementId}/process")
|
||||||
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
|
public ResponseEntity<APIResponseDTO<Object>> processTransactions(@PathVariable("statementId") Long statementId) {
|
||||||
|
statementsService.processStatement(statementId);
|
||||||
|
return emptySuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{statementId}")
|
@DeleteMapping("/{statementId}")
|
||||||
public ResponseEntity<APIResponseDTO> deleteStatement(@PathVariable("statementId") Long statementId, Authentication authentication) {
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
statementsService.deleteStatement(statementId, Integer.parseInt(authentication.getName()));
|
public ResponseEntity<APIResponseDTO<Object>> deleteStatement(@PathVariable("statementId") Long statementId) {
|
||||||
return ResponseEntity.ofNullable(ok(null));
|
statementsService.deleteStatement(statementId);
|
||||||
|
|
||||||
|
return emptySuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/uploadSheet", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/uploadSheet", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<APIResponseDTO<Integer>> uploadStatement(@RequestParam("file") MultipartFile file, Authentication authentication) throws IOException {
|
public ResponseEntity<APIResponseDTO<Object>> uploadStatement(@RequestParam("file") MultipartFile file, Authentication authentication) throws IOException {
|
||||||
statementsService.uploadStatementFromExcelSheetForUser(
|
statementsService.uploadStatementFromExcelSheetForUser(
|
||||||
file.getOriginalFilename(),
|
file.getOriginalFilename(),
|
||||||
file.getContentType(),
|
file.getContentType(),
|
||||||
file.getInputStream(),
|
file.getInputStream(),
|
||||||
Integer.parseInt(authentication.getName())
|
Integer.parseInt(authentication.getName())
|
||||||
);
|
);
|
||||||
return ResponseEntity.ofNullable(ok(1));
|
|
||||||
|
return emptySuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record CategorizationDTO(
|
||||||
|
Long id,
|
||||||
|
|
||||||
|
CategorizationRule rule,
|
||||||
|
|
||||||
|
String stringValue,
|
||||||
|
|
||||||
|
Double numericGreaterThan,
|
||||||
|
|
||||||
|
Double numericLessThan,
|
||||||
|
|
||||||
|
Double numericValue,
|
||||||
|
|
||||||
|
LocalDateTime timestampGreaterThan,
|
||||||
|
|
||||||
|
LocalDateTime timestampLessThan,
|
||||||
|
|
||||||
|
Boolean booleanValue,
|
||||||
|
|
||||||
|
CategorizationDTO left,
|
||||||
|
|
||||||
|
CategorizationDTO right
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
public record CategoryDTO(
|
||||||
|
Long id,
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Null;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record CreateCategorizationDTO (
|
||||||
|
@NotNull
|
||||||
|
CategorizationRule rule,
|
||||||
|
|
||||||
|
@Length(max = 1024)
|
||||||
|
String stringValue,
|
||||||
|
|
||||||
|
Double numericGreaterThan,
|
||||||
|
|
||||||
|
Double numericLessThan,
|
||||||
|
|
||||||
|
Double numericValue,
|
||||||
|
|
||||||
|
LocalDateTime timestampGreaterThan,
|
||||||
|
|
||||||
|
LocalDateTime timestampLessThan,
|
||||||
|
|
||||||
|
Boolean booleanValue,
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
CreateCategorizationDTO left,
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
CreateCategorizationDTO right
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
|
public record CreateCategoryDTO (
|
||||||
|
@Length(max = 255)
|
||||||
|
@NotNull
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record CreateTransactionMappingDTO(
|
||||||
|
@NotNull
|
||||||
|
Long rawTransactionValueGroupId,
|
||||||
|
@NotNull
|
||||||
|
ProcessedTransactionField field
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
|
||||||
|
public record TransactionMappingDTO(
|
||||||
|
Long id,
|
||||||
|
Long rawTransactionValueGroupId,
|
||||||
|
ProcessedTransactionField processedTransactionField
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
|
public record UpdateCategoryDTO (
|
||||||
|
@NotNull
|
||||||
|
@Length(max = 255)
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
|
import dev.mvvasilev.common.data.UserOwned;
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import jakarta.persistence.Convert;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "categories")
|
||||||
|
public class Categorization extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
@Convert(converter = ProcessedTransactionField.JpaConverter.class)
|
||||||
|
private ProcessedTransactionField ruleBasedOn;
|
||||||
|
|
||||||
|
@Convert(converter = CategorizationRule.JpaConverter.class)
|
||||||
|
private CategorizationRule categorizationRule;
|
||||||
|
|
||||||
|
private String stringValue;
|
||||||
|
|
||||||
|
private double numericGreaterThan;
|
||||||
|
|
||||||
|
private double numericLessThan;
|
||||||
|
|
||||||
|
private double numericValue;
|
||||||
|
|
||||||
|
private LocalDateTime timestampGreaterThan;
|
||||||
|
|
||||||
|
private LocalDateTime timestampLessThan;
|
||||||
|
|
||||||
|
private boolean booleanValue;
|
||||||
|
|
||||||
|
private Long categoryId;
|
||||||
|
|
||||||
|
private Long leftCategorizationId;
|
||||||
|
|
||||||
|
private Long rightCategorizationId;
|
||||||
|
|
||||||
|
public Categorization() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getUserId() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Integer userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProcessedTransactionField getRuleBasedOn() {
|
||||||
|
return ruleBasedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRuleBasedOn(ProcessedTransactionField ruleBasedOn) {
|
||||||
|
this.ruleBasedOn = ruleBasedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CategorizationRule getCategorizationRule() {
|
||||||
|
return categorizationRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategorizationRule(CategorizationRule categorizationRule) {
|
||||||
|
this.categorizationRule = categorizationRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStringValue() {
|
||||||
|
return stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStringValue(String stringValue) {
|
||||||
|
this.stringValue = stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getNumericGreaterThan() {
|
||||||
|
return numericGreaterThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumericGreaterThan(double numericGreaterThan) {
|
||||||
|
this.numericGreaterThan = numericGreaterThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getNumericLessThan() {
|
||||||
|
return numericLessThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumericLessThan(double numericLessThan) {
|
||||||
|
this.numericLessThan = numericLessThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getNumericValue() {
|
||||||
|
return numericValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumericValue(double numericValue) {
|
||||||
|
this.numericValue = numericValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestampGreaterThan() {
|
||||||
|
return timestampGreaterThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestampGreaterThan(LocalDateTime timestampGreaterThan) {
|
||||||
|
this.timestampGreaterThan = timestampGreaterThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestampLessThan() {
|
||||||
|
return timestampLessThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestampLessThan(LocalDateTime timestampLessThan) {
|
||||||
|
this.timestampLessThan = timestampLessThan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean getBooleanValue() {
|
||||||
|
return booleanValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBooleanValue(boolean booleanValue) {
|
||||||
|
this.booleanValue = booleanValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(Long categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLeftCategorizationId() {
|
||||||
|
return leftCategorizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLeftCategorizationId(Long leftCategorizationId) {
|
||||||
|
this.leftCategorizationId = leftCategorizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getRightCategorizationId() {
|
||||||
|
return rightCategorizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRightCategorizationId(Long rightCategorizationId) {
|
||||||
|
this.rightCategorizationId = rightCategorizationId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package dev.mvvasilev.finances.entity;
|
package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
import dev.mvvasilev.common.data.AbstractEntity;
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
|
import dev.mvvasilev.common.data.UserOwned;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@ -8,7 +9,7 @@ import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(schema = "transactions")
|
@Table(schema = "transactions")
|
||||||
public class ProcessedTransaction extends AbstractEntity {
|
public class ProcessedTransaction extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@ -18,11 +19,9 @@ public class ProcessedTransaction extends AbstractEntity {
|
||||||
|
|
||||||
private boolean isInflow;
|
private boolean isInflow;
|
||||||
|
|
||||||
private Long categoryId;
|
|
||||||
|
|
||||||
private LocalDateTime timestamp;
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
// private Long transactionMappingId;
|
private Long statementId;
|
||||||
|
|
||||||
public ProcessedTransaction() {
|
public ProcessedTransaction() {
|
||||||
}
|
}
|
||||||
|
@ -51,14 +50,6 @@ public class ProcessedTransaction extends AbstractEntity {
|
||||||
isInflow = inflow;
|
isInflow = inflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getCategoryId() {
|
|
||||||
return categoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCategoryId(Long categoryId) {
|
|
||||||
this.categoryId = categoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getTimestamp() {
|
public LocalDateTime getTimestamp() {
|
||||||
return timestamp;
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
@ -75,11 +66,11 @@ public class ProcessedTransaction extends AbstractEntity {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// public Long getTransactionMappingId() {
|
public Long getStatementId() {
|
||||||
// return transactionMappingId;
|
return statementId;
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// public void setTransactionMappingId(Long transactionMappingId) {
|
public void setStatementId(Long statementId) {
|
||||||
// this.transactionMappingId = transactionMappingId;
|
this.statementId = statementId;
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "categories")
|
||||||
|
public class ProcessedTransactionCategory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(columnDefinition = "bigserial")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long processedTransactionId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long categoryId;
|
||||||
|
|
||||||
|
public ProcessedTransactionCategory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProcessedTransactionCategory(Long processedTransactionId, Long categoryId) {
|
||||||
|
this.processedTransactionId = processedTransactionId;
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getProcessedTransactionId() {
|
||||||
|
return processedTransactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessedTransactionId(Long processedTransactionId) {
|
||||||
|
this.processedTransactionId = processedTransactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(Long categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,13 @@
|
||||||
package dev.mvvasilev.finances.entity;
|
package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
import dev.mvvasilev.common.data.AbstractEntity;
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
|
import dev.mvvasilev.common.data.UserOwned;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(schema = "transactions")
|
@Table(schema = "transactions")
|
||||||
public class RawStatement extends AbstractEntity {
|
public class RawStatement extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
private Integer userId;
|
private Integer userId;
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
package dev.mvvasilev.finances.entity;
|
package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
import dev.mvvasilev.common.data.AbstractEntity;
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
|
import dev.mvvasilev.common.data.UserOwned;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(schema = "categories")
|
@Table(schema = "categories")
|
||||||
public class TransactionCategory extends AbstractEntity {
|
public class TransactionCategory extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
private Integer userId;
|
private Integer userId;
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,37 @@
|
||||||
package dev.mvvasilev.finances.entity;
|
package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
import dev.mvvasilev.common.data.AbstractEntity;
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
|
import dev.mvvasilev.common.data.UserOwned;
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import jakarta.persistence.Convert;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
//@Entity
|
@Entity
|
||||||
//@Table(schema = "mappings")
|
@Table(schema = "transactions")
|
||||||
//public class TransactionMapping extends AbstractEntity {
|
public class TransactionMapping extends AbstractEntity {
|
||||||
//
|
|
||||||
// private Long rawStatementId;
|
private Long rawTransactionValueGroupId;
|
||||||
//
|
|
||||||
//
|
@Convert(converter = ProcessedTransactionField.JpaConverter.class)
|
||||||
//
|
private ProcessedTransactionField processedTransactionField;
|
||||||
//}
|
|
||||||
|
public TransactionMapping() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getRawTransactionValueGroupId() {
|
||||||
|
return rawTransactionValueGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRawTransactionValueGroupId(Long rawTransactionValueGroupId) {
|
||||||
|
this.rawTransactionValueGroupId = rawTransactionValueGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProcessedTransactionField getProcessedTransactionField() {
|
||||||
|
return processedTransactionField;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessedTransactionField(ProcessedTransactionField processedTransactionField) {
|
||||||
|
this.processedTransactionField = processedTransactionField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
package dev.mvvasilev.finances.enums;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.data.AbstractEnumConverter;
|
||||||
|
import dev.mvvasilev.common.data.PersistableEnum;
|
||||||
|
|
||||||
|
// TODO: Create custom converter for JPA
|
||||||
|
public enum CategorizationRule implements PersistableEnum<String> {
|
||||||
|
STRING_REGEX(RawTransactionValueType.STRING),
|
||||||
|
STRING_EQ(RawTransactionValueType.STRING),
|
||||||
|
STRING_CONTAINS(RawTransactionValueType.STRING),
|
||||||
|
NUMERIC_GREATER_THAN(RawTransactionValueType.NUMERIC),
|
||||||
|
NUMERIC_LESS_THAN(RawTransactionValueType.NUMERIC),
|
||||||
|
NUMERIC_EQUALS(RawTransactionValueType.NUMERIC),
|
||||||
|
NUMERIC_BETWEEN(RawTransactionValueType.NUMERIC),
|
||||||
|
TIMESTAMP_GREATER_THAN(RawTransactionValueType.TIMESTAMP),
|
||||||
|
TIMESTAMP_LESS_THAN(RawTransactionValueType.TIMESTAMP),
|
||||||
|
TIMESTAMP_BETWEEN(RawTransactionValueType.TIMESTAMP),
|
||||||
|
BOOLEAN_EQ(RawTransactionValueType.BOOLEAN),
|
||||||
|
AND(null),
|
||||||
|
OR(null),
|
||||||
|
NOT(null);
|
||||||
|
|
||||||
|
final private RawTransactionValueType applicableForType;
|
||||||
|
|
||||||
|
CategorizationRule(RawTransactionValueType applicableForType) {
|
||||||
|
this.applicableForType = applicableForType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String value() {
|
||||||
|
return name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RawTransactionValueType applicableForType() {
|
||||||
|
return applicableForType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class JpaConverter extends AbstractEnumConverter<CategorizationRule, String> {
|
||||||
|
public JpaConverter() {
|
||||||
|
super(CategorizationRule.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package dev.mvvasilev.finances.enums;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.data.AbstractEnumConverter;
|
||||||
|
import dev.mvvasilev.common.data.PersistableEnum;
|
||||||
|
|
||||||
|
// TODO: Create custom converter for JPA
|
||||||
|
public enum ProcessedTransactionField implements PersistableEnum<String> {
|
||||||
|
DESCRIPTION(RawTransactionValueType.STRING),
|
||||||
|
AMOUNT(RawTransactionValueType.NUMERIC),
|
||||||
|
IS_INFLOW(RawTransactionValueType.BOOLEAN),
|
||||||
|
TIMESTAMP(RawTransactionValueType.TIMESTAMP);
|
||||||
|
|
||||||
|
final private RawTransactionValueType type;
|
||||||
|
|
||||||
|
ProcessedTransactionField(RawTransactionValueType type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public String value() {
|
||||||
|
return name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RawTransactionValueType type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class JpaConverter extends AbstractEnumConverter<ProcessedTransactionField, String> {
|
||||||
|
public JpaConverter() {
|
||||||
|
super(ProcessedTransactionField.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,16 @@
|
||||||
package dev.mvvasilev.finances.enums;
|
package dev.mvvasilev.finances.enums;
|
||||||
|
|
||||||
public enum RawTransactionValueType {
|
import dev.mvvasilev.common.data.PersistableEnum;
|
||||||
|
|
||||||
|
// TODO: Create custom converter for JPA
|
||||||
|
public enum RawTransactionValueType implements PersistableEnum<String> {
|
||||||
STRING,
|
STRING,
|
||||||
NUMERIC,
|
NUMERIC,
|
||||||
TIMESTAMP,
|
TIMESTAMP,
|
||||||
BOOLEAN
|
BOOLEAN;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String value() {
|
||||||
|
return name();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.dtos.CategorizationDTO;
|
||||||
|
import dev.mvvasilev.finances.entity.Categorization;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CategorizationRepository extends JpaRepository<Categorization, Long> {
|
||||||
|
|
||||||
|
// We fetch only the ones with non-null category ids
|
||||||
|
// because ones with null category are used in AND, OR or NOT logical operations
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
SELECT cat.*
|
||||||
|
FROM categories.categorization AS cat
|
||||||
|
WHERE user_id = :userId AND category_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
Collection<Categorization> fetchForUser(@Param("userId") int userId);
|
||||||
|
|
||||||
|
// TODO: Use Recursive CTE
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
SELECT cat.*
|
||||||
|
FROM categories.categorization AS cat
|
||||||
|
WHERE category_id = :categoryId
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
Collection<Categorization> fetchForCategory(@Param("categoryId") Long categoryId);
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransactionCategory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ProcessedTransactionCategoryRepository extends JpaRepository<ProcessedTransactionCategory, Long> {
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ProcessedTransactionRepository extends JpaRepository<ProcessedTransaction, Long> {
|
||||||
|
|
||||||
|
@Query(value = "SELECT * FROM transactions.processed_transaction WHERE user_id = :userId", nativeQuery = true)
|
||||||
|
Collection<ProcessedTransaction> fetchForUser(@Param("userId") int userId);
|
||||||
|
}
|
|
@ -12,16 +12,18 @@ import java.util.Collection;
|
||||||
@Repository
|
@Repository
|
||||||
public interface RawTransactionValueGroupRepository extends JpaRepository<RawTransactionValueGroup, Long> {
|
public interface RawTransactionValueGroupRepository extends JpaRepository<RawTransactionValueGroup, Long> {
|
||||||
|
|
||||||
@Query(value =
|
@Query(
|
||||||
"SELECT " +
|
value = """
|
||||||
"rtvg.id, " +
|
SELECT
|
||||||
"rtvg.name, " +
|
rtvg.id,
|
||||||
"rtvg.type " +
|
rtvg.name,
|
||||||
"FROM transactions.raw_transaction_value_group AS rtvg " +
|
rtvg.type
|
||||||
"JOIN transactions.raw_statement AS rs ON rtvg.statement_id = rs.id " +
|
FROM transactions.raw_transaction_value_group AS rtvg
|
||||||
"WHERE rs.user_id = :userId AND rs.id = :statementId",
|
JOIN transactions.raw_statement AS rs ON rtvg.statement_id = rs.id
|
||||||
|
WHERE rs.id = :statementId
|
||||||
|
""",
|
||||||
nativeQuery = true
|
nativeQuery = true
|
||||||
)
|
)
|
||||||
Collection<RawTransactionValueGroupDTO> fetchAllForStatementAndUser(@Param("statementId") Long statementId, @Param("userId") Integer userId);
|
Collection<RawTransactionValueGroupDTO> fetchAllForStatement(@Param("statementId") Long statementId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.dtos.CategoryDTO;
|
||||||
|
import dev.mvvasilev.finances.entity.TransactionCategory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TransactionCategoryRepository extends JpaRepository<TransactionCategory, Long> {
|
||||||
|
@Query(value = "SELECT * FROM categories.transaction_category WHERE user_id = :userId", nativeQuery = true)
|
||||||
|
Collection<TransactionCategory> fetchTransactionCategoriesWithUserId(@Param("userId") int userId);
|
||||||
|
|
||||||
|
@Query(value = "UPDATE categories.transaction_category SET name = :name WHERE id = :categoryId", nativeQuery = true)
|
||||||
|
int updateTransactionCategoryName(@Param("categoryId") Long categoryId, @Param("name") String name);
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.entity.TransactionMapping;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TransactionMappingRepository extends JpaRepository<TransactionMapping, Long> {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
SELECT tm.*
|
||||||
|
FROM transactions.transaction_mappings AS tm
|
||||||
|
JOIN transactions.raw_transaction_value_group AS rtvg ON rtvg.id = tm.raw_transaction_value_group_id
|
||||||
|
JOIN transactions.statements AS s ON s.id = rtvg.statement_id
|
||||||
|
WHERE s.id = :statementId
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
Collection<TransactionMapping> fetchTransactionMappingsWithStatementId(@Param("statementId") Long statementId);
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
package dev.mvvasilev.finances.persistence.dtos;
|
||||||
|
|
||||||
|
public interface RawTransactionDTO {
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package dev.mvvasilev.finances.services;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service("authService")
|
||||||
|
public class AuthorizationService {
|
||||||
|
|
||||||
|
final private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AuthorizationService(EntityManager entityManager) {
|
||||||
|
this.entityManager = entityManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user in the {@link SecurityContextHolder} is the owner of the requested resource.
|
||||||
|
* Executes a query on the database, equivalent to the following:
|
||||||
|
* <br>
|
||||||
|
* <pre type="SQL">
|
||||||
|
* SELECT 1 FROM {user owned entity} WHERE id = :id AND user_id = :userId
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @param id the id of the resource to check
|
||||||
|
* @param userOwnedEntity the entity class
|
||||||
|
* @return whether the user is the owner of the resource or not
|
||||||
|
*/
|
||||||
|
public boolean isOwner(Long id, Class<?> userOwnedEntity) {
|
||||||
|
var cb = entityManager.getCriteriaBuilder();
|
||||||
|
|
||||||
|
var query = cb.createQuery(Boolean.class);
|
||||||
|
var root = query.from(userOwnedEntity);
|
||||||
|
|
||||||
|
var userId = SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
|
||||||
|
var parsedUserId = Integer.parseInt(userId);
|
||||||
|
|
||||||
|
var finalQuery = query.select(cb.literal(true)).where(
|
||||||
|
cb.and(
|
||||||
|
cb.equal(root.get("id"), id),
|
||||||
|
cb.equal(root.get("userId"), parsedUserId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no results were returned, then the user was not the owner of the resource
|
||||||
|
return !entityManager.createQuery(finalQuery).setMaxResults(1).getResultList().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,276 @@
|
||||||
|
package dev.mvvasilev.finances.services;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
||||||
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
|
import dev.mvvasilev.finances.entity.Categorization;
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransactionCategory;
|
||||||
|
import dev.mvvasilev.finances.entity.TransactionCategory;
|
||||||
|
import dev.mvvasilev.finances.persistence.CategorizationRepository;
|
||||||
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository;
|
||||||
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
||||||
|
import dev.mvvasilev.finances.persistence.TransactionCategoryRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CategoryService {
|
||||||
|
|
||||||
|
final private TransactionCategoryRepository transactionCategoryRepository;
|
||||||
|
|
||||||
|
final private CategorizationRepository categorizationRepository;
|
||||||
|
|
||||||
|
final private ProcessedTransactionRepository processedTransactionRepository;
|
||||||
|
|
||||||
|
final private ProcessedTransactionCategoryRepository processedTransactionCategoryRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CategoryService(
|
||||||
|
TransactionCategoryRepository transactionCategoryRepository,
|
||||||
|
CategorizationRepository categorizationRepository,
|
||||||
|
ProcessedTransactionRepository processedTransactionRepository,
|
||||||
|
ProcessedTransactionCategoryRepository processedTransactionCategoryRepository
|
||||||
|
) {
|
||||||
|
this.transactionCategoryRepository = transactionCategoryRepository;
|
||||||
|
this.categorizationRepository = categorizationRepository;
|
||||||
|
this.processedTransactionRepository = processedTransactionRepository;
|
||||||
|
this.processedTransactionCategoryRepository = processedTransactionCategoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long createForUser(CreateCategoryDTO dto, int userId) {
|
||||||
|
var transactionCategory = new TransactionCategory();
|
||||||
|
|
||||||
|
transactionCategory.setName(dto.name());
|
||||||
|
transactionCategory.setUserId(userId);
|
||||||
|
|
||||||
|
transactionCategory = transactionCategoryRepository.saveAndFlush(transactionCategory);
|
||||||
|
|
||||||
|
return transactionCategory.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<CategoryDTO> listForUser(int userId) {
|
||||||
|
return transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId)
|
||||||
|
.stream()
|
||||||
|
.map(entity -> new CategoryDTO(entity.getId(), entity.getName()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int update(Long categoryId, UpdateCategoryDTO dto) {
|
||||||
|
return transactionCategoryRepository.updateTransactionCategoryName(
|
||||||
|
categoryId,
|
||||||
|
dto.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int delete(Long categoryId) {
|
||||||
|
transactionCategoryRepository.deleteById(categoryId);
|
||||||
|
return 1; // Affected rows. TODO: Actually fetch from database
|
||||||
|
}
|
||||||
|
|
||||||
|
public void categorizeForUser(int userId) {
|
||||||
|
final var categorizations = categorizationRepository.fetchForUser(userId);
|
||||||
|
final var transactions = processedTransactionRepository.fetchForUser(userId);
|
||||||
|
|
||||||
|
// Run all the categorization rules async
|
||||||
|
final var futures = categorizations.stream()
|
||||||
|
.map(c -> CompletableFuture.supplyAsync(() ->
|
||||||
|
transactions.stream()
|
||||||
|
.map((transaction) -> categorizeTransaction(categorizations, c, transaction))
|
||||||
|
.filter(Optional::isPresent)
|
||||||
|
.map(Optional::get)
|
||||||
|
.toList())
|
||||||
|
)
|
||||||
|
.toArray(length -> (CompletableFuture<List<ProcessedTransactionCategory>>[]) new CompletableFuture[length]);
|
||||||
|
|
||||||
|
// Run them all in parallel
|
||||||
|
final var categories = CompletableFuture.allOf(futures).thenApply((v) ->
|
||||||
|
Arrays.stream(futures)
|
||||||
|
.flatMap(future -> future.join().stream())
|
||||||
|
.toList()
|
||||||
|
).join();
|
||||||
|
|
||||||
|
processedTransactionCategoryRepository.saveAllAndFlush(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ProcessedTransactionCategory> categorizeTransaction(final Collection<Categorization> allCategorizations, Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||||
|
if (matchesRule(allCategorizations, categorization, processedTransaction)) {
|
||||||
|
return Optional.of(new ProcessedTransactionCategory(processedTransaction.getId(), categorization.getCategoryId()));
|
||||||
|
} else {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesRule(final Collection<Categorization> allCategorizations, final Categorization categorization, final ProcessedTransaction processedTransaction) {
|
||||||
|
return switch (categorization.getCategorizationRule()) {
|
||||||
|
// string operations
|
||||||
|
case STRING_REGEX, STRING_EQ, STRING_CONTAINS -> {
|
||||||
|
final String fieldValue = fetchTransactionStringValue(categorization, processedTransaction);
|
||||||
|
|
||||||
|
yield switch (categorization.getCategorizationRule()) {
|
||||||
|
case STRING_EQ -> fieldValue.equalsIgnoreCase(categorization.getStringValue());
|
||||||
|
case STRING_REGEX -> fieldValue.matches(categorization.getStringValue());
|
||||||
|
case STRING_CONTAINS -> fieldValue.contains(categorization.getStringValue());
|
||||||
|
default -> throw new CommonFinancesException("Unsupported string rule: %s", categorization.getCategorizationRule());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// numeric operations
|
||||||
|
case NUMERIC_GREATER_THAN, NUMERIC_LESS_THAN, NUMERIC_EQUALS, NUMERIC_BETWEEN -> {
|
||||||
|
final double fieldValue = fetchTransactionNumericValue(categorization, processedTransaction);
|
||||||
|
|
||||||
|
yield switch (categorization.getCategorizationRule()) {
|
||||||
|
case NUMERIC_GREATER_THAN -> fieldValue > categorization.getNumericGreaterThan();
|
||||||
|
case NUMERIC_LESS_THAN -> fieldValue < categorization.getNumericLessThan();
|
||||||
|
case NUMERIC_EQUALS -> fieldValue == categorization.getNumericValue();
|
||||||
|
case NUMERIC_BETWEEN -> fieldValue > categorization.getNumericGreaterThan() && fieldValue < categorization.getNumericLessThan();
|
||||||
|
default -> throw new CommonFinancesException("Unsupported numeric rule: %s", categorization.getCategorizationRule());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// datetime/timestamp operations
|
||||||
|
case TIMESTAMP_GREATER_THAN, TIMESTAMP_LESS_THAN, TIMESTAMP_BETWEEN -> {
|
||||||
|
final LocalDateTime fieldValue = fetchTransactionTimestampValue(categorization, processedTransaction);
|
||||||
|
|
||||||
|
yield switch (categorization.getCategorizationRule()) {
|
||||||
|
case TIMESTAMP_GREATER_THAN -> fieldValue.isBefore(categorization.getTimestampGreaterThan());
|
||||||
|
case TIMESTAMP_LESS_THAN -> fieldValue.isAfter(categorization.getTimestampLessThan());
|
||||||
|
case TIMESTAMP_BETWEEN -> fieldValue.isBefore(categorization.getTimestampGreaterThan()) && fieldValue.isAfter(categorization.getTimestampLessThan());
|
||||||
|
default -> throw new CommonFinancesException("Unsupported timestamp rule: %s", categorization.getCategorizationRule());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// boolean operations
|
||||||
|
case BOOLEAN_EQ -> {
|
||||||
|
final boolean equalsValue = categorization.getBooleanValue();
|
||||||
|
|
||||||
|
boolean fieldValue = fetchTransactionBooleanValue(categorization, processedTransaction);
|
||||||
|
|
||||||
|
yield equalsValue == fieldValue;
|
||||||
|
}
|
||||||
|
// logical operations
|
||||||
|
case OR, AND, NOT -> {
|
||||||
|
var leftId = categorization.getLeftCategorizationId();
|
||||||
|
var rightId = categorization.getRightCategorizationId();
|
||||||
|
|
||||||
|
final var left = allCategorizations.stream()
|
||||||
|
.filter(c -> c.getId() == leftId)
|
||||||
|
.findFirst();
|
||||||
|
|
||||||
|
final var right = allCategorizations.stream()
|
||||||
|
.filter(c -> c.getId() == rightId)
|
||||||
|
.findFirst();
|
||||||
|
|
||||||
|
yield switch (categorization.getCategorizationRule()) {
|
||||||
|
case AND -> {
|
||||||
|
if (right.isEmpty()) {
|
||||||
|
throw new CommonFinancesException("Invalid categorization: right does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.isEmpty()) {
|
||||||
|
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
yield matchesRule(allCategorizations, left.get(), processedTransaction) && matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||||
|
}
|
||||||
|
case OR -> {
|
||||||
|
if (right.isEmpty()) {
|
||||||
|
throw new CommonFinancesException("Invalid categorization: right does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.isEmpty()) {
|
||||||
|
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
yield matchesRule(allCategorizations, left.get(), processedTransaction) || matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||||
|
}
|
||||||
|
case NOT -> {
|
||||||
|
if (right.isEmpty()) {
|
||||||
|
throw new CommonFinancesException("Invalid categorization: right does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
yield !matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||||
|
}
|
||||||
|
default -> throw new CommonFinancesException("Invalid logical operation: %s", categorization.getCategorizationRule());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fetchTransactionStringValue(Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||||
|
return switch (categorization.getRuleBasedOn()) {
|
||||||
|
case DESCRIPTION -> processedTransaction.getDescription();
|
||||||
|
case AMOUNT, IS_INFLOW, TIMESTAMP -> throw invalidCategorizationRule(categorization);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private double fetchTransactionNumericValue(Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||||
|
return switch (categorization.getRuleBasedOn()) {
|
||||||
|
case AMOUNT -> processedTransaction.getAmount();
|
||||||
|
case DESCRIPTION, IS_INFLOW, TIMESTAMP -> throw invalidCategorizationRule(categorization);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime fetchTransactionTimestampValue(Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||||
|
return switch (categorization.getRuleBasedOn()) {
|
||||||
|
case TIMESTAMP -> processedTransaction.getTimestamp();
|
||||||
|
case DESCRIPTION, IS_INFLOW, AMOUNT -> throw invalidCategorizationRule(categorization);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean fetchTransactionBooleanValue(Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||||
|
return switch (categorization.getRuleBasedOn()) {
|
||||||
|
case IS_INFLOW -> processedTransaction.isInflow();
|
||||||
|
case DESCRIPTION, TIMESTAMP, AMOUNT -> throw invalidCategorizationRule(categorization);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommonFinancesException invalidCategorizationRule(Categorization categorization) {
|
||||||
|
throw new CommonFinancesException(
|
||||||
|
"Invalid categorization rule: field %s is of type %s, while the rule is applicable only for type %s",
|
||||||
|
categorization.getRuleBasedOn(),
|
||||||
|
categorization.getRuleBasedOn().type(),
|
||||||
|
categorization.getCategorizationRule().applicableForType()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) {
|
||||||
|
return categorizationRepository.fetchForCategory(categoryId).stream()
|
||||||
|
.map(entity -> {
|
||||||
|
// TODO: Recursion
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long createCategorizationRule(Long categoryId, Collection<CreateCategorizationDTO> dto) {
|
||||||
|
// TODO: Clear previous rules for category and replace with new ones
|
||||||
|
|
||||||
|
// final var categorization = new Categorization();
|
||||||
|
//
|
||||||
|
// categorization.setCategorizationRule(dto.rule());
|
||||||
|
// categorization.setCategoryId(categoryId);
|
||||||
|
// categorization.setStringValue(dto.stringValue());
|
||||||
|
// categorization.setNumericGreaterThan(dto.numericGreaterThan());
|
||||||
|
// categorization.setNumericLessThan(dto.numericLessThan());
|
||||||
|
// categorization.setNumericValue(dto.numericValue());
|
||||||
|
// categorization.setTimestampGreaterThan(dto.timestampGreaterThan());
|
||||||
|
// categorization.setTimestampLessThan(dto.timestampLessThan());
|
||||||
|
// categorization.setBooleanValue(dto.booleanValue());
|
||||||
|
//
|
||||||
|
// if (dto.left() != null) {
|
||||||
|
// final var leftCat = createCategorizationRule(null, dto.left());
|
||||||
|
// categorization.setLeftCategorizationId(leftCat);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (dto.right() != null) {
|
||||||
|
// final var rightCat = createCategorizationRule(null, dto.right());
|
||||||
|
// categorization.setRightCategorizationId(rightCat);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return categorizationRepository.save(categorization).getId();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,26 @@
|
||||||
package dev.mvvasilev.finances.services;
|
package dev.mvvasilev.finances.services;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
|
import dev.mvvasilev.finances.dtos.CreateTransactionMappingDTO;
|
||||||
|
import dev.mvvasilev.finances.dtos.TransactionMappingDTO;
|
||||||
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
|
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
|
||||||
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
|
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
|
||||||
import dev.mvvasilev.finances.entity.RawStatement;
|
import dev.mvvasilev.finances.entity.RawStatement;
|
||||||
import dev.mvvasilev.finances.entity.RawTransactionValue;
|
import dev.mvvasilev.finances.entity.RawTransactionValue;
|
||||||
import dev.mvvasilev.finances.entity.RawTransactionValueGroup;
|
import dev.mvvasilev.finances.entity.RawTransactionValueGroup;
|
||||||
|
import dev.mvvasilev.finances.entity.TransactionMapping;
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
import dev.mvvasilev.finances.enums.RawTransactionValueType;
|
import dev.mvvasilev.finances.enums.RawTransactionValueType;
|
||||||
import dev.mvvasilev.finances.persistence.RawStatementRepository;
|
import dev.mvvasilev.finances.persistence.RawStatementRepository;
|
||||||
import dev.mvvasilev.finances.persistence.RawTransactionValueGroupRepository;
|
import dev.mvvasilev.finances.persistence.RawTransactionValueGroupRepository;
|
||||||
import dev.mvvasilev.finances.persistence.RawTransactionValueRepository;
|
import dev.mvvasilev.finances.persistence.RawTransactionValueRepository;
|
||||||
import jakarta.transaction.Transactional;
|
import dev.mvvasilev.finances.persistence.TransactionMappingRepository;
|
||||||
import org.apache.poi.ss.usermodel.CellType;
|
import org.apache.poi.ss.usermodel.CellType;
|
||||||
import org.apache.poi.ss.usermodel.Sheet;
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
@ -24,14 +30,10 @@ import java.time.format.DateTimeFormatterBuilder;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.time.format.ResolverStyle;
|
import java.time.format.ResolverStyle;
|
||||||
import java.time.temporal.ChronoField;
|
import java.time.temporal.ChronoField;
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
|
||||||
public class StatementsService {
|
public class StatementsService {
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FORMAT = new DateTimeFormatterBuilder()
|
private static final DateTimeFormatter DATE_FORMAT = new DateTimeFormatterBuilder()
|
||||||
|
@ -42,21 +44,25 @@ public class StatementsService {
|
||||||
.toFormatter()
|
.toFormatter()
|
||||||
.withResolverStyle(ResolverStyle.LENIENT);
|
.withResolverStyle(ResolverStyle.LENIENT);
|
||||||
|
|
||||||
private RawStatementRepository rawStatementRepository;
|
private final RawStatementRepository rawStatementRepository;
|
||||||
|
|
||||||
private RawTransactionValueGroupRepository rawTransactionValueGroupRepository;
|
private final RawTransactionValueGroupRepository rawTransactionValueGroupRepository;
|
||||||
|
|
||||||
private RawTransactionValueRepository rawTransactionValueRepository;
|
private final RawTransactionValueRepository rawTransactionValueRepository;
|
||||||
|
|
||||||
|
private final TransactionMappingRepository transactionMappingRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public StatementsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository) {
|
public StatementsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository, TransactionMappingRepository transactionMappingRepository) {
|
||||||
this.rawStatementRepository = rawStatementRepository;
|
this.rawStatementRepository = rawStatementRepository;
|
||||||
this.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository;
|
this.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository;
|
||||||
this.rawTransactionValueRepository = rawTransactionValueRepository;
|
this.rawTransactionValueRepository = rawTransactionValueRepository;
|
||||||
|
this.transactionMappingRepository = transactionMappingRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void uploadStatementFromExcelSheetForUser(String fileName, String mimeType, InputStream workbookInputStream, int userId) throws IOException {
|
@Transactional
|
||||||
|
public void uploadStatementFromExcelSheetForUser(final String fileName, final String mimeType, final InputStream workbookInputStream, final int userId) throws IOException {
|
||||||
|
|
||||||
var workbook = WorkbookFactory.create(workbookInputStream);
|
var workbook = WorkbookFactory.create(workbookInputStream);
|
||||||
|
|
||||||
|
@ -121,9 +127,12 @@ public class StatementsService {
|
||||||
|
|
||||||
switch (group.getType()) {
|
switch (group.getType()) {
|
||||||
case STRING -> value.setStringValue(firstWorksheet.getRow(y).getCell(column).getStringCellValue());
|
case STRING -> value.setStringValue(firstWorksheet.getRow(y).getCell(column).getStringCellValue());
|
||||||
case NUMERIC -> value.setNumericValue(firstWorksheet.getRow(y).getCell(column).getNumericCellValue());
|
case NUMERIC ->
|
||||||
case TIMESTAMP -> value.setTimestampValue(LocalDateTime.parse(firstWorksheet.getRow(y).getCell(column).getStringCellValue().trim(), DATE_FORMAT));
|
value.setNumericValue(firstWorksheet.getRow(y).getCell(column).getNumericCellValue());
|
||||||
case BOOLEAN -> value.setBooleanValue(firstWorksheet.getRow(y).getCell(column).getBooleanCellValue());
|
case TIMESTAMP ->
|
||||||
|
value.setTimestampValue(LocalDateTime.parse(firstWorksheet.getRow(y).getCell(column).getStringCellValue().trim(), DATE_FORMAT));
|
||||||
|
case BOOLEAN ->
|
||||||
|
value.setBooleanValue(firstWorksheet.getRow(y).getCell(column).getBooleanCellValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
valueList.add(value);
|
valueList.add(value);
|
||||||
|
@ -135,7 +144,7 @@ public class StatementsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<RawTransactionValueType> determineGroupType(Sheet worksheet, int rowIndex, int columnIndex) {
|
private Optional<RawTransactionValueType> determineGroupType(final Sheet worksheet, final int rowIndex, final int columnIndex) {
|
||||||
var cell = worksheet.getRow(rowIndex).getCell(columnIndex);
|
var cell = worksheet.getRow(rowIndex).getCell(columnIndex);
|
||||||
|
|
||||||
if (cell == null || cell.getCellType() == CellType.BLANK) {
|
if (cell == null || cell.getCellType() == CellType.BLANK) {
|
||||||
|
@ -168,22 +177,62 @@ public class StatementsService {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<UploadedStatementDTO> fetchStatementsForUser(int userId) {
|
public Collection<UploadedStatementDTO> fetchStatementsForUser(final int userId) {
|
||||||
return rawStatementRepository.fetchAllForUser(userId)
|
return rawStatementRepository.fetchAllForUser(userId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(dto -> new UploadedStatementDTO(dto.getId(), dto.getName(), dto.getTimeCreated()))
|
.map(dto -> new UploadedStatementDTO(dto.getId(), dto.getName(), dto.getTimeCreated()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<TransactionValueGroupDTO> fetchTransactionValueGroupsForUserStatement(Long statementId, int userId) {
|
public Collection<TransactionValueGroupDTO> fetchTransactionValueGroupsForUserStatement(final Long statementId) {
|
||||||
return rawTransactionValueGroupRepository.fetchAllForStatementAndUser(statementId, userId)
|
return rawTransactionValueGroupRepository.fetchAllForStatement(statementId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(dto -> new TransactionValueGroupDTO(dto.getId(), dto.getName(), RawTransactionValueType.values()[dto.getType()]))
|
.map(dto -> new TransactionValueGroupDTO(dto.getId(), dto.getName(), RawTransactionValueType.values()[dto.getType()]))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteStatement(Long statementId, int userId) {
|
@Transactional
|
||||||
|
public void deleteStatement(final Long statementId) {
|
||||||
rawStatementRepository.deleteById(statementId);
|
rawStatementRepository.deleteById(statementId);
|
||||||
rawStatementRepository.flush();
|
rawStatementRepository.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Collection<TransactionMappingDTO> fetchMappingsForStatement(Long statementId) {
|
||||||
|
return transactionMappingRepository.fetchTransactionMappingsWithStatementId(statementId)
|
||||||
|
.stream()
|
||||||
|
.map(entity -> new TransactionMappingDTO(
|
||||||
|
entity.getId(),
|
||||||
|
entity.getRawTransactionValueGroupId(),
|
||||||
|
entity.getProcessedTransactionField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<CrudResponseDTO> createTransactionMappingsForStatement(Long statementId, Collection<CreateTransactionMappingDTO> dtos) {
|
||||||
|
return transactionMappingRepository.saveAllAndFlush(
|
||||||
|
dtos.stream()
|
||||||
|
.map(dto -> {
|
||||||
|
final var mapping = new TransactionMapping();
|
||||||
|
|
||||||
|
mapping.setRawTransactionValueGroupId(dto.rawTransactionValueGroupId());
|
||||||
|
mapping.setProcessedTransactionField(dto.field());
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
)
|
||||||
|
.stream()
|
||||||
|
.map(entity -> new CrudResponseDTO(entity.getId(), 1))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public void processStatement(Long statementId) {
|
||||||
|
final var mappings = transactionMappingRepository.fetchTransactionMappingsWithStatementId(statementId);
|
||||||
|
final var mappingByField = new HashMap<ProcessedTransactionField, Long>();
|
||||||
|
|
||||||
|
mappings.forEach(m -> mappingByField.put(m.getProcessedTransactionField(), m.getRawTransactionValueGroupId()));
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS categories.processed_transaction_category (
|
||||||
|
id BIGSERIAL,
|
||||||
|
processed_transaction_id BIGINT,
|
||||||
|
category_id BIGINT,
|
||||||
|
CONSTRAINT PK_processed_transaction_category PRIMARY KEY (id),
|
||||||
|
CONSTRAINT FK_processed_transaction_category_category FOREIGN KEY (category_id) REFERENCES categories.category(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_processed_transaction_category_processed_transaction FOREIGN KEY (processed_transaction_id) REFERENCES transactions.processed_transaction(id) ON DELETE CASCADE
|
||||||
|
);
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS trasactions.transaction_mapping (
|
||||||
|
id BIGSERIAL,
|
||||||
|
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
raw_transaction_value_group_id BIGINT,
|
||||||
|
processed_transaction_field VARCHAR(255),
|
||||||
|
CONSTRAINT PK_transaction_mapping PRIMARY KEY (id),
|
||||||
|
CONSTRAINT FK_transaction_mapping_raw_transaction_value_group FOREIGN KEY(raw_transaction_value_group_id) REFERENCES transactions.raw_transaction_value_group(id) ON DELETE CASCADE
|
||||||
|
);
|
|
@ -0,0 +1,22 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS categories.categorization (
|
||||||
|
id BIGSERIAL,
|
||||||
|
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
rule_based_on VARCHAR(255),
|
||||||
|
categorization_rule VARCHAR(255),
|
||||||
|
string_value VARCHAR(1024),
|
||||||
|
numeric_greater_than INTEGER,
|
||||||
|
numeric_less_than INTEGER,
|
||||||
|
numeric_value INTEGER,
|
||||||
|
timestamp_greater_than TIMESTAMP,
|
||||||
|
timestamp_less_than TIMESTAMP,
|
||||||
|
boolean_value BOOLEAN,
|
||||||
|
category_id BIGINT NULL,
|
||||||
|
left_categorization_id BIGINT,
|
||||||
|
right_categorization_id BIGINT,
|
||||||
|
CONSTRAINT PK_categorization PRIMARY KEY (id),
|
||||||
|
CONSTRAINT FK_categorization_category FOREIGN KEY (category_id) REFERENCES categories.category(id),
|
||||||
|
CONSTRAINT FK_categorization_categorization_left FOREIGN KEY (left_categorization_id) REFERENCES categories.categorization(left_categorization_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_categorization_categorization_right FOREIGN KEY (right_categorization_id) REFERENCES categories.categorization(right_categorization_id) ON DELETE CASCADE
|
||||||
|
);
|
|
@ -1 +1 @@
|
||||||
ALTER TABLE transactions.raw_statement ADD user_id INTEGER NOT NULL;
|
ALTER TABLE transactions.raw_statement ADD COLUMN IF NOT EXISTS user_id INTEGER NOT NULL;
|
|
@ -1 +1 @@
|
||||||
ALTER TABLE transactions.raw_transaction_value ADD boolean_value BOOLEAN;
|
ALTER TABLE transactions.raw_transaction_value ADD COLUMN IF NOT EXISTS boolean_value BOOLEAN;
|
|
@ -1,3 +1,3 @@
|
||||||
ALTER TABLE transactions.processed_transaction ADD user_id INTEGER;
|
ALTER TABLE transactions.processed_transaction ADD COLUMN IF NOT EXISTS user_id INTEGER;
|
||||||
|
|
||||||
ALTER TABLE categories.transaction_category ADD user_id INTEGER;
|
ALTER TABLE categories.transaction_category ADD COLUMN IF NOT EXISTS user_id INTEGER;
|
|
@ -1 +1 @@
|
||||||
ALTER TABLE transactions.raw_transaction_value ADD row_index INTEGER NOT NULL;
|
ALTER TABLE transactions.raw_transaction_value ADD COLUMN IF NOT EXISTS row_index INTEGER NOT NULL;
|
|
@ -1 +1 @@
|
||||||
ALTER TABLE transactions.raw_statement ADD name VARCHAR(255);
|
ALTER TABLE transactions.raw_statement ADD COLUMN IF NOT EXISTS name VARCHAR(255);
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE transactions.processed_transaction
|
||||||
|
DROP COLUMN IF EXISTS category_id;
|
Loading…
Add table
Reference in a new issue