mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-19 14:19:52 +03:00
Statement parsing works, so does transaction visualization
This commit is contained in:
parent
ad0bce4eba
commit
03d5d23a03
44 changed files with 1043 additions and 167 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="gradleJvm" value="openjdk-21" />
|
<option name="gradleJvm" value="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="openjdk-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
|
@ -6,7 +6,9 @@ import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public abstract class AbstractRestController {
|
public abstract class AbstractRestController {
|
||||||
|
|
||||||
|
@ -30,6 +32,15 @@ public abstract class AbstractRestController {
|
||||||
return withStatus(HttpStatus.CREATED, new CrudResponseDTO(id, null));
|
return withStatus(HttpStatus.CREATED, new CrudResponseDTO(id, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> created(Collection<Long> ids) {
|
||||||
|
return withStatus(
|
||||||
|
HttpStatus.CREATED,
|
||||||
|
ids.stream()
|
||||||
|
.map(id -> new CrudResponseDTO(id, null))
|
||||||
|
.collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> updated(Integer affectedRows) {
|
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> updated(Integer affectedRows) {
|
||||||
return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows));
|
return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows));
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,10 @@ public abstract class AbstractEnumConverter<T extends Enum<T> & PersistableEnum<
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public T convertToEntityAttribute(E dbData) {
|
public T convertToEntityAttribute(E dbData) {
|
||||||
|
if (dbData == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
T[] enums = clazz.getEnumConstants();
|
T[] enums = clazz.getEnumConstants();
|
||||||
|
|
||||||
for (T e : enums) {
|
for (T e : enums) {
|
||||||
|
@ -26,6 +30,6 @@ public abstract class AbstractEnumConverter<T extends Enum<T> & PersistableEnum<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException(String.format("Can't find value '%s' for enum '%s'", dbData, clazz.getCanonicalName()));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,7 @@
|
||||||
package dev.mvvasilev.common.exceptions;
|
package dev.mvvasilev.common.exceptions;
|
||||||
|
|
||||||
public class InvalidUserIdException extends CommonFinancesException {
|
public class InvalidUserIdException extends CommonFinancesException {
|
||||||
|
public InvalidUserIdException() {
|
||||||
|
super("UserId is invalid");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,21 +60,21 @@ public class CategoriesController extends AbstractRestController {
|
||||||
|
|
||||||
@PostMapping("/{categoryId}/rules")
|
@PostMapping("/{categoryId}/rules")
|
||||||
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> createCategorizationRule(
|
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createCategorizationRule(
|
||||||
@PathVariable("categoryId") Long categoryId,
|
@PathVariable("categoryId") Long categoryId,
|
||||||
@RequestBody Collection<CreateCategorizationDTO> dto
|
@RequestBody Collection<CreateCategorizationDTO> dto
|
||||||
) {
|
) {
|
||||||
return created(categoryService.createCategorizationRule(categoryId, dto));
|
return created(categoryService.createCategorizationRules(categoryId, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{categoryId}/rules/{ruleId}")
|
// @DeleteMapping("/{categoryId}/rules/{ruleId}")
|
||||||
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
// @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> deleteCategorizationRule(
|
// public ResponseEntity<APIResponseDTO<CrudResponseDTO>> deleteCategorizationRule(
|
||||||
@PathVariable("categoryId") Long categoryId,
|
// @PathVariable("categoryId") Long categoryId,
|
||||||
@PathVariable("ruleId") Long ruleId
|
// @PathVariable("ruleId") Long ruleId
|
||||||
) {
|
// ) {
|
||||||
return deleted(categoryService.deleteCategorizationRule(ruleId));
|
// return deleted(categoryService.deleteCategorizationRule(ruleId));
|
||||||
}
|
// }
|
||||||
|
|
||||||
@PostMapping("/categorize")
|
@PostMapping("/categorize")
|
||||||
public ResponseEntity<APIResponseDTO<Object>> categorizeTransactions(Authentication authentication) {
|
public ResponseEntity<APIResponseDTO<Object>> categorizeTransactions(Authentication authentication) {
|
||||||
|
|
|
@ -2,18 +2,55 @@ 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.finances.dtos.ProcessedTransactionDTO;
|
||||||
|
import dev.mvvasilev.finances.dtos.ProcessedTransactionFieldDTO;
|
||||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import dev.mvvasilev.finances.services.ProcessedTransactionService;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/processed-transactions")
|
@RequestMapping("/processed-transactions")
|
||||||
public class ProcessedTransactionsController extends AbstractRestController {
|
public class ProcessedTransactionsController extends AbstractRestController {
|
||||||
|
|
||||||
|
final private ProcessedTransactionService processedTransactionService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ProcessedTransactionsController(ProcessedTransactionService processedTransactionService) {
|
||||||
|
this.processedTransactionService = processedTransactionService;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/fields")
|
@GetMapping("/fields")
|
||||||
public ResponseEntity<APIResponseDTO<ProcessedTransactionField[]>> fetchFields() {
|
public ResponseEntity<APIResponseDTO<Collection<ProcessedTransactionFieldDTO>>> fetchFields() {
|
||||||
return ok(ProcessedTransactionField.values());
|
return ok(
|
||||||
|
Arrays.stream(ProcessedTransactionField.values())
|
||||||
|
.map(field -> new ProcessedTransactionFieldDTO(field, field.type()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<APIResponseDTO<Page<ProcessedTransactionDTO>>> fetchProcessedTransactions(
|
||||||
|
Authentication authentication,
|
||||||
|
@NotNull final Pageable pageable
|
||||||
|
) {
|
||||||
|
return ok(processedTransactionService.fetchPagedProcessedTransactionsForUser(
|
||||||
|
Integer.parseInt(authentication.getName()),
|
||||||
|
pageable
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,8 @@ 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.common.web.CrudResponseDTO;
|
||||||
import dev.mvvasilev.finances.dtos.CreateTransactionMappingDTO;
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
import dev.mvvasilev.finances.dtos.TransactionMappingDTO;
|
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||||
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
|
|
||||||
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.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
@ -17,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
@ -51,6 +50,15 @@ public class StatementsController extends AbstractRestController {
|
||||||
return ok(statementsService.fetchMappingsForStatement(statementId));
|
return ok(statementsService.fetchMappingsForStatement(statementId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/supported-conversions")
|
||||||
|
public ResponseEntity<APIResponseDTO<Collection<SupportedMappingConversionDTO>>> fetchTransactionMappings() {
|
||||||
|
return ok(Arrays.stream(MappingConversionType.values()).map(conv -> new SupportedMappingConversionDTO(
|
||||||
|
conv,
|
||||||
|
conv.getFrom(),
|
||||||
|
conv.getTo()
|
||||||
|
)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{statementId}/mappings")
|
@PostMapping("/{statementId}/mappings")
|
||||||
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createTransactionMappings(
|
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createTransactionMappings(
|
||||||
|
@ -63,8 +71,8 @@ public class StatementsController extends AbstractRestController {
|
||||||
|
|
||||||
@PostMapping("/{statementId}/process")
|
@PostMapping("/{statementId}/process")
|
||||||
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||||
public ResponseEntity<APIResponseDTO<Object>> processTransactions(@PathVariable("statementId") Long statementId) {
|
public ResponseEntity<APIResponseDTO<Object>> processTransactions(@PathVariable("statementId") Long statementId, Authentication authentication) {
|
||||||
statementsService.processStatement(statementId);
|
statementsService.processStatement(statementId, Integer.parseInt(authentication.getName()));
|
||||||
return emptySuccess();
|
return emptySuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,19 @@
|
||||||
package dev.mvvasilev.finances.dtos;
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
public record CreateTransactionMappingDTO(
|
public record CreateTransactionMappingDTO(
|
||||||
@NotNull
|
@NotNull
|
||||||
Long rawTransactionValueGroupId,
|
Long rawTransactionValueGroupId,
|
||||||
@NotNull
|
@NotNull
|
||||||
ProcessedTransactionField field
|
ProcessedTransactionField field,
|
||||||
|
MappingConversionType conversionType,
|
||||||
|
@Length(max = 255)
|
||||||
|
String trueBranchStringValue,
|
||||||
|
@Length(max = 255)
|
||||||
|
String falseBranchStringValue
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
public record ProcessedTransactionDTO(
|
||||||
|
Long id,
|
||||||
|
Double amount,
|
||||||
|
boolean isInflow,
|
||||||
|
LocalDateTime timestamp,
|
||||||
|
String description,
|
||||||
|
Collection<TransactionCategoryDTO> categories
|
||||||
|
) {}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import dev.mvvasilev.finances.enums.RawTransactionValueType;
|
||||||
|
|
||||||
|
public record ProcessedTransactionFieldDTO(
|
||||||
|
ProcessedTransactionField field,
|
||||||
|
RawTransactionValueType type
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||||
|
import dev.mvvasilev.finances.enums.RawTransactionValueType;
|
||||||
|
|
||||||
|
public record SupportedMappingConversionDTO(
|
||||||
|
MappingConversionType type,
|
||||||
|
RawTransactionValueType from,
|
||||||
|
RawTransactionValueType to
|
||||||
|
) {
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
public record TransactionCategoryDTO(
|
||||||
|
Long id,
|
||||||
|
String name
|
||||||
|
) {}
|
|
@ -1,10 +1,14 @@
|
||||||
package dev.mvvasilev.finances.dtos;
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
|
||||||
public record TransactionMappingDTO(
|
public record TransactionMappingDTO(
|
||||||
Long id,
|
Long id,
|
||||||
Long rawTransactionValueGroupId,
|
Long rawTransactionValueGroupId,
|
||||||
ProcessedTransactionField processedTransactionField
|
ProcessedTransactionFieldDTO processedTransactionField,
|
||||||
|
SupportedMappingConversionDTO conversionType,
|
||||||
|
String trueBranchStringValue,
|
||||||
|
String falseBranchStringValue
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,15 +2,29 @@ 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.common.data.UserOwned;
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(schema = "transactions")
|
@Table(schema = "transactions")
|
||||||
public class ProcessedTransaction extends AbstractEntity implements UserOwned {
|
public class ProcessedTransaction extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
|
// This const is a result of the limitations of the JVM.
|
||||||
|
// It is impossible to refer to either a field, or method of a class statically.
|
||||||
|
// Because of this, it is very difficult to tie the ProcessedTransactionField values to the actual class fields they represent.
|
||||||
|
// To resolve this imperfection, this const lives here, in plain view, so when one of the fields is changed,
|
||||||
|
// hopefully the programmer remembers to change the value inside as well.
|
||||||
|
public static final Map<ProcessedTransactionField, String> FIELD_NAMES = Map.of(
|
||||||
|
ProcessedTransactionField.DESCRIPTION, "description",
|
||||||
|
ProcessedTransactionField.AMOUNT, "amount",
|
||||||
|
ProcessedTransactionField.IS_INFLOW, "isInflow",
|
||||||
|
ProcessedTransactionField.TIMESTAMP, "timestamp"
|
||||||
|
);
|
||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
private Integer userId;
|
private Integer userId;
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
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.finances.enums.ProcessedTransactionField;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(schema = "transactions")
|
@Table(schema = "transactions")
|
||||||
|
|
|
@ -2,6 +2,7 @@ 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.common.data.UserOwned;
|
||||||
|
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
import jakarta.persistence.Convert;
|
import jakarta.persistence.Convert;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
@ -16,6 +17,13 @@ public class TransactionMapping extends AbstractEntity {
|
||||||
@Convert(converter = ProcessedTransactionField.JpaConverter.class)
|
@Convert(converter = ProcessedTransactionField.JpaConverter.class)
|
||||||
private ProcessedTransactionField processedTransactionField;
|
private ProcessedTransactionField processedTransactionField;
|
||||||
|
|
||||||
|
@Convert(converter = MappingConversionType.JpaConverter.class)
|
||||||
|
private MappingConversionType conversionType;
|
||||||
|
|
||||||
|
private String trueBranchStringValue;
|
||||||
|
|
||||||
|
private String falseBranchStringValue;
|
||||||
|
|
||||||
public TransactionMapping() {
|
public TransactionMapping() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,4 +42,28 @@ public class TransactionMapping extends AbstractEntity {
|
||||||
public void setProcessedTransactionField(ProcessedTransactionField processedTransactionField) {
|
public void setProcessedTransactionField(ProcessedTransactionField processedTransactionField) {
|
||||||
this.processedTransactionField = processedTransactionField;
|
this.processedTransactionField = processedTransactionField;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public MappingConversionType getConversionType() {
|
||||||
|
return conversionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversionType(MappingConversionType conversionType) {
|
||||||
|
this.conversionType = conversionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrueBranchStringValue() {
|
||||||
|
return trueBranchStringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrueBranchStringValue(String trueBranchStringValue) {
|
||||||
|
this.trueBranchStringValue = trueBranchStringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFalseBranchStringValue() {
|
||||||
|
return falseBranchStringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFalseBranchStringValue(String falseBranchStringValue) {
|
||||||
|
this.falseBranchStringValue = falseBranchStringValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
package dev.mvvasilev.finances.enums;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.data.AbstractEnumConverter;
|
||||||
|
import dev.mvvasilev.common.data.PersistableEnum;
|
||||||
|
|
||||||
|
// TODO: Only single type of conversion currently supported: from string to boolean
|
||||||
|
// TODO: Add more: *_TO_NUMERIC, *_TO_TIMESTAMP, *_TO_STRING
|
||||||
|
// TODO: Also, figure out a better way to do these?
|
||||||
|
public enum MappingConversionType implements PersistableEnum<String> {
|
||||||
|
STRING_TO_BOOLEAN(RawTransactionValueType.STRING, RawTransactionValueType.BOOLEAN);
|
||||||
|
|
||||||
|
// TODO: Add the rest
|
||||||
|
// STRING_TO_TIMESTAMP(RawTransactionValueType.STRING, RawTransactionValueType.TIMESTAMP),
|
||||||
|
// STRING_TO_NUMERIC(RawTransactionValueType.STRING, RawTransactionValueType.NUMERIC),
|
||||||
|
// BOOLEAN_TO_STRING(RawTransactionValueType.BOOLEAN, RawTransactionValueType.STRING),
|
||||||
|
// BOOLEAN_TO_TIMESTAMP(RawTransactionValueType.BOOLEAN, RawTransactionValueType.TIMESTAMP),
|
||||||
|
// BOOLEAN_TO_NUMERIC(RawTransactionValueType.BOOLEAN, RawTransactionValueType.NUMERIC),
|
||||||
|
// TIMESTAMP_TO_STRING(RawTransactionValueType.TIMESTAMP, RawTransactionValueType.STRING),
|
||||||
|
// TIMESTAMP_TO_BOOLEAN(RawTransactionValueType.TIMESTAMP, RawTransactionValueType.BOOLEAN),
|
||||||
|
// TIMESTAMP_TO_NUMERIC(RawTransactionValueType.TIMESTAMP, RawTransactionValueType.NUMERIC),
|
||||||
|
// NUMERIC_TO_STRING(RawTransactionValueType.NUMERIC, RawTransactionValueType.STRING),
|
||||||
|
// NUMERIC_TO_BOOLEAN(RawTransactionValueType.NUMERIC, RawTransactionValueType.BOOLEAN),
|
||||||
|
// NUMERIC_TO_TIMESTAMP(RawTransactionValueType.NUMERIC, RawTransactionValueType.TIMESTAMP);
|
||||||
|
|
||||||
|
private final RawTransactionValueType from;
|
||||||
|
private final RawTransactionValueType to;
|
||||||
|
|
||||||
|
MappingConversionType(RawTransactionValueType from, RawTransactionValueType to) {
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RawTransactionValueType getFrom() {
|
||||||
|
return from;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RawTransactionValueType getTo() {
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String value() {
|
||||||
|
return name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class JpaConverter extends AbstractEnumConverter<MappingConversionType, String> {
|
||||||
|
public JpaConverter() {
|
||||||
|
super(MappingConversionType.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,8 +2,10 @@ package dev.mvvasilev.finances.enums;
|
||||||
|
|
||||||
import dev.mvvasilev.common.data.AbstractEnumConverter;
|
import dev.mvvasilev.common.data.AbstractEnumConverter;
|
||||||
import dev.mvvasilev.common.data.PersistableEnum;
|
import dev.mvvasilev.common.data.PersistableEnum;
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
// TODO: Create custom converter for JPA
|
|
||||||
public enum ProcessedTransactionField implements PersistableEnum<String> {
|
public enum ProcessedTransactionField implements PersistableEnum<String> {
|
||||||
DESCRIPTION(RawTransactionValueType.STRING),
|
DESCRIPTION(RawTransactionValueType.STRING),
|
||||||
AMOUNT(RawTransactionValueType.NUMERIC),
|
AMOUNT(RawTransactionValueType.NUMERIC),
|
||||||
|
@ -16,7 +18,6 @@ public enum ProcessedTransactionField implements PersistableEnum<String> {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public String value() {
|
public String value() {
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package dev.mvvasilev.finances.persistence;
|
||||||
import dev.mvvasilev.finances.dtos.CategorizationDTO;
|
import dev.mvvasilev.finances.dtos.CategorizationDTO;
|
||||||
import dev.mvvasilev.finances.entity.Categorization;
|
import dev.mvvasilev.finances.entity.Categorization;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
@ -27,11 +28,35 @@ public interface CategorizationRepository extends JpaRepository<Categorization,
|
||||||
// TODO: Use Recursive CTE
|
// TODO: Use Recursive CTE
|
||||||
@Query(
|
@Query(
|
||||||
value = """
|
value = """
|
||||||
|
WITH RECURSIVE cats AS (
|
||||||
SELECT cat.*
|
SELECT cat.*
|
||||||
FROM categories.categorization AS cat
|
FROM categories.categorization AS cat
|
||||||
WHERE category_id = :categoryId
|
WHERE cat.category_id = :categoryId
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT l.*
|
||||||
|
FROM categories.categorization AS l
|
||||||
|
JOIN cats ON cats.`left` = l.id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT r.*
|
||||||
|
FROM categories.categorization AS r
|
||||||
|
JOIN cats ON cats.`right` = r.id
|
||||||
|
)
|
||||||
|
SELECT * FROM cats;
|
||||||
""",
|
""",
|
||||||
nativeQuery = true
|
nativeQuery = true
|
||||||
)
|
)
|
||||||
Collection<Categorization> fetchForCategory(@Param("categoryId") Long categoryId);
|
Collection<Categorization> fetchForCategory(@Param("categoryId") Long categoryId);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
DELETE FROM categories.categorization WHERE category_id = :categoryId
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
@Modifying
|
||||||
|
int deleteAllForCategory(@Param("categoryId") Long categoryId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
package dev.mvvasilev.finances.persistence;
|
package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
@ -13,4 +16,10 @@ public interface ProcessedTransactionRepository extends JpaRepository<ProcessedT
|
||||||
|
|
||||||
@Query(value = "SELECT * FROM transactions.processed_transaction WHERE user_id = :userId", nativeQuery = true)
|
@Query(value = "SELECT * FROM transactions.processed_transaction WHERE user_id = :userId", nativeQuery = true)
|
||||||
Collection<ProcessedTransaction> fetchForUser(@Param("userId") int userId);
|
Collection<ProcessedTransaction> fetchForUser(@Param("userId") int userId);
|
||||||
|
|
||||||
|
Page<ProcessedTransaction> findAllByUserId(int userId, Pageable pageable);
|
||||||
|
|
||||||
|
@Query(value = "DELETE FROM transactions.processed_transaction WHERE statement_id = :statementId", nativeQuery = true)
|
||||||
|
@Modifying
|
||||||
|
void deleteProcessedTransactionsForStatement(@Param("statementId") Long statementId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,21 @@ package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
import dev.mvvasilev.finances.entity.RawTransactionValue;
|
import dev.mvvasilev.finances.entity.RawTransactionValue;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface RawTransactionValueRepository extends JpaRepository<RawTransactionValue, Long> {
|
public interface RawTransactionValueRepository extends JpaRepository<RawTransactionValue, Long> {
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
SELECT rtv.*
|
||||||
|
FROM transactions.raw_transaction_value AS rtv
|
||||||
|
WHERE rtv.group_id IN (:valueGroupIds)
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
Collection<RawTransactionValue> fetchAllValuesForValueGroups(@Param("valueGroupIds") Collection<Long> values);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package dev.mvvasilev.finances.persistence;
|
||||||
import dev.mvvasilev.finances.dtos.CategoryDTO;
|
import dev.mvvasilev.finances.dtos.CategoryDTO;
|
||||||
import dev.mvvasilev.finances.entity.TransactionCategory;
|
import dev.mvvasilev.finances.entity.TransactionCategory;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
@ -15,5 +16,6 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
|
||||||
Collection<TransactionCategory> fetchTransactionCategoriesWithUserId(@Param("userId") int userId);
|
Collection<TransactionCategory> fetchTransactionCategoriesWithUserId(@Param("userId") int userId);
|
||||||
|
|
||||||
@Query(value = "UPDATE categories.transaction_category SET name = :name WHERE id = :categoryId", nativeQuery = true)
|
@Query(value = "UPDATE categories.transaction_category SET name = :name WHERE id = :categoryId", nativeQuery = true)
|
||||||
|
@Modifying
|
||||||
int updateTransactionCategoryName(@Param("categoryId") Long categoryId, @Param("name") String name);
|
int updateTransactionCategoryName(@Param("categoryId") Long categoryId, @Param("name") String name);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
import dev.mvvasilev.finances.entity.TransactionMapping;
|
import dev.mvvasilev.finances.entity.TransactionMapping;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
@ -14,13 +15,28 @@ public interface TransactionMappingRepository extends JpaRepository<TransactionM
|
||||||
@Query(
|
@Query(
|
||||||
value = """
|
value = """
|
||||||
SELECT tm.*
|
SELECT tm.*
|
||||||
FROM transactions.transaction_mappings AS tm
|
FROM transactions.transaction_mapping AS tm
|
||||||
JOIN transactions.raw_transaction_value_group AS rtvg ON rtvg.id = tm.raw_transaction_value_group_id
|
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
|
JOIN transactions.raw_statement AS s ON s.id = rtvg.statement_id
|
||||||
WHERE s.id = :statementId
|
WHERE s.id = :statementId
|
||||||
""",
|
""",
|
||||||
nativeQuery = true
|
nativeQuery = true
|
||||||
)
|
)
|
||||||
Collection<TransactionMapping> fetchTransactionMappingsWithStatementId(@Param("statementId") Long statementId);
|
Collection<TransactionMapping> fetchTransactionMappingsWithStatementId(@Param("statementId") Long statementId);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
DELETE FROM transactions.transaction_mapping AS tm
|
||||||
|
WHERE tm.id IN (
|
||||||
|
SELECT m.id
|
||||||
|
FROM transactions.transaction_mapping AS m
|
||||||
|
JOIN transactions.raw_transaction_value_group AS rtvg ON rtvg.id = m.raw_transaction_value_group_id
|
||||||
|
JOIN transactions.raw_statement AS s ON s.id = rtvg.statement_id
|
||||||
|
WHERE s.id = :statementId
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
@Modifying
|
||||||
|
void deleteAllForStatement(@Param("statementId") Long statementId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package dev.mvvasilev.finances.services;
|
package dev.mvvasilev.finances.services;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
||||||
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
import dev.mvvasilev.finances.dtos.*;
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
import dev.mvvasilev.finances.entity.Categorization;
|
import dev.mvvasilev.finances.entity.Categorization;
|
||||||
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
@ -10,8 +12,10 @@ import dev.mvvasilev.finances.persistence.CategorizationRepository;
|
||||||
import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository;
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository;
|
||||||
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
||||||
import dev.mvvasilev.finances.persistence.TransactionCategoryRepository;
|
import dev.mvvasilev.finances.persistence.TransactionCategoryRepository;
|
||||||
|
import org.apache.commons.compress.utils.Lists;
|
||||||
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.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
@ -22,6 +26,7 @@ import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Transactional
|
||||||
public class CategoryService {
|
public class CategoryService {
|
||||||
|
|
||||||
final private TransactionCategoryRepository transactionCategoryRepository;
|
final private TransactionCategoryRepository transactionCategoryRepository;
|
||||||
|
@ -175,6 +180,7 @@ public class CategoryService {
|
||||||
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Avoid recursion
|
||||||
yield matchesRule(allCategorizations, left.get(), processedTransaction) && matchesRule(allCategorizations, right.get(), processedTransaction);
|
yield matchesRule(allCategorizations, left.get(), processedTransaction) && matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||||
}
|
}
|
||||||
case OR -> {
|
case OR -> {
|
||||||
|
@ -186,6 +192,7 @@ public class CategoryService {
|
||||||
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Avoid recursion
|
||||||
yield matchesRule(allCategorizations, left.get(), processedTransaction) || matchesRule(allCategorizations, right.get(), processedTransaction);
|
yield matchesRule(allCategorizations, left.get(), processedTransaction) || matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||||
}
|
}
|
||||||
case NOT -> {
|
case NOT -> {
|
||||||
|
@ -193,6 +200,7 @@ public class CategoryService {
|
||||||
throw new CommonFinancesException("Invalid categorization: right does not exist");
|
throw new CommonFinancesException("Invalid categorization: right does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Avoid recursion
|
||||||
yield !matchesRule(allCategorizations, right.get(), processedTransaction);
|
yield !matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||||
}
|
}
|
||||||
default -> throw new CommonFinancesException("Invalid logical operation: %s", categorization.getCategorizationRule());
|
default -> throw new CommonFinancesException("Invalid logical operation: %s", categorization.getCategorizationRule());
|
||||||
|
@ -239,38 +247,48 @@ public class CategoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) {
|
public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) {
|
||||||
return categorizationRepository.fetchForCategory(categoryId).stream()
|
return Lists.newArrayList();
|
||||||
.map(entity -> {
|
}
|
||||||
// TODO: Recursion
|
|
||||||
})
|
public Collection<Long> createCategorizationRules(Long categoryId, Collection<CreateCategorizationDTO> dtos) {
|
||||||
|
categorizationRepository.deleteAllForCategory(categoryId);
|
||||||
|
|
||||||
|
final var newCategorizations = dtos.stream()
|
||||||
|
.map(dto -> saveCategorizationRule(categoryId, dto))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return categorizationRepository.saveAllAndFlush(newCategorizations).stream()
|
||||||
|
.map(AbstractEntity::getId)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long createCategorizationRule(Long categoryId, Collection<CreateCategorizationDTO> dto) {
|
private Categorization saveCategorizationRule(Long categoryId, CreateCategorizationDTO dto) {
|
||||||
// TODO: Clear previous rules for category and replace with new ones
|
// TODO: Avoid recursion
|
||||||
|
|
||||||
// final var categorization = new Categorization();
|
final var categorization = new Categorization();
|
||||||
//
|
|
||||||
// categorization.setCategorizationRule(dto.rule());
|
categorization.setCategorizationRule(dto.rule());
|
||||||
// categorization.setCategoryId(categoryId);
|
categorization.setCategoryId(null);
|
||||||
// categorization.setStringValue(dto.stringValue());
|
categorization.setStringValue(dto.stringValue());
|
||||||
// categorization.setNumericGreaterThan(dto.numericGreaterThan());
|
categorization.setNumericGreaterThan(dto.numericGreaterThan());
|
||||||
// categorization.setNumericLessThan(dto.numericLessThan());
|
categorization.setNumericLessThan(dto.numericLessThan());
|
||||||
// categorization.setNumericValue(dto.numericValue());
|
categorization.setNumericValue(dto.numericValue());
|
||||||
// categorization.setTimestampGreaterThan(dto.timestampGreaterThan());
|
categorization.setTimestampGreaterThan(dto.timestampGreaterThan());
|
||||||
// categorization.setTimestampLessThan(dto.timestampLessThan());
|
categorization.setTimestampLessThan(dto.timestampLessThan());
|
||||||
// categorization.setBooleanValue(dto.booleanValue());
|
categorization.setBooleanValue(dto.booleanValue());
|
||||||
//
|
|
||||||
// if (dto.left() != null) {
|
// Only root rules have category id set, to differentiate them from non-roots
|
||||||
// final var leftCat = createCategorizationRule(null, dto.left());
|
// TODO: This smells bad. Add an isRoot property instead?
|
||||||
// categorization.setLeftCategorizationId(leftCat);
|
if (dto.left() != null) {
|
||||||
// }
|
final var leftCat = saveCategorizationRule(null, dto.left());
|
||||||
//
|
categorization.setLeftCategorizationId(leftCat.getId());
|
||||||
// if (dto.right() != null) {
|
}
|
||||||
// final var rightCat = createCategorizationRule(null, dto.right());
|
|
||||||
// categorization.setRightCategorizationId(rightCat);
|
if (dto.right() != null) {
|
||||||
// }
|
final var rightCat = saveCategorizationRule(null, dto.right());
|
||||||
//
|
categorization.setRightCategorizationId(rightCat.getId());
|
||||||
// return categorizationRepository.save(categorization).getId();
|
}
|
||||||
|
|
||||||
|
return categorizationRepository.saveAndFlush(categorization);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package dev.mvvasilev.finances.services;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.dtos.ProcessedTransactionDTO;
|
||||||
|
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||||
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
||||||
|
import org.apache.commons.compress.utils.Lists;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ProcessedTransactionService {
|
||||||
|
|
||||||
|
final private ProcessedTransactionRepository processedTransactionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ProcessedTransactionService(ProcessedTransactionRepository processedTransactionRepository) {
|
||||||
|
this.processedTransactionRepository = processedTransactionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<ProcessedTransactionDTO> fetchPagedProcessedTransactionsForUser(int userId, final Pageable pageable) {
|
||||||
|
return processedTransactionRepository.findAllByUserId(userId, pageable)
|
||||||
|
.map(t -> new ProcessedTransactionDTO(
|
||||||
|
t.getId(),
|
||||||
|
t.getAmount(),
|
||||||
|
t.isInflow(),
|
||||||
|
t.getTimestamp(),
|
||||||
|
t.getDescription(),
|
||||||
|
Lists.newArrayList() // TODO: Fetch categories. Do it all in SQL for better performance.
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +1,18 @@
|
||||||
package dev.mvvasilev.finances.services;
|
package dev.mvvasilev.finances.services;
|
||||||
|
|
||||||
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
|
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
||||||
import dev.mvvasilev.common.web.CrudResponseDTO;
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
import dev.mvvasilev.finances.dtos.CreateTransactionMappingDTO;
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
import dev.mvvasilev.finances.dtos.TransactionMappingDTO;
|
import dev.mvvasilev.finances.entity.*;
|
||||||
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
|
|
||||||
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
|
|
||||||
import dev.mvvasilev.finances.entity.RawStatement;
|
|
||||||
import dev.mvvasilev.finances.entity.RawTransactionValue;
|
|
||||||
import dev.mvvasilev.finances.entity.RawTransactionValueGroup;
|
|
||||||
import dev.mvvasilev.finances.entity.TransactionMapping;
|
|
||||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
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.*;
|
||||||
import dev.mvvasilev.finances.persistence.RawTransactionValueGroupRepository;
|
|
||||||
import dev.mvvasilev.finances.persistence.RawTransactionValueRepository;
|
|
||||||
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.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
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 org.springframework.transaction.annotation.Transactional;
|
||||||
|
@ -31,9 +26,11 @@ 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.*;
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
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()
|
||||||
|
@ -44,6 +41,8 @@ public class StatementsService {
|
||||||
.toFormatter()
|
.toFormatter()
|
||||||
.withResolverStyle(ResolverStyle.LENIENT);
|
.withResolverStyle(ResolverStyle.LENIENT);
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(StatementsService.class);
|
||||||
|
|
||||||
private final RawStatementRepository rawStatementRepository;
|
private final RawStatementRepository rawStatementRepository;
|
||||||
|
|
||||||
private final RawTransactionValueGroupRepository rawTransactionValueGroupRepository;
|
private final RawTransactionValueGroupRepository rawTransactionValueGroupRepository;
|
||||||
|
@ -52,12 +51,15 @@ public class StatementsService {
|
||||||
|
|
||||||
private final TransactionMappingRepository transactionMappingRepository;
|
private final TransactionMappingRepository transactionMappingRepository;
|
||||||
|
|
||||||
|
private final ProcessedTransactionRepository processedTransactionRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public StatementsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository, TransactionMappingRepository transactionMappingRepository) {
|
public StatementsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository, TransactionMappingRepository transactionMappingRepository, ProcessedTransactionRepository processedTransactionRepository) {
|
||||||
this.rawStatementRepository = rawStatementRepository;
|
this.rawStatementRepository = rawStatementRepository;
|
||||||
this.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository;
|
this.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository;
|
||||||
this.rawTransactionValueRepository = rawTransactionValueRepository;
|
this.rawTransactionValueRepository = rawTransactionValueRepository;
|
||||||
this.transactionMappingRepository = transactionMappingRepository;
|
this.transactionMappingRepository = transactionMappingRepository;
|
||||||
|
this.processedTransactionRepository = processedTransactionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -203,13 +205,26 @@ public class StatementsService {
|
||||||
.map(entity -> new TransactionMappingDTO(
|
.map(entity -> new TransactionMappingDTO(
|
||||||
entity.getId(),
|
entity.getId(),
|
||||||
entity.getRawTransactionValueGroupId(),
|
entity.getRawTransactionValueGroupId(),
|
||||||
entity.getProcessedTransactionField()
|
new ProcessedTransactionFieldDTO(
|
||||||
|
entity.getProcessedTransactionField(),
|
||||||
|
entity.getProcessedTransactionField().type()
|
||||||
|
),
|
||||||
|
entity.getConversionType() != null ?
|
||||||
|
new SupportedMappingConversionDTO(
|
||||||
|
entity.getConversionType(),
|
||||||
|
entity.getConversionType().getFrom(),
|
||||||
|
entity.getConversionType().getTo()
|
||||||
|
) : null,
|
||||||
|
entity.getTrueBranchStringValue(),
|
||||||
|
entity.getFalseBranchStringValue()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<CrudResponseDTO> createTransactionMappingsForStatement(Long statementId, Collection<CreateTransactionMappingDTO> dtos) {
|
public Collection<CrudResponseDTO> createTransactionMappingsForStatement(Long statementId, Collection<CreateTransactionMappingDTO> dtos) {
|
||||||
|
transactionMappingRepository.deleteAllForStatement(statementId);
|
||||||
|
|
||||||
return transactionMappingRepository.saveAllAndFlush(
|
return transactionMappingRepository.saveAllAndFlush(
|
||||||
dtos.stream()
|
dtos.stream()
|
||||||
.map(dto -> {
|
.map(dto -> {
|
||||||
|
@ -218,6 +233,12 @@ public class StatementsService {
|
||||||
mapping.setRawTransactionValueGroupId(dto.rawTransactionValueGroupId());
|
mapping.setRawTransactionValueGroupId(dto.rawTransactionValueGroupId());
|
||||||
mapping.setProcessedTransactionField(dto.field());
|
mapping.setProcessedTransactionField(dto.field());
|
||||||
|
|
||||||
|
if (dto.conversionType() != null) {
|
||||||
|
mapping.setConversionType(dto.conversionType());
|
||||||
|
mapping.setTrueBranchStringValue(dto.trueBranchStringValue());
|
||||||
|
mapping.setFalseBranchStringValue(dto.falseBranchStringValue());
|
||||||
|
}
|
||||||
|
|
||||||
return mapping;
|
return mapping;
|
||||||
})
|
})
|
||||||
.toList()
|
.toList()
|
||||||
|
@ -228,11 +249,98 @@ public class StatementsService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void processStatement(Long statementId) {
|
public void processStatement(Long statementId, Integer userId) {
|
||||||
|
processedTransactionRepository.deleteProcessedTransactionsForStatement(statementId);
|
||||||
|
|
||||||
final var mappings = transactionMappingRepository.fetchTransactionMappingsWithStatementId(statementId);
|
final var mappings = transactionMappingRepository.fetchTransactionMappingsWithStatementId(statementId);
|
||||||
final var mappingByField = new HashMap<ProcessedTransactionField, Long>();
|
|
||||||
|
|
||||||
mappings.forEach(m -> mappingByField.put(m.getProcessedTransactionField(), m.getRawTransactionValueGroupId()));
|
final var processedTransactions = rawTransactionValueRepository
|
||||||
|
.fetchAllValuesForValueGroups(mappings.stream().map(TransactionMapping::getRawTransactionValueGroupId).toList())
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.groupingBy(RawTransactionValue::getRowIndex, HashMap::new, Collectors.toCollection(ArrayList::new)))
|
||||||
|
.values()
|
||||||
|
.stream()
|
||||||
|
.map(rawTransactionValues -> {
|
||||||
|
final var transaction = mapValuesToTransaction(rawTransactionValues, mappings);
|
||||||
|
|
||||||
|
transaction.setStatementId(statementId);
|
||||||
|
transaction.setUserId(userId);
|
||||||
|
|
||||||
|
return transaction;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
processedTransactionRepository.saveAllAndFlush(processedTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProcessedTransaction mapValuesToTransaction(List<RawTransactionValue> values, final Collection<TransactionMapping> mappings) {
|
||||||
|
final var processedTransaction = new ProcessedTransaction();
|
||||||
|
|
||||||
|
values.forEach(value -> {
|
||||||
|
final var mapping = mappings.stream()
|
||||||
|
.filter(m -> Objects.equals(m.getRawTransactionValueGroupId(), value.getGroupId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new CommonFinancesException("Unable to map values to transaction: no mapping found for group"));
|
||||||
|
|
||||||
|
final var conversionType = mapping.getConversionType();
|
||||||
|
|
||||||
|
// Not converting
|
||||||
|
if (conversionType == null) {
|
||||||
|
|
||||||
|
// Map the field from the uploaded statement to the final processed transaction
|
||||||
|
// 1. Fetch the class field using the mapping FIELD_NAMES
|
||||||
|
// 2. Determine the type of field from the enum ( avoid using more reflection than necessary )
|
||||||
|
// 3. Set the new value of the field
|
||||||
|
// This should work fine for new fields as well, so long as the ProcessedTransactionField enum and FIELD_NAMES is maintained.
|
||||||
|
// If only Java had a better way of doing this.
|
||||||
|
|
||||||
|
try {
|
||||||
|
final var classField = ProcessedTransaction.class.getDeclaredField(ProcessedTransaction.FIELD_NAMES.get(mapping.getProcessedTransactionField()));
|
||||||
|
|
||||||
|
classField.setAccessible(true);
|
||||||
|
|
||||||
|
switch (mapping.getProcessedTransactionField().type()) {
|
||||||
|
case STRING -> classField.set(processedTransaction, value.getStringValue());
|
||||||
|
case NUMERIC -> classField.set(processedTransaction, value.getNumericValue());
|
||||||
|
case TIMESTAMP -> classField.set(processedTransaction, value.getTimestampValue());
|
||||||
|
case BOOLEAN -> classField.set(processedTransaction, value.getBooleanValue());
|
||||||
|
}
|
||||||
|
} catch (NoSuchFieldException | IllegalAccessException e) {
|
||||||
|
logger.error("Error while mapping statement.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If converting, first pass the value through conversion, then set it to the field as described above
|
||||||
|
|
||||||
|
// TODO: Expand on this. Or maybe better yet, don't - figure out a better way.
|
||||||
|
switch (conversionType) {
|
||||||
|
case STRING_TO_BOOLEAN -> {
|
||||||
|
if (mapping.getProcessedTransactionField().type() != RawTransactionValueType.BOOLEAN) {
|
||||||
|
logger.error("Invalid conversion type: is {}, but expected {}", conversionType.getTo(), mapping.getProcessedTransactionField().type());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean result = convertStringToBoolean(value.getStringValue(), mapping.getTrueBranchStringValue(), mapping.getFalseBranchStringValue());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final var classField = ProcessedTransaction.class.getDeclaredField(ProcessedTransaction.FIELD_NAMES.get(mapping.getProcessedTransactionField()));
|
||||||
|
|
||||||
|
classField.setAccessible(true);
|
||||||
|
|
||||||
|
classField.set(processedTransaction, result);
|
||||||
|
} catch (NoSuchFieldException | IllegalAccessException e) {
|
||||||
|
logger.error("Error while mapping statement.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return processedTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean convertStringToBoolean(String stringValue, String trueBranchStringValue, String falseBranchStringValue) {
|
||||||
|
return stringValue.equals(trueBranchStringValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
package dev.mvvasilev.finances.services;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class WorkbookParsingService {
|
|
||||||
}
|
|
|
@ -3,6 +3,6 @@ CREATE TABLE IF NOT EXISTS categories.processed_transaction_category (
|
||||||
processed_transaction_id BIGINT,
|
processed_transaction_id BIGINT,
|
||||||
category_id BIGINT,
|
category_id BIGINT,
|
||||||
CONSTRAINT PK_processed_transaction_category PRIMARY KEY (id),
|
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_category FOREIGN KEY (category_id) REFERENCES categories.transaction_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
|
CONSTRAINT FK_processed_transaction_category_processed_transaction FOREIGN KEY (processed_transaction_id) REFERENCES transactions.processed_transaction(id) ON DELETE CASCADE
|
||||||
);
|
);
|
|
@ -1,9 +1,11 @@
|
||||||
CREATE TABLE IF NOT EXISTS trasactions.transaction_mapping (
|
CREATE TABLE IF NOT EXISTS transactions.transaction_mapping (
|
||||||
id BIGSERIAL,
|
id BIGSERIAL,
|
||||||
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
raw_transaction_value_group_id BIGINT,
|
raw_transaction_value_group_id BIGINT,
|
||||||
processed_transaction_field VARCHAR(255),
|
processed_transaction_field VARCHAR(255),
|
||||||
|
statement_id BIGINT,
|
||||||
CONSTRAINT PK_transaction_mapping PRIMARY KEY (id),
|
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
|
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,
|
||||||
|
CONSTRAINT FK_transaction_mapping_statement FOREIGN KEY(statement_id) REFERENCES transactions.raw_statement(id) ON DELETE CASCADE
|
||||||
);
|
);
|
|
@ -6,9 +6,9 @@ CREATE TABLE IF NOT EXISTS categories.categorization (
|
||||||
rule_based_on VARCHAR(255),
|
rule_based_on VARCHAR(255),
|
||||||
categorization_rule VARCHAR(255),
|
categorization_rule VARCHAR(255),
|
||||||
string_value VARCHAR(1024),
|
string_value VARCHAR(1024),
|
||||||
numeric_greater_than INTEGER,
|
numeric_greater_than FLOAT,
|
||||||
numeric_less_than INTEGER,
|
numeric_less_than FLOAT,
|
||||||
numeric_value INTEGER,
|
numeric_value FLOAT,
|
||||||
timestamp_greater_than TIMESTAMP,
|
timestamp_greater_than TIMESTAMP,
|
||||||
timestamp_less_than TIMESTAMP,
|
timestamp_less_than TIMESTAMP,
|
||||||
boolean_value BOOLEAN,
|
boolean_value BOOLEAN,
|
||||||
|
@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS categories.categorization (
|
||||||
left_categorization_id BIGINT,
|
left_categorization_id BIGINT,
|
||||||
right_categorization_id BIGINT,
|
right_categorization_id BIGINT,
|
||||||
CONSTRAINT PK_categorization PRIMARY KEY (id),
|
CONSTRAINT PK_categorization PRIMARY KEY (id),
|
||||||
CONSTRAINT FK_categorization_category FOREIGN KEY (category_id) REFERENCES categories.category(id),
|
CONSTRAINT FK_categorization_category FOREIGN KEY (category_id) REFERENCES categories.transaction_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_left FOREIGN KEY (left_categorization_id) REFERENCES categories.categorization(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT FK_categorization_categorization_right FOREIGN KEY (right_categorization_id) REFERENCES categories.categorization(right_categorization_id) ON DELETE CASCADE
|
CONSTRAINT FK_categorization_categorization_right FOREIGN KEY (right_categorization_id) REFERENCES categories.categorization(id) ON DELETE CASCADE
|
||||||
);
|
);
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE transactions.transaction_mapping
|
||||||
|
ADD COLUMN IF NOT EXISTS conversion_type VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS true_branch_string_value VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS false_branch_string_value VARCHAR(255);
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE transactions.transaction_mapping
|
||||||
|
DROP COLUMN IF EXISTS statement_id;
|
|
@ -1,4 +1,5 @@
|
||||||
CREATE TABLE IF NOT EXISTS transactions.processed_transaction (
|
CREATE TABLE IF NOT EXISTS transactions.processed_transaction
|
||||||
|
(
|
||||||
id BIGSERIAL,
|
id BIGSERIAL,
|
||||||
description VARCHAR(1024),
|
description VARCHAR(1024),
|
||||||
amount FLOAT,
|
amount FLOAT,
|
||||||
|
@ -7,5 +8,7 @@ CREATE TABLE IF NOT EXISTS transactions.processed_transaction (
|
||||||
timestamp TIMESTAMP,
|
timestamp TIMESTAMP,
|
||||||
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
CONSTRAINT PK_processed_transaction PRIMARY KEY (id)
|
statement_id BIGINT,
|
||||||
|
CONSTRAINT PK_processed_transaction PRIMARY KEY (id),
|
||||||
|
CONSTRAINT FK_processed_transaction_statement FOREIGN KEY (statement_id) REFERENCES transactions.raw_statement(id)
|
||||||
);
|
);
|
|
@ -1,4 +1,4 @@
|
||||||
# Personal Finances
|
# Personal Finances
|
||||||
|
|
||||||
## Network Architecture / Topology
|
## Network Architecture / Topology
|
||||||

|

|
Before Width: | Height: | Size: 817 KiB After Width: | Height: | Size: 817 KiB |
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
|
@ -13,6 +13,7 @@
|
||||||
"@fontsource/roboto": "^5.0.8",
|
"@fontsource/roboto": "^5.0.8",
|
||||||
"@mui/icons-material": "^5.15.0",
|
"@mui/icons-material": "^5.15.0",
|
||||||
"@mui/material": "^5.15.0",
|
"@mui/material": "^5.15.0",
|
||||||
|
"@mui/x-data-grid": "^6.18.6",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
@ -1346,6 +1347,31 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
||||||
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
|
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-data-grid": {
|
||||||
|
"version": "6.18.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.18.6.tgz",
|
||||||
|
"integrity": "sha512-BRI2ilDrWq5pSBH1Rx/har57CIQPF0CebvPZNRVE4EV9RqTt3pwNyWaRQDjmHN9kij0YUlbZe0v9UrRT9I8KLw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2",
|
||||||
|
"@mui/utils": "^5.14.16",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"reselect": "^4.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mui/material": "^5.4.1",
|
||||||
|
"@mui/system": "^5.4.1",
|
||||||
|
"react": "^17.0.0 || ^18.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
@ -4115,6 +4141,11 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "4.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
|
||||||
|
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.5",
|
"version": "2.0.0-next.5",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
"@fontsource/roboto": "^5.0.8",
|
"@fontsource/roboto": "^5.0.8",
|
||||||
"@mui/icons-material": "^5.15.0",
|
"@mui/icons-material": "^5.15.0",
|
||||||
"@mui/material": "^5.15.0",
|
"@mui/material": "^5.15.0",
|
||||||
|
"@mui/x-data-grid": "^6.18.6",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom';
|
||||||
import HomePage from "@/app/pages/HomePage"
|
import HomePage from "@/app/pages/HomePage"
|
||||||
import RootLayout from '@/app/Layout';
|
import RootLayout from '@/app/Layout';
|
||||||
import StatementsPage from './app/pages/StatementsPage.jsx';
|
import StatementsPage from './app/pages/StatementsPage.jsx';
|
||||||
|
import TransactionsPage from "@/app/pages/TransactionsPage.jsx";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -10,6 +11,7 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/statements" element={<StatementsPage />} />
|
<Route path="/statements" element={<StatementsPage />} />
|
||||||
|
<Route path="/transactions" element={<TransactionsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RootLayout>
|
</RootLayout>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -11,19 +11,21 @@ import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import {Home as HomeIcon} from '@mui/icons-material';
|
import {Home as HomeIcon} from '@mui/icons-material';
|
||||||
|
import {ReceiptLong as StatementsIcon} from '@mui/icons-material';
|
||||||
import {Receipt as TransactionsIcon} from '@mui/icons-material';
|
import {Receipt as TransactionsIcon} from '@mui/icons-material';
|
||||||
|
import {Category as CategoryIcon} from '@mui/icons-material';
|
||||||
import {Logout as LogoutIcon} from '@mui/icons-material';
|
import {Logout as LogoutIcon} from '@mui/icons-material';
|
||||||
import {Login as LoginIcon} from "@mui/icons-material";
|
import {Login as LoginIcon} from "@mui/icons-material";
|
||||||
import {Toaster} from 'react-hot-toast';
|
import {Toaster} from 'react-hot-toast';
|
||||||
import theme from '../components/ThemeRegistry/theme';
|
import theme from '../components/ThemeRegistry/theme';
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import Grid from "@mui/material/Unstable_Grid2";
|
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240;
|
const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{text: 'Home', to: '/', icon: HomeIcon},
|
{text: 'Home', to: '/', icon: HomeIcon},
|
||||||
{text: 'Statements', to: '/statements', icon: TransactionsIcon},
|
{text: 'Statements', to: '/statements', icon: StatementsIcon},
|
||||||
|
{text: 'Categories', to: '/categories', icon: CategoryIcon},
|
||||||
|
{text: 'Transactions', to: '/transactions', icon: TransactionsIcon},
|
||||||
];
|
];
|
||||||
|
|
||||||
const BOTTOM_LINKS = [
|
const BOTTOM_LINKS = [
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { CloudUpload as CloudUploadIcon } from "@mui/icons-material";
|
import { CloudUpload as CloudUploadIcon } from "@mui/icons-material";
|
||||||
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
|
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
@ -7,10 +6,8 @@ import utils from "@/utils.js";
|
||||||
import Grid from "@mui/material/Unstable_Grid2";
|
import Grid from "@mui/material/Unstable_Grid2";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {Stack} from "@mui/material";
|
import {Stack} from "@mui/material";
|
||||||
import VisNetwork from "@/components/statements/VisNetwork.jsx";
|
|
||||||
import StatementCard from "@/components/statements/StatementCard.jsx";
|
import StatementCard from "@/components/statements/StatementCard.jsx";
|
||||||
import Carousel from "react-material-ui-carousel";
|
import Carousel from "react-material-ui-carousel";
|
||||||
import NodeModal from "@/components/statements/NodeModal.jsx";
|
|
||||||
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
|
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,12 +15,10 @@ export default function StatementsPage() {
|
||||||
|
|
||||||
const [statements, setStatements] = useState([]);
|
const [statements, setStatements] = useState([]);
|
||||||
|
|
||||||
const [valueGroups, setValueGroups] = useState([]);
|
const [mappingStatementId, setMappingStatementId] = useState(-1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
utils.performRequest("/api/statements")
|
fetchStatements();
|
||||||
.then(resp => resp.json())
|
|
||||||
.then(({ result }) => setStatements(result));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function fetchStatements() {
|
function fetchStatements() {
|
||||||
|
@ -55,17 +50,8 @@ export default function StatementsPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapStatement(e, statementId) {
|
function mapStatement(e, statementId) {
|
||||||
await toast.promise(
|
setMappingStatementId(statementId);
|
||||||
utils.performRequest(`/api/statements/${statementId}/transactionValueGroups`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(json => setValueGroups(json.result)),
|
|
||||||
{
|
|
||||||
loading: "Preparing...",
|
|
||||||
success: "Ready",
|
|
||||||
error: (err) => `Uh oh, something went wrong: ${err}`
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteStatement(e, statementId) {
|
async function deleteStatement(e, statementId) {
|
||||||
|
@ -84,7 +70,7 @@ export default function StatementsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCarouselItems() {
|
function createCarouselItems() {
|
||||||
let carouselItemCount = Math.ceil(statements.length / 4) || 1;
|
let carouselItemCount = Math.ceil(statements.length / 4) ?? 1;
|
||||||
|
|
||||||
let carouselItems = [];
|
let carouselItems = [];
|
||||||
|
|
||||||
|
@ -97,7 +83,13 @@ export default function StatementsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
carouselItems.push(
|
carouselItems.push(
|
||||||
<Grid key={i} container spacing={2}>
|
<Grid
|
||||||
|
key={i}
|
||||||
|
container
|
||||||
|
spacing={2}
|
||||||
|
pl={7}
|
||||||
|
pr={7}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
statements.slice(firstIndex, lastIndex).map((statement) => (
|
statements.slice(firstIndex, lastIndex).map((statement) => (
|
||||||
<Grid key={statement.id} xs={3}>
|
<Grid key={statement.id} xs={3}>
|
||||||
|
@ -118,8 +110,6 @@ export default function StatementsPage() {
|
||||||
return carouselItems;
|
return carouselItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
var i = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<div>
|
<div>
|
||||||
|
@ -134,6 +124,7 @@ export default function StatementsPage() {
|
||||||
<Grid xs={9}></Grid>
|
<Grid xs={9}></Grid>
|
||||||
|
|
||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
|
{(statements && statements.length > 0) &&
|
||||||
<Carousel
|
<Carousel
|
||||||
cycleNavigation
|
cycleNavigation
|
||||||
fullHeightHover
|
fullHeightHover
|
||||||
|
@ -144,10 +135,14 @@ export default function StatementsPage() {
|
||||||
>
|
>
|
||||||
{createCarouselItems()}
|
{createCarouselItems()}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
<StatementMappingEditor valueGroups={valueGroups} />
|
{
|
||||||
|
mappingStatementId !== -1 &&
|
||||||
|
<StatementMappingEditor statementId={mappingStatementId}/>
|
||||||
|
}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
|
|
93
frontend/src/app/pages/TransactionsPage.jsx
Normal file
93
frontend/src/app/pages/TransactionsPage.jsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import Grid from "@mui/material/Unstable_Grid2";
|
||||||
|
import {Stack} from "@mui/material";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {DataGrid} from "@mui/x-data-grid";
|
||||||
|
import utils from "@/utils.js";
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{
|
||||||
|
field: "isInflow",
|
||||||
|
headerName: "Inflow/Outflow",
|
||||||
|
type: "boolean",
|
||||||
|
width: 150,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "amount",
|
||||||
|
headerName: "Amount",
|
||||||
|
type: "currency",
|
||||||
|
maxWidth: 150,
|
||||||
|
flex: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
valueFormatter: val => `${val.value} лв.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "description",
|
||||||
|
headerName: "Description",
|
||||||
|
type: "string",
|
||||||
|
minWidth: 150,
|
||||||
|
flex: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "timestamp",
|
||||||
|
headerName: "Timestamp",
|
||||||
|
type: "datetime",
|
||||||
|
maxWidth: 250,
|
||||||
|
flex: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
valueFormatter: val => new Date(val.value).toLocaleString('en-UK')
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TransactionsPage() {
|
||||||
|
|
||||||
|
const [pageOptions, setPageOptions] = useState({
|
||||||
|
page: 0,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sortOptions, setSortOptions] = useState([
|
||||||
|
{
|
||||||
|
field: "timestamp",
|
||||||
|
sort: "asc"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [transactions, setTransactions] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Multi-sorting requires the MUI data grid pro license :)
|
||||||
|
let sortBy = sortOptions.map((sort) => `&sort=${sort.field},${sort.sort}`).join("")
|
||||||
|
|
||||||
|
console.log(sortBy)
|
||||||
|
|
||||||
|
utils.performRequest(`/api/processed-transactions?page=${pageOptions.page}&size=${pageOptions.pageSize}${sortBy}`)
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(({result}) => setTransactions(result));
|
||||||
|
}, [pageOptions, sortOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Grid container columnSpacing={1}>
|
||||||
|
<Grid xs={12} lg={12}>
|
||||||
|
<DataGrid
|
||||||
|
columns={COLUMNS}
|
||||||
|
rows={transactions.content ?? []}
|
||||||
|
rowCount={transactions.totalElements ?? 0}
|
||||||
|
paginationMode={"server"}
|
||||||
|
sortingMode={"server"}
|
||||||
|
paginationModel={pageOptions}
|
||||||
|
onPaginationModelChange={(model, details) => setPageOptions(model)}
|
||||||
|
sortModel={sortOptions}
|
||||||
|
onSortModelChange={(model, change) => setSortOptions(model)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,42 +1,348 @@
|
||||||
import VisNetwork from "@/components/statements/VisNetwork.jsx";
|
|
||||||
import Grid from "@mui/material/Unstable_Grid2";
|
import Grid from "@mui/material/Unstable_Grid2";
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import {FormHelperText, Input, MenuItem, Select} from "@mui/material";
|
||||||
|
import utils from "@/utils.js";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import NodeModal from "@/components/statements/NodeModal.jsx";
|
import Box from "@mui/material/Box";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export default function StatementMappingEditor({ shown: shown = false, valueGroups: valueGroups = [] }) {
|
const FIELD_TYPES = [
|
||||||
|
"STRING",
|
||||||
|
"NUMERIC",
|
||||||
|
"TIMESTAMP",
|
||||||
|
"BOOLEAN"
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONVERSION_TYPES = {
|
||||||
|
STRING_TO_BOOLEAN: "STRING_TO_BOOLEAN"
|
||||||
|
}
|
||||||
|
|
||||||
var i = 0;
|
const UNMAPPED_FIELD_NAME = "UNMAPPED";
|
||||||
|
|
||||||
|
const UNMAPPED_FIELDS = FIELD_TYPES.map(type => {
|
||||||
|
return {
|
||||||
|
field: UNMAPPED_FIELD_NAME,
|
||||||
|
type
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const NONE_CONVERSION_TYPE = "NONE";
|
||||||
|
|
||||||
|
const NONE_CONVERSIONS = FIELD_TYPES.flatMap(fromType => {
|
||||||
|
return FIELD_TYPES.map(toType => {
|
||||||
|
return {
|
||||||
|
type: NONE_CONVERSION_TYPE,
|
||||||
|
from: fromType,
|
||||||
|
to: toType
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function StatementMappingEditor({statementId}) {
|
||||||
|
const [mappings, setMappings] = useState([]);
|
||||||
|
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
|
||||||
|
const [supportedConversions, setSupportedConversions] = useState([]);
|
||||||
|
|
||||||
|
const [valueGroups, setValueGroups] = useState([]);
|
||||||
|
|
||||||
|
const [existingMappings, setExistingMappings] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let supportedConversionsPromise = utils.performRequest("/api/statements/supported-conversions")
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(({result}) => setSupportedConversions(result));
|
||||||
|
|
||||||
|
let valueGroupsPromise = utils.performRequest(`/api/statements/${statementId}/transactionValueGroups`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => setValueGroups(json.result));
|
||||||
|
|
||||||
|
let existingMappingsPromise = utils.performRequest(`/api/statements/${statementId}/mappings`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => setExistingMappings(json.result));
|
||||||
|
|
||||||
|
let fieldsPromise = utils.performRequest("/api/processed-transactions/fields")
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(({result}) => setFields(result));
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise]),
|
||||||
|
{
|
||||||
|
loading: "Preparing...",
|
||||||
|
success: "Ready",
|
||||||
|
error: (err) => `Uh oh, something went wrong: ${err}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [statementId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMappings(valueGroups.map(g => {
|
||||||
|
let existingMapping = existingMappings.find(m => m.rawTransactionValueGroupId === g.id);
|
||||||
|
|
||||||
|
let unmappedField = UNMAPPED_FIELDS.find(f => f.type === g.type);
|
||||||
|
let noneConversion = NONE_CONVERSIONS.find(c => c.from === g.type && c.to === g.type );
|
||||||
|
|
||||||
|
return {
|
||||||
|
valueGroup: {
|
||||||
|
id: g.id,
|
||||||
|
type: g.type,
|
||||||
|
name: g.name,
|
||||||
|
unmappedField,
|
||||||
|
noneConversion
|
||||||
|
},
|
||||||
|
conversion: {
|
||||||
|
options: supportedConversions
|
||||||
|
.concat(noneConversion)
|
||||||
|
.filter(c => c.from === g.type)
|
||||||
|
.map(c => {
|
||||||
|
return {
|
||||||
|
from: c.from,
|
||||||
|
to: c.to,
|
||||||
|
type: c.type
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
selected: {
|
||||||
|
from: existingMapping?.conversionType?.from ?? noneConversion.from,
|
||||||
|
to: existingMapping?.conversionType?.to ?? noneConversion.to,
|
||||||
|
type: existingMapping?.conversionType?.type ?? noneConversion.type,
|
||||||
|
trueBranchStringValue: existingMapping?.trueBranchStringValue ?? ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
options: fields.concat(UNMAPPED_FIELDS)
|
||||||
|
.filter(f => f.type === (existingMapping?.conversionType?.to ?? noneConversion.to))
|
||||||
|
.map(f => {
|
||||||
|
return {
|
||||||
|
field: f.field,
|
||||||
|
type: f.type
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
selected: {
|
||||||
|
field: existingMapping?.processedTransactionField?.field ?? unmappedField.field,
|
||||||
|
type: existingMapping?.processedTransactionField?.type ?? unmappedField.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}, [supportedConversions, existingMappings, fields, valueGroups]);
|
||||||
|
|
||||||
|
async function onSave(e) {
|
||||||
|
let createMappingsDto = mappings
|
||||||
|
.filter(m => m.field.selected.field !== UNMAPPED_FIELD_NAME)
|
||||||
|
.map(m => {
|
||||||
|
return {
|
||||||
|
rawTransactionValueGroupId: m.valueGroup.id,
|
||||||
|
field: m.field.selected.field,
|
||||||
|
conversionType: m.conversion.selected.type === NONE_CONVERSION_TYPE ? undefined : m.conversion.selected.type,
|
||||||
|
trueBranchStringValue: m.conversion.selected.trueBranchStringValue
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
utils.performRequest(`/api/statements/${statementId}/mappings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(createMappingsDto)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Saving mappings...",
|
||||||
|
success: "Saved",
|
||||||
|
error: (err) => `Uh oh, something went wrong: ${err}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
utils.performRequest(`/api/statements/${statementId}/process`, {
|
||||||
|
method: "POST"
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Creating transactions...",
|
||||||
|
success: "Done!",
|
||||||
|
error: (err) => `Uh oh, something went wrong: ${err}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConversionChange(e, mapping) {
|
||||||
|
let newConversion = supportedConversions.find(c => c.type === e.target.value) ?? mapping.valueGroup.noneConversion;
|
||||||
|
|
||||||
|
mapping.conversion.selected = newConversion;
|
||||||
|
|
||||||
|
mapping.field.options = fields.concat(UNMAPPED_FIELDS)
|
||||||
|
.filter(f => f.type === newConversion.to)
|
||||||
|
.map(f => {
|
||||||
|
return {
|
||||||
|
field: f.field,
|
||||||
|
type: f.type
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mapping.field.selected.type !== newConversion.to) {
|
||||||
|
mapping.field.selected = mapping.valueGroup.unmappedField;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMappings(mappings.map(m => {
|
||||||
|
if (m.valueGroup.id === mapping.valueGroup.id) {
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFieldChange(e, mapping) {
|
||||||
|
setMappings(mappings.map(m => {
|
||||||
|
if (m.valueGroup.id === mapping.valueGroup.id) {
|
||||||
|
mapping.field.selected = fields.find(c => c.field === e.target.value) ?? mapping.valueGroup.unmappedField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConversion(mapping, type) {
|
||||||
|
switch (type) {
|
||||||
|
case CONVERSION_TYPES.STRING_TO_BOOLEAN:
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Input
|
||||||
|
onChange={(e) => onChangeStringToBooleanTrueValue(e, mapping)}
|
||||||
|
value={mapping.conversion.selected.trueBranchStringValue}
|
||||||
|
/> = true, else false
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
default: return (<p>Unsupported</p>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeStringToBooleanTrueValue(e, mapping) {
|
||||||
|
setMappings(mappings.map(m => {
|
||||||
|
if (m.valueGroup.id === mapping.valueGroup.id) {
|
||||||
|
m.conversion.selected.trueBranchStringValue = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid justifyContent={"space-between"} container columnSpacing={3}>
|
<Grid container columnSpacing={3}>
|
||||||
<Grid xs={9}>
|
<Grid container xs={12} lg={12}>
|
||||||
<VisNetwork
|
<Grid xs={1} lg={1}>
|
||||||
nodes={valueGroups.map(group => {
|
<Button sx={{width: "100%"}} variant="contained" onClick={onSave}>
|
||||||
return {
|
Save & Apply
|
||||||
id: group.id,
|
</Button>
|
||||||
label: group.name,
|
</Grid>
|
||||||
x: 0, y: (i++) * 10
|
{/* TODO */}
|
||||||
|
{/*<Grid xs={1} lg={1}>*/}
|
||||||
|
{/* <Button sx={{width: "100%"}} variant="contained">*/}
|
||||||
|
{/* Export*/}
|
||||||
|
{/* </Button>*/}
|
||||||
|
{/*</Grid>*/}
|
||||||
|
{/*<Grid xs={1} lg={1}>*/}
|
||||||
|
{/* <Button sx={{width: "100%"}} variant="contained">*/}
|
||||||
|
{/* Import*/}
|
||||||
|
{/* </Button>*/}
|
||||||
|
{/*</Grid>*/}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12} lg={12}>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell style={{width: "300px"}} align="left" >Statement Column</TableCell>
|
||||||
|
<TableCell align="left">Conversion</TableCell>
|
||||||
|
<TableCell style={{width: "300px"}} align="left">Transaction Field</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{
|
||||||
|
mappings.map(m => (
|
||||||
|
<TableRow key={m.valueGroup.id}>
|
||||||
|
<TableCell>{m.valueGroup.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Grid columnSpacing={1} container>
|
||||||
|
<Grid xs={2} lg={2}>
|
||||||
|
<Select
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
color: () => {
|
||||||
|
if (m.conversion.selected.type === NONE_CONVERSION_TYPE) {
|
||||||
|
return 'gray'
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})}
|
|
||||||
backgroundColor="#222222"
|
|
||||||
options={{
|
|
||||||
height: "1000px"
|
|
||||||
}}
|
}}
|
||||||
/>
|
defaultValue={m.valueGroup.noneConversion.type}
|
||||||
|
value={m.conversion.selected.type}
|
||||||
|
onChange={(e) => onConversionChange(e, m)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
m.conversion.options.map(c => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${m.valueGroup.name}-${c.type}`}
|
||||||
|
value={c.type}
|
||||||
|
>
|
||||||
|
{ utils.toPascalCase(c.type.replace(/_/g, " ")) }
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Select>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid xs={10} lg={10}>
|
||||||
<Grid xs={3} container columnSpacing={1}>
|
{
|
||||||
<Grid xs={6}>
|
m.conversion.selected.type !== NONE_CONVERSION_TYPE &&
|
||||||
<Button>Add Node</Button>
|
renderConversion(m, m.conversion.selected.type)
|
||||||
</Grid>
|
}
|
||||||
<Grid xs={6}>
|
|
||||||
<Button>Add Connection</Button>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</TableCell>
|
||||||
{/*<NodeModal open={true}></NodeModal>*/}
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
color: () => {
|
||||||
|
if (m.field.selected.field === UNMAPPED_FIELD_NAME) {
|
||||||
|
return 'gray'
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
defaultValue={m.valueGroup.unmappedField.field}
|
||||||
|
value={m.field.selected.field}
|
||||||
|
onChange={(e) => onFieldChange(e, m)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
m.field.options.map(f => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${m.valueGroup.name}-${f.field}`}
|
||||||
|
value={f.field}
|
||||||
|
>
|
||||||
|
{ utils.toPascalCase(f.field.replace(/_/g, " ")) }
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -13,6 +13,9 @@ let utils = {
|
||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
toPascalCase: (s) => {
|
||||||
|
return s.replace(/(\w)(\w*)/g, (g0,g1,g2) => g1.toUpperCase() + g2.toLowerCase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue