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

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

View file

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

View file

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

View file

@ -1,4 +1,7 @@
package dev.mvvasilev.common.exceptions;
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")
@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) {

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

View file

@ -1,4 +1,4 @@
# Personal Finances
## 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",
"@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",

View file

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

View file

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

View file

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

View file

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

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

View file

@ -13,6 +13,9 @@ let utils = {
return resp;
});
},
toPascalCase: (s) => {
return s.replace(/(\w)(\w*)/g, (g0,g1,g2) => g1.toUpperCase() + g2.toLowerCase());
}
}