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">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="openjdk-21" />
|
||||
<option name="gradleJvm" value="21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
|
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
|
@ -4,7 +4,7 @@
|
|||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</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" />
|
||||
</component>
|
||||
</project>
|
|
@ -6,7 +6,9 @@ import dev.mvvasilev.common.web.CrudResponseDTO;
|
|||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public abstract class AbstractRestController {
|
||||
|
||||
|
@ -30,6 +32,15 @@ public abstract class AbstractRestController {
|
|||
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) {
|
||||
return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows));
|
||||
}
|
||||
|
|
|
@ -18,6 +18,10 @@ public abstract class AbstractEnumConverter<T extends Enum<T> & PersistableEnum<
|
|||
|
||||
@Override
|
||||
public T convertToEntityAttribute(E dbData) {
|
||||
if (dbData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
T[] enums = clazz.getEnumConstants();
|
||||
|
||||
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;
|
||||
|
||||
public class InvalidUserIdException extends CommonFinancesException {
|
||||
public InvalidUserIdException() {
|
||||
super("UserId is invalid");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,21 +60,21 @@ public class CategoriesController extends AbstractRestController {
|
|||
|
||||
@PostMapping("/{categoryId}/rules")
|
||||
@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,
|
||||
@RequestBody Collection<CreateCategorizationDTO> dto
|
||||
) {
|
||||
return created(categoryService.createCategorizationRule(categoryId, dto));
|
||||
return created(categoryService.createCategorizationRules(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));
|
||||
}
|
||||
// @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) {
|
||||
|
|
|
@ -2,18 +2,55 @@ package dev.mvvasilev.finances.controllers;
|
|||
|
||||
import dev.mvvasilev.common.controller.AbstractRestController;
|
||||
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.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.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/processed-transactions")
|
||||
public class ProcessedTransactionsController extends AbstractRestController {
|
||||
|
||||
final private ProcessedTransactionService processedTransactionService;
|
||||
|
||||
@Autowired
|
||||
public ProcessedTransactionsController(ProcessedTransactionService processedTransactionService) {
|
||||
this.processedTransactionService = processedTransactionService;
|
||||
}
|
||||
|
||||
@GetMapping("/fields")
|
||||
public ResponseEntity<APIResponseDTO<ProcessedTransactionField[]>> fetchFields() {
|
||||
return ok(ProcessedTransactionField.values());
|
||||
public ResponseEntity<APIResponseDTO<Collection<ProcessedTransactionFieldDTO>>> fetchFields() {
|
||||
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.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.UploadedStatementDTO;
|
||||
import dev.mvvasilev.finances.dtos.*;
|
||||
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||
import dev.mvvasilev.finances.services.StatementsService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
@ -17,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
|
|||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
|
||||
@RestController
|
||||
|
@ -51,6 +50,15 @@ public class StatementsController extends AbstractRestController {
|
|||
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")
|
||||
@PreAuthorize("@authService.isOwner(#statementId, T(dev.mvvasilev.finances.entity.RawStatement))")
|
||||
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createTransactionMappings(
|
||||
|
@ -63,8 +71,8 @@ public class StatementsController extends AbstractRestController {
|
|||
|
||||
@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);
|
||||
public ResponseEntity<APIResponseDTO<Object>> processTransactions(@PathVariable("statementId") Long statementId, Authentication authentication) {
|
||||
statementsService.processStatement(statementId, Integer.parseInt(authentication.getName()));
|
||||
return emptySuccess();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
package dev.mvvasilev.finances.dtos;
|
||||
|
||||
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
public record CreateTransactionMappingDTO(
|
||||
@NotNull
|
||||
Long rawTransactionValueGroupId,
|
||||
@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;
|
||||
|
||||
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
|
||||
public record TransactionMappingDTO(
|
||||
Long id,
|
||||
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.UserOwned;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(schema = "transactions")
|
||||
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 Integer userId;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package dev.mvvasilev.finances.entity;
|
||||
|
||||
import dev.mvvasilev.common.data.AbstractEntity;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(schema = "transactions")
|
||||
|
|
|
@ -2,6 +2,7 @@ package dev.mvvasilev.finances.entity;
|
|||
|
||||
import dev.mvvasilev.common.data.AbstractEntity;
|
||||
import dev.mvvasilev.common.data.UserOwned;
|
||||
import dev.mvvasilev.finances.enums.MappingConversionType;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
|
@ -16,6 +17,13 @@ public class TransactionMapping extends AbstractEntity {
|
|||
@Convert(converter = ProcessedTransactionField.JpaConverter.class)
|
||||
private ProcessedTransactionField processedTransactionField;
|
||||
|
||||
@Convert(converter = MappingConversionType.JpaConverter.class)
|
||||
private MappingConversionType conversionType;
|
||||
|
||||
private String trueBranchStringValue;
|
||||
|
||||
private String falseBranchStringValue;
|
||||
|
||||
public TransactionMapping() {
|
||||
}
|
||||
|
||||
|
@ -34,4 +42,28 @@ public class TransactionMapping extends AbstractEntity {
|
|||
public void setProcessedTransactionField(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.PersistableEnum;
|
||||
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
// TODO: Create custom converter for JPA
|
||||
public enum ProcessedTransactionField implements PersistableEnum<String> {
|
||||
DESCRIPTION(RawTransactionValueType.STRING),
|
||||
AMOUNT(RawTransactionValueType.NUMERIC),
|
||||
|
@ -16,7 +18,6 @@ public enum ProcessedTransactionField implements PersistableEnum<String> {
|
|||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
public String value() {
|
||||
return name();
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ 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.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
@ -27,11 +28,35 @@ public interface CategorizationRepository extends JpaRepository<Categorization,
|
|||
// TODO: Use Recursive CTE
|
||||
@Query(
|
||||
value = """
|
||||
WITH RECURSIVE cats AS (
|
||||
SELECT 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
|
||||
)
|
||||
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;
|
||||
|
||||
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.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
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)
|
||||
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 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 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.entity.TransactionCategory;
|
||||
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.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
@ -15,5 +16,6 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
|
|||
Collection<TransactionCategory> fetchTransactionCategoriesWithUserId(@Param("userId") int userId);
|
||||
|
||||
@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);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package dev.mvvasilev.finances.persistence;
|
|||
|
||||
import dev.mvvasilev.finances.entity.TransactionMapping;
|
||||
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.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
@ -14,13 +15,28 @@ public interface TransactionMappingRepository extends JpaRepository<TransactionM
|
|||
@Query(
|
||||
value = """
|
||||
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.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
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
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;
|
||||
|
||||
import dev.mvvasilev.common.data.AbstractEntity;
|
||||
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
||||
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||
import dev.mvvasilev.finances.dtos.*;
|
||||
import dev.mvvasilev.finances.entity.Categorization;
|
||||
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.ProcessedTransactionRepository;
|
||||
import dev.mvvasilev.finances.persistence.TransactionCategoryRepository;
|
||||
import org.apache.commons.compress.utils.Lists;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
|
@ -22,6 +26,7 @@ import java.util.concurrent.CompletableFuture;
|
|||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class CategoryService {
|
||||
|
||||
final private TransactionCategoryRepository transactionCategoryRepository;
|
||||
|
@ -175,6 +180,7 @@ public class CategoryService {
|
|||
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
||||
}
|
||||
|
||||
// TODO: Avoid recursion
|
||||
yield matchesRule(allCategorizations, left.get(), processedTransaction) && matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||
}
|
||||
case OR -> {
|
||||
|
@ -186,6 +192,7 @@ public class CategoryService {
|
|||
throw new CommonFinancesException("Invalid categorization: left does not exist");
|
||||
}
|
||||
|
||||
// TODO: Avoid recursion
|
||||
yield matchesRule(allCategorizations, left.get(), processedTransaction) || matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||
}
|
||||
case NOT -> {
|
||||
|
@ -193,6 +200,7 @@ public class CategoryService {
|
|||
throw new CommonFinancesException("Invalid categorization: right does not exist");
|
||||
}
|
||||
|
||||
// TODO: Avoid recursion
|
||||
yield !matchesRule(allCategorizations, right.get(), processedTransaction);
|
||||
}
|
||||
default -> throw new CommonFinancesException("Invalid logical operation: %s", categorization.getCategorizationRule());
|
||||
|
@ -239,38 +247,48 @@ public class CategoryService {
|
|||
}
|
||||
|
||||
public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) {
|
||||
return categorizationRepository.fetchForCategory(categoryId).stream()
|
||||
.map(entity -> {
|
||||
// TODO: Recursion
|
||||
})
|
||||
return Lists.newArrayList();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public Long createCategorizationRule(Long categoryId, Collection<CreateCategorizationDTO> dto) {
|
||||
// TODO: Clear previous rules for category and replace with new ones
|
||||
private Categorization saveCategorizationRule(Long categoryId, CreateCategorizationDTO dto) {
|
||||
// TODO: Avoid recursion
|
||||
|
||||
// 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();
|
||||
final var categorization = new Categorization();
|
||||
|
||||
categorization.setCategorizationRule(dto.rule());
|
||||
categorization.setCategoryId(null);
|
||||
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());
|
||||
|
||||
// Only root rules have category id set, to differentiate them from non-roots
|
||||
// TODO: This smells bad. Add an isRoot property instead?
|
||||
if (dto.left() != null) {
|
||||
final var leftCat = saveCategorizationRule(null, dto.left());
|
||||
categorization.setLeftCategorizationId(leftCat.getId());
|
||||
}
|
||||
|
||||
if (dto.right() != null) {
|
||||
final var rightCat = saveCategorizationRule(null, dto.right());
|
||||
categorization.setRightCategorizationId(rightCat.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;
|
||||
|
||||
import dev.mvvasilev.common.data.AbstractEntity;
|
||||
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
||||
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.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.dtos.*;
|
||||
import dev.mvvasilev.finances.entity.*;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
import dev.mvvasilev.finances.enums.RawTransactionValueType;
|
||||
import dev.mvvasilev.finances.persistence.RawStatementRepository;
|
||||
import dev.mvvasilev.finances.persistence.RawTransactionValueGroupRepository;
|
||||
import dev.mvvasilev.finances.persistence.RawTransactionValueRepository;
|
||||
import dev.mvvasilev.finances.persistence.TransactionMappingRepository;
|
||||
import dev.mvvasilev.finances.persistence.*;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
@ -31,9 +26,11 @@ import java.time.format.DateTimeParseException;
|
|||
import java.time.format.ResolverStyle;
|
||||
import java.time.temporal.ChronoField;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class StatementsService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = new DateTimeFormatterBuilder()
|
||||
|
@ -44,6 +41,8 @@ public class StatementsService {
|
|||
.toFormatter()
|
||||
.withResolverStyle(ResolverStyle.LENIENT);
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(StatementsService.class);
|
||||
|
||||
private final RawStatementRepository rawStatementRepository;
|
||||
|
||||
private final RawTransactionValueGroupRepository rawTransactionValueGroupRepository;
|
||||
|
@ -52,12 +51,15 @@ public class StatementsService {
|
|||
|
||||
private final TransactionMappingRepository transactionMappingRepository;
|
||||
|
||||
private final ProcessedTransactionRepository processedTransactionRepository;
|
||||
|
||||
@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.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository;
|
||||
this.rawTransactionValueRepository = rawTransactionValueRepository;
|
||||
this.transactionMappingRepository = transactionMappingRepository;
|
||||
this.processedTransactionRepository = processedTransactionRepository;
|
||||
}
|
||||
|
||||
|
||||
|
@ -203,13 +205,26 @@ public class StatementsService {
|
|||
.map(entity -> new TransactionMappingDTO(
|
||||
entity.getId(),
|
||||
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();
|
||||
}
|
||||
|
||||
public Collection<CrudResponseDTO> createTransactionMappingsForStatement(Long statementId, Collection<CreateTransactionMappingDTO> dtos) {
|
||||
transactionMappingRepository.deleteAllForStatement(statementId);
|
||||
|
||||
return transactionMappingRepository.saveAllAndFlush(
|
||||
dtos.stream()
|
||||
.map(dto -> {
|
||||
|
@ -218,6 +233,12 @@ public class StatementsService {
|
|||
mapping.setRawTransactionValueGroupId(dto.rawTransactionValueGroupId());
|
||||
mapping.setProcessedTransactionField(dto.field());
|
||||
|
||||
if (dto.conversionType() != null) {
|
||||
mapping.setConversionType(dto.conversionType());
|
||||
mapping.setTrueBranchStringValue(dto.trueBranchStringValue());
|
||||
mapping.setFalseBranchStringValue(dto.falseBranchStringValue());
|
||||
}
|
||||
|
||||
return mapping;
|
||||
})
|
||||
.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 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,
|
||||
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_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
|
||||
);
|
|
@ -1,9 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS trasactions.transaction_mapping (
|
||||
CREATE TABLE IF NOT EXISTS transactions.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),
|
||||
statement_id BIGINT,
|
||||
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),
|
||||
categorization_rule VARCHAR(255),
|
||||
string_value VARCHAR(1024),
|
||||
numeric_greater_than INTEGER,
|
||||
numeric_less_than INTEGER,
|
||||
numeric_value INTEGER,
|
||||
numeric_greater_than FLOAT,
|
||||
numeric_less_than FLOAT,
|
||||
numeric_value FLOAT,
|
||||
timestamp_greater_than TIMESTAMP,
|
||||
timestamp_less_than TIMESTAMP,
|
||||
boolean_value BOOLEAN,
|
||||
|
@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS categories.categorization (
|
|||
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
|
||||
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(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,
|
||||
description VARCHAR(1024),
|
||||
amount FLOAT,
|
||||
|
@ -7,5 +8,7 @@ CREATE TABLE IF NOT EXISTS transactions.processed_transaction (
|
|||
timestamp TIMESTAMP,
|
||||
time_created 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
|
||||
|
||||
## 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",
|
||||
"@mui/icons-material": "^5.15.0",
|
||||
"@mui/material": "^5.15.0",
|
||||
"@mui/x-data-grid": "^6.18.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"react": "^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",
|
||||
"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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
@ -4115,6 +4141,11 @@
|
|||
"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": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"@fontsource/roboto": "^5.0.8",
|
||||
"@mui/icons-material": "^5.15.0",
|
||||
"@mui/material": "^5.15.0",
|
||||
"@mui/x-data-grid": "^6.18.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"react": "^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 RootLayout from '@/app/Layout';
|
||||
import StatementsPage from './app/pages/StatementsPage.jsx';
|
||||
import TransactionsPage from "@/app/pages/TransactionsPage.jsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
@ -10,6 +11,7 @@ function App() {
|
|||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/statements" element={<StatementsPage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
</Routes>
|
||||
</RootLayout>
|
||||
</>
|
||||
|
|
|
@ -11,19 +11,21 @@ import ListItemButton from '@mui/material/ListItemButton';
|
|||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
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 {Category as CategoryIcon} from '@mui/icons-material';
|
||||
import {Logout as LogoutIcon} from '@mui/icons-material';
|
||||
import {Login as LoginIcon} from "@mui/icons-material";
|
||||
import {Toaster} from 'react-hot-toast';
|
||||
import theme from '../components/ThemeRegistry/theme';
|
||||
import Button from "@mui/material/Button";
|
||||
import Grid from "@mui/material/Unstable_Grid2";
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
const NAV_LINKS = [
|
||||
{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 = [
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
import { CloudUpload as CloudUploadIcon } from "@mui/icons-material";
|
||||
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
|
||||
import toast from "react-hot-toast";
|
||||
|
@ -7,10 +6,8 @@ import utils from "@/utils.js";
|
|||
import Grid from "@mui/material/Unstable_Grid2";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Stack} from "@mui/material";
|
||||
import VisNetwork from "@/components/statements/VisNetwork.jsx";
|
||||
import StatementCard from "@/components/statements/StatementCard.jsx";
|
||||
import Carousel from "react-material-ui-carousel";
|
||||
import NodeModal from "@/components/statements/NodeModal.jsx";
|
||||
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
|
||||
|
||||
|
||||
|
@ -18,12 +15,10 @@ export default function StatementsPage() {
|
|||
|
||||
const [statements, setStatements] = useState([]);
|
||||
|
||||
const [valueGroups, setValueGroups] = useState([]);
|
||||
const [mappingStatementId, setMappingStatementId] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
utils.performRequest("/api/statements")
|
||||
.then(resp => resp.json())
|
||||
.then(({ result }) => setStatements(result));
|
||||
fetchStatements();
|
||||
}, []);
|
||||
|
||||
function fetchStatements() {
|
||||
|
@ -55,17 +50,8 @@ export default function StatementsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
async function mapStatement(e, statementId) {
|
||||
await toast.promise(
|
||||
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}`
|
||||
}
|
||||
);
|
||||
function mapStatement(e, statementId) {
|
||||
setMappingStatementId(statementId);
|
||||
}
|
||||
|
||||
async function deleteStatement(e, statementId) {
|
||||
|
@ -84,7 +70,7 @@ export default function StatementsPage() {
|
|||
}
|
||||
|
||||
function createCarouselItems() {
|
||||
let carouselItemCount = Math.ceil(statements.length / 4) || 1;
|
||||
let carouselItemCount = Math.ceil(statements.length / 4) ?? 1;
|
||||
|
||||
let carouselItems = [];
|
||||
|
||||
|
@ -97,7 +83,13 @@ export default function StatementsPage() {
|
|||
}
|
||||
|
||||
carouselItems.push(
|
||||
<Grid key={i} container spacing={2}>
|
||||
<Grid
|
||||
key={i}
|
||||
container
|
||||
spacing={2}
|
||||
pl={7}
|
||||
pr={7}
|
||||
>
|
||||
{
|
||||
statements.slice(firstIndex, lastIndex).map((statement) => (
|
||||
<Grid key={statement.id} xs={3}>
|
||||
|
@ -118,8 +110,6 @@ export default function StatementsPage() {
|
|||
return carouselItems;
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
|
@ -134,6 +124,7 @@ export default function StatementsPage() {
|
|||
<Grid xs={9}></Grid>
|
||||
|
||||
<Grid xs={12}>
|
||||
{(statements && statements.length > 0) &&
|
||||
<Carousel
|
||||
cycleNavigation
|
||||
fullHeightHover
|
||||
|
@ -144,10 +135,14 @@ export default function StatementsPage() {
|
|||
>
|
||||
{createCarouselItems()}
|
||||
</Carousel>
|
||||
}
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12}>
|
||||
<StatementMappingEditor valueGroups={valueGroups} />
|
||||
{
|
||||
mappingStatementId !== -1 &&
|
||||
<StatementMappingEditor statementId={mappingStatementId}/>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</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 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 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 (
|
||||
<Grid justifyContent={"space-between"} container columnSpacing={3}>
|
||||
<Grid xs={9}>
|
||||
<VisNetwork
|
||||
nodes={valueGroups.map(group => {
|
||||
return {
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
x: 0, y: (i++) * 10
|
||||
<Grid container columnSpacing={3}>
|
||||
<Grid container xs={12} lg={12}>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button sx={{width: "100%"}} variant="contained" onClick={onSave}>
|
||||
Save & Apply
|
||||
</Button>
|
||||
</Grid>
|
||||
{/* 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 xs={3} container columnSpacing={1}>
|
||||
<Grid xs={6}>
|
||||
<Button>Add Node</Button>
|
||||
</Grid>
|
||||
<Grid xs={6}>
|
||||
<Button>Add Connection</Button>
|
||||
<Grid xs={10} lg={10}>
|
||||
{
|
||||
m.conversion.selected.type !== NONE_CONVERSION_TYPE &&
|
||||
renderConversion(m, m.conversion.selected.type)
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/*<NodeModal open={true}></NodeModal>*/}
|
||||
|
||||
</TableCell>
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -13,6 +13,9 @@ let utils = {
|
|||
|
||||
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