Statement parsing works, so does transaction visualization

This commit is contained in:
Miroslav Vasilev 2023-12-28 23:26:59 +02:00
parent ad0bce4eba
commit 03d5d23a03
44 changed files with 1043 additions and 167 deletions

2
.idea/gradle.xml generated
View file

@ -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
View file

@ -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>

View file

@ -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));
} }

View file

@ -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()));
} }
} }

View file

@ -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");
}
} }

View file

@ -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) {

View file

@ -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
));
} }
} }

View file

@ -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();
} }

View file

@ -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
) { ) {
} }

View file

@ -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
) {}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -0,0 +1,6 @@
package dev.mvvasilev.finances.dtos;
public record TransactionCategoryDTO(
Long id,
String name
) {}

View file

@ -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
) { ) {
} }

View file

@ -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;

View file

@ -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")

View file

@ -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;
}
} }

View file

@ -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);
}
}
}

View file

@ -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();
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }
} }

View file

@ -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.
));
}
}

View file

@ -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);
} }
} }

View file

@ -1,7 +0,0 @@
package dev.mvvasilev.finances.services;
import org.springframework.stereotype.Service;
@Service
public class WorkbookParsingService {
}

View file

@ -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
); );

View file

@ -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
); );

View file

@ -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
); );

View file

@ -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);

View file

@ -0,0 +1,2 @@
ALTER TABLE transactions.transaction_mapping
DROP COLUMN IF EXISTS statement_id;

View file

@ -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)
); );

View file

@ -1,4 +1,4 @@
# Personal Finances # Personal Finances
## Network Architecture / Topology ## Network Architecture / Topology
![finances_architecture.png](PersonalFinancesService%2Ffinances_architecture.png) ![finances_architecture.png](finances_architecture.png)

View file

Before

Width:  |  Height:  |  Size: 817 KiB

After

Width:  |  Height:  |  Size: 817 KiB

View file

@ -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",

View file

@ -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",

View file

@ -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>
</> </>

View file

@ -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 = [

View file

@ -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>

View 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>
);
}

View file

@ -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>
); );
} }

View file

@ -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());
} }
} }