diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..6b53e14 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,18 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/finances + + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index f6d4fd2..08300ac 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -10,6 +10,7 @@ diff --git a/APIGateway/.env.example b/APIGateway/.env.example index b45127a..687faea 100644 --- a/APIGateway/.env.example +++ b/APIGateway/.env.example @@ -1,3 +1,5 @@ +PROFILE= production/development + AUTHENTIK_CLIENT_ID= authentik oauth2 client id AUTHENTIK_CLIENT_SECRET= authentik oauth2 client secret AUTHENTIK_ISSUER_URL= authentik issuer url ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/ ) diff --git a/APIGateway/src/main/resources/application.yml b/APIGateway/src/main/resources/application.yml index 7fd79e0..1e4a146 100644 --- a/APIGateway/src/main/resources/application.yml +++ b/APIGateway/src/main/resources/application.yml @@ -3,6 +3,8 @@ logging: web: trace core: trace spring: + profiles: + active: ${PROFILE} data: redis: host: ${REDIS_HOST} @@ -30,6 +32,7 @@ spring: predicates: - Path=/api/** filters: + - RewritePath=/api/(?.*), /$\{segment} - TokenRelay= - id: spa order: 10 diff --git a/Common/build.gradle b/Common/build.gradle new file mode 100644 index 0000000..99eba84 --- /dev/null +++ b/Common/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'java' +} + +group = 'dev.mvvasilev' +version = '1.0-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' + + testImplementation platform('org.junit:junit-bom:5.9.1') + testImplementation 'org.junit.jupiter:junit-jupiter' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java b/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java new file mode 100644 index 0000000..3a266a6 --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java @@ -0,0 +1,22 @@ +package dev.mvvasilev.common.controller; + +import dev.mvvasilev.common.web.APIErrorDTO; +import dev.mvvasilev.common.web.APIResponseDTO; + +import java.util.List; + +public class AbstractRestController { + + protected APIResponseDTO withStatus(int statusCode, String statusText, T body) { + return new APIResponseDTO<>(body, null, statusCode, statusText); + } + + protected APIResponseDTO withSingleError(int statusCode, String statusText, String errorMessage, String errorCode, String stacktrace) { + return new APIResponseDTO<>(null, List.of(new APIErrorDTO(errorMessage, errorCode, stacktrace)), statusCode, statusText); + } + + protected APIResponseDTO ok(T body) { + return withStatus(200, "ok", body); + } + +} diff --git a/Common/src/main/java/dev/mvvasilev/common/data/AbstractEntity.java b/Common/src/main/java/dev/mvvasilev/common/data/AbstractEntity.java new file mode 100644 index 0000000..57bb3fb --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/data/AbstractEntity.java @@ -0,0 +1,54 @@ +package dev.mvvasilev.common.data; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@MappedSuperclass +public abstract class AbstractEntity implements DatabaseStorable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition = "bigserial") + private Long id; + + @Column(name = "time_created", insertable = false, updatable = false) + private LocalDateTime timeCreated; + + @Column(name = "time_last_modified", insertable = false) + private LocalDateTime timeLastModified; + + protected AbstractEntity() {} + + @Override + public long getId() { + return id; + } + + @Override + public LocalDateTime getTimeCreated() { + return timeCreated; + } + + @Override + public LocalDateTime getTimeLastModified() { + return timeLastModified; + } + + public void setId(Long id) { + this.id = id; + } + + public void setTimeCreated(LocalDateTime timeCreated) { + this.timeCreated = timeCreated; + } + + public void setTimeLastModified(LocalDateTime timeLastModified) { + this.timeLastModified = timeLastModified; + } + + @PreUpdate + protected void onUpdate() { + this.timeLastModified = LocalDateTime.now(); + } +} diff --git a/Common/src/main/java/dev/mvvasilev/common/data/DataNamingStrategy.java b/Common/src/main/java/dev/mvvasilev/common/data/DataNamingStrategy.java new file mode 100644 index 0000000..6c50b76 --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/data/DataNamingStrategy.java @@ -0,0 +1,4 @@ +package dev.mvvasilev.common.data; + +public class DataNamingStrategy { +} diff --git a/Common/src/main/java/dev/mvvasilev/common/data/DatabaseStorable.java b/Common/src/main/java/dev/mvvasilev/common/data/DatabaseStorable.java new file mode 100644 index 0000000..005bd01 --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/data/DatabaseStorable.java @@ -0,0 +1,13 @@ +package dev.mvvasilev.common.data; + +import java.time.LocalDateTime; + +public interface DatabaseStorable { + + long getId(); + + LocalDateTime getTimeCreated(); + + LocalDateTime getTimeLastModified(); + +} diff --git a/Common/src/main/java/dev/mvvasilev/common/exceptions/CommonFinancesException.java b/Common/src/main/java/dev/mvvasilev/common/exceptions/CommonFinancesException.java new file mode 100644 index 0000000..d15e631 --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/exceptions/CommonFinancesException.java @@ -0,0 +1,4 @@ +package dev.mvvasilev.common.exceptions; + +public class CommonFinancesException extends RuntimeException { +} diff --git a/Common/src/main/java/dev/mvvasilev/common/exceptions/InvalidUserIdException.java b/Common/src/main/java/dev/mvvasilev/common/exceptions/InvalidUserIdException.java new file mode 100644 index 0000000..d27d00a --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/exceptions/InvalidUserIdException.java @@ -0,0 +1,4 @@ +package dev.mvvasilev.common.exceptions; + +public class InvalidUserIdException extends CommonFinancesException { +} diff --git a/Common/src/main/java/dev/mvvasilev/common/web/APIErrorDTO.java b/Common/src/main/java/dev/mvvasilev/common/web/APIErrorDTO.java new file mode 100644 index 0000000..e417496 --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/web/APIErrorDTO.java @@ -0,0 +1,3 @@ +package dev.mvvasilev.common.web; + +public record APIErrorDTO(String message, String errorCode, String stacktrace) { } diff --git a/Common/src/main/java/dev/mvvasilev/common/web/APIResponseDTO.java b/Common/src/main/java/dev/mvvasilev/common/web/APIResponseDTO.java new file mode 100644 index 0000000..90d6cac --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/web/APIResponseDTO.java @@ -0,0 +1,5 @@ +package dev.mvvasilev.common.web; + +import java.util.Collection; + +public record APIResponseDTO(T result, Collection errors, int statusCode, String statusText) { } diff --git a/PersonalFinancesService/.env.example b/PersonalFinancesService/.env.example index 42952cd..a7c6912 100644 --- a/PersonalFinancesService/.env.example +++ b/PersonalFinancesService/.env.example @@ -1,5 +1,7 @@ +PROFILE= production/development + AUTHENTIK_ISSUER_URL= auth server configuration url for fetching JWKs ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/ ) -DATASOURCE_URL= database jdbc url ( postgres only, example: jdbc:postgresql://localhost:45093/mydatabase ) +DATASOURCE_URL= database jdbc url ( postgres only, example: jdbc:postgresql://localhost:5432/mydatabase ) DATASOURCE_USER= database user DATASOURCE_PASSWORD= database password \ No newline at end of file diff --git a/PersonalFinancesService/build.gradle b/PersonalFinancesService/build.gradle index 5f1240d..86cf4d5 100644 --- a/PersonalFinancesService/build.gradle +++ b/PersonalFinancesService/build.gradle @@ -26,10 +26,19 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.flywaydb:flyway-core' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.springdoc:springdoc-openapi-starter-common:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.apache.poi:poi:5.2.5' + implementation 'org.apache.poi:poi-ooxml:5.2.5' + + implementation project(":Common") + runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/advice/APIResponseAdvice.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/advice/APIResponseAdvice.java new file mode 100644 index 0000000..586c0d0 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/advice/APIResponseAdvice.java @@ -0,0 +1,42 @@ +package dev.mvvasilev.finances.advice; + +import dev.mvvasilev.common.web.APIErrorDTO; +import dev.mvvasilev.common.web.APIResponseDTO; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice(basePackages = {"dev.mvvasilev"}) +public class APIResponseAdvice { + + @Value("${debug}") + private boolean isDebug; + + private Logger logger = LoggerFactory.getLogger(this.getClass()); + + @ExceptionHandler(Exception.class) + public APIResponseDTO processGenericException(Exception ex) { + List errors = List.of( + new APIErrorDTO( + ex.getMessage(), + isDebug ? ex.getClass().getCanonicalName() : null, + isDebug ? ExceptionUtils.getStackTrace(ex) : null + ) + ); + + logger.error("Exception", ex); + + return new APIResponseDTO<>( + null, + errors, + HttpStatus.INTERNAL_SERVER_ERROR.value(), + HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase() + ); + } +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SecurityConfiguration.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SecurityConfiguration.java index adab7f0..98e609e 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SecurityConfiguration.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SecurityConfiguration.java @@ -11,6 +11,7 @@ import org.springframework.security.oauth2.jwt.JwtDecoders; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.filter.CommonsRequestLoggingFilter; @Configuration public class SecurityConfiguration { @@ -18,20 +19,32 @@ public class SecurityConfiguration { @Value("${jwt.issuer-url}") public String jwtIssuerUrl; - private static final String[] SWAGGER_URLS = { + private static final String[] WHITELISTED_URLS = { "/v3/api-docs/**", "/swagger-ui/**", "/v2/api-docs/**", - "/swagger-resources/**" + "/swagger-resources/**", + "/actuator/**" }; + @Bean + public CommonsRequestLoggingFilter requestLoggingFilter() { + CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter(); + loggingFilter.setIncludeClientInfo(true); + loggingFilter.setIncludeHeaders(true); + loggingFilter.setIncludeQueryString(true); + loggingFilter.setIncludePayload(true); + loggingFilter.setMaxPayloadLength(64000); + return loggingFilter; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, BearerTokenResolver bearerTokenResolver) throws Exception { return httpSecurity .cors(c -> c.disable()) // won't be needing cors, as the API will be completely hidden behind an api gateway .csrf(c -> c.disable()) .authorizeHttpRequests(c -> { - c.requestMatchers(SWAGGER_URLS).permitAll(); + c.requestMatchers(WHITELISTED_URLS).permitAll(); c.anyRequest().authenticated(); }) .oauth2ResourceServer(c -> { diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SwaggerConfiguration.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SwaggerConfiguration.java index 8dddece..dfc8c34 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SwaggerConfiguration.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/configuration/SwaggerConfiguration.java @@ -7,25 +7,25 @@ import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + @Configuration public class SwaggerConfiguration { @Bean public OpenAPI customizeOpenAPI() { - final String securitySchemeName = "Bearer"; - + final String securitySchemeName = "bearerAuth"; return new OpenAPI() - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) .components( - new Components().addSecuritySchemes( - securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.APIKEY) - .scheme("bearer") - .bearerFormat("JWT") - ) - ); + new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + ) + .security(List.of(new SecurityRequirement().addList(securitySchemeName))); } } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java deleted file mode 100644 index de353c2..0000000 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.mvvasilev.finances.controllers; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Optional; - -@RestController -public class ApiController { - - Logger logger = LoggerFactory.getLogger(ApiController.class); - - private final ObjectMapper objectMapper; - - @Autowired - public ApiController(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - - @PostMapping("/api/user-info") - public ResponseEntity userInfo(JwtAuthenticationToken authenticationToken) { - logger.info(authenticationToken.getToken().getClaimAsString(JwtClaimNames.SUB)); - return ResponseEntity.of(Optional.of(SecurityContextHolder.getContext().getAuthentication())); - } - -} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/DebugController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/DebugController.java new file mode 100644 index 0000000..6fe1eeb --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/DebugController.java @@ -0,0 +1,18 @@ +package dev.mvvasilev.finances.controllers; + +import dev.mvvasilev.common.controller.AbstractRestController; +import dev.mvvasilev.common.web.APIResponseDTO; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("development") +public class DebugController extends AbstractRestController { + @GetMapping("/token") + public ResponseEntity> fetchToken(@RequestHeader("Authorization") String authHeader) { + return ResponseEntity.ofNullable(ok(authHeader)); + } +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/TransactionsController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/TransactionsController.java new file mode 100644 index 0000000..f970596 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/TransactionsController.java @@ -0,0 +1,36 @@ +package dev.mvvasilev.finances.controllers; + +import dev.mvvasilev.common.controller.AbstractRestController; +import dev.mvvasilev.common.web.APIResponseDTO; +import dev.mvvasilev.finances.services.TransactionsService; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@RestController("/transactions") +public class TransactionsController extends AbstractRestController { + + private TransactionsService transactionsService; + + @Autowired + public TransactionsController(TransactionsService transactionsService) { + this.transactionsService = transactionsService; + } + + @PostMapping(value = "/transactions/uploadSheet", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadTransactions(@RequestParam("file") MultipartFile file, Authentication authentication) throws IOException { + transactionsService.uploadMultipleTransactionsFromExcelSheetForUser( + file.getInputStream(), + authentication.getName() + ); + return ResponseEntity.ofNullable(ok(1)); + } + +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawStatement.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawStatement.java new file mode 100644 index 0000000..39c8acf --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawStatement.java @@ -0,0 +1,23 @@ +package dev.mvvasilev.finances.entity; + +import dev.mvvasilev.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(schema = "transactions") +public class RawStatement extends AbstractEntity { + + private Integer userId; + + public RawStatement() { + } + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawTransactionValue.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawTransactionValue.java new file mode 100644 index 0000000..7c7cf21 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawTransactionValue.java @@ -0,0 +1,64 @@ +package dev.mvvasilev.finances.entity; + +import dev.mvvasilev.common.data.AbstractEntity; +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(schema = "transactions") +public class RawTransactionValue extends AbstractEntity { + + private Long groupId; + + private String stringValue; + + private LocalDateTime timestampValue; + + private Double numericValue; + + private Boolean booleanValue; + + public RawTransactionValue() { + } + + public Long getGroupId() { + return groupId; + } + + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + + public String getStringValue() { + return stringValue; + } + + public void setStringValue(String stringValue) { + this.stringValue = stringValue; + } + + public LocalDateTime getTimestampValue() { + return timestampValue; + } + + public void setTimestampValue(LocalDateTime timestampValue) { + this.timestampValue = timestampValue; + } + + public Double getNumericValue() { + return numericValue; + } + + public void setNumericValue(Double numericValue) { + this.numericValue = numericValue; + } + + public Boolean getBooleanValue() { + return booleanValue; + } + + public void setBooleanValue(Boolean booleanValue) { + this.booleanValue = booleanValue; + } +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawTransactionValueGroup.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawTransactionValueGroup.java new file mode 100644 index 0000000..83dc05b --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/RawTransactionValueGroup.java @@ -0,0 +1,44 @@ +package dev.mvvasilev.finances.entity; + +import dev.mvvasilev.common.data.AbstractEntity; +import dev.mvvasilev.finances.enums.RawTransactionValueType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(schema = "transactions") +public class RawTransactionValueGroup extends AbstractEntity { + + private Long statementId; + + private String name; + + private RawTransactionValueType type; + + public RawTransactionValueGroup() { + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public RawTransactionValueType getType() { + return type; + } + + public void setType(RawTransactionValueType type) { + this.type = type; + } + + public Long getStatementId() { + return statementId; + } + + public void setStatementId(Long statementId) { + this.statementId = statementId; + } +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/enums/RawTransactionValueType.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/enums/RawTransactionValueType.java new file mode 100644 index 0000000..0fefb53 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/enums/RawTransactionValueType.java @@ -0,0 +1,8 @@ +package dev.mvvasilev.finances.enums; + +public enum RawTransactionValueType { + STRING, + NUMERIC, + TIMESTAMP, + BOOLEAN +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawStatementRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawStatementRepository.java new file mode 100644 index 0000000..44dc380 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawStatementRepository.java @@ -0,0 +1,9 @@ +package dev.mvvasilev.finances.persistence; + +import dev.mvvasilev.finances.entity.RawStatement; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RawStatementRepository extends JpaRepository { +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueGroupRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueGroupRepository.java new file mode 100644 index 0000000..459ee4c --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueGroupRepository.java @@ -0,0 +1,9 @@ +package dev.mvvasilev.finances.persistence; + +import dev.mvvasilev.finances.entity.RawTransactionValueGroup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RawTransactionValueGroupRepository extends JpaRepository { +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueRepository.java new file mode 100644 index 0000000..0d6af8f --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/RawTransactionValueRepository.java @@ -0,0 +1,9 @@ +package dev.mvvasilev.finances.persistence; + +import dev.mvvasilev.finances.entity.RawTransactionValue; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RawTransactionValueRepository extends JpaRepository { +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/TransactionsService.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/TransactionsService.java new file mode 100644 index 0000000..882faee --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/TransactionsService.java @@ -0,0 +1,169 @@ +package dev.mvvasilev.finances.services; + +import dev.mvvasilev.finances.entity.RawStatement; +import dev.mvvasilev.finances.entity.RawTransactionValue; +import dev.mvvasilev.finances.entity.RawTransactionValueGroup; +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 jakarta.transaction.Transactional; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.CellUtil; +import org.hibernate.type.descriptor.DateTimeUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.time.temporal.ChronoField; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; + +@Service +@Transactional +public class TransactionsService { + + private static final DateTimeFormatter DATE_FORMAT = new DateTimeFormatterBuilder() + .appendPattern("dd.MM.yyyy[ [HH][:mm][:ss]]") + .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) + .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) + .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) + .toFormatter() + .withResolverStyle(ResolverStyle.LENIENT); + + private RawStatementRepository rawStatementRepository; + + private RawTransactionValueGroupRepository rawTransactionValueGroupRepository; + + private RawTransactionValueRepository rawTransactionValueRepository; + + @Autowired + public TransactionsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository) { + this.rawStatementRepository = rawStatementRepository; + this.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository; + this.rawTransactionValueRepository = rawTransactionValueRepository; + } + + + public void uploadMultipleTransactionsFromExcelSheetForUser(InputStream workbookInputStream, String userId) throws IOException { + var workbook = WorkbookFactory.create(workbookInputStream); + + var firstWorksheet = workbook.getSheetAt(0); + + var lastRowIndex = firstWorksheet.getLastRowNum(); + + var statement = new RawStatement(); + statement.setUserId(Integer.parseInt(userId)); + + statement = rawStatementRepository.saveAndFlush(statement); + + var firstRow = firstWorksheet.getRow(0); + + List valueGroups = new ArrayList<>(); + + for (var c : firstRow) { + + if (c == null || c.getCellType() == CellType.BLANK) { + break; + } + + var transactionValueGroup = new RawTransactionValueGroup(); + + transactionValueGroup.setStatementId(statement.getId()); + transactionValueGroup.setName(c.getStringCellValue()); + + // group type is string by default, if no other type could have been determined + var groupType = RawTransactionValueType.STRING; + + for (int y = c.getRowIndex() + 1; y <= lastRowIndex; y++) { + var typeResult = determineGroupType(firstWorksheet, y, c.getColumnIndex()); + + if (typeResult.isPresent()) { + groupType = typeResult.get(); + break; + } + } + + transactionValueGroup.setType(groupType); + + valueGroups.add(transactionValueGroup); + } + + valueGroups = rawTransactionValueGroupRepository.saveAllAndFlush(valueGroups); + + var column = 0; + for (var group : valueGroups) { + var valueList = new ArrayList(); + + for (int y = 1; y < lastRowIndex; y++) { + var value = new RawTransactionValue(); + + value.setGroupId(group.getId()); + + switch (group.getType()) { + case STRING -> value.setStringValue(firstWorksheet.getRow(y).getCell(column).getStringCellValue()); + case NUMERIC -> value.setNumericValue(firstWorksheet.getRow(y).getCell(column).getNumericCellValue()); + case TIMESTAMP -> value.setTimestampValue(LocalDateTime.parse(firstWorksheet.getRow(y).getCell(column).getStringCellValue().trim(), DATE_FORMAT)); + case BOOLEAN -> value.setBooleanValue(firstWorksheet.getRow(y).getCell(column).getBooleanCellValue()); + } + + valueList.add(value); + } + + rawTransactionValueRepository.saveAllAndFlush(valueList); + + column++; + } + } + + private Optional determineGroupType(Sheet worksheet, int rowIndex, int columnIndex) { + var cell = worksheet.getRow(rowIndex).getCell(columnIndex); + + if (cell == null || cell.getCellType() == CellType.BLANK) { + return Optional.empty(); + } + + if (cell.getCellType() == CellType.BOOLEAN) { + return Optional.of(RawTransactionValueType.BOOLEAN); + } + + if (cell.getCellType() == CellType.NUMERIC) { + return Optional.of(RawTransactionValueType.NUMERIC); + } + + var cellValue = cell.getStringCellValue(); + + if (isValidDate(cellValue)) { + return Optional.of(RawTransactionValueType.TIMESTAMP); + } + + return Optional.empty(); + } + + private boolean isValidDate(String inDate) { + try { + DATE_FORMAT.parse(inDate.trim()); + } catch (DateTimeParseException e) { + return false; + } + return true; + } + +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/WorkbookParsingService.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/WorkbookParsingService.java new file mode 100644 index 0000000..1407474 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/WorkbookParsingService.java @@ -0,0 +1,7 @@ +package dev.mvvasilev.finances.services; + +import org.springframework.stereotype.Service; + +@Service +public class WorkbookParsingService { +} diff --git a/PersonalFinancesService/src/main/resources/application.properties b/PersonalFinancesService/src/main/resources/application.properties index a4809b5..7133232 100644 --- a/PersonalFinancesService/src/main/resources/application.properties +++ b/PersonalFinancesService/src/main/resources/application.properties @@ -1,6 +1,9 @@ server.port=8081 +debug=true -logging.level.org.springframework.web=DEBUG +logging.level.org.springframework.boot.autoconfigure=ERROR + +spring.profiles.active=${PROFILE} # Database spring.datasource.url=${DATASOURCE_URL} @@ -8,5 +11,9 @@ spring.datasource.username=${DATASOURCE_USER} spring.datasource.password=${DATASOURCE_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.generate-ddl=false +spring.jpa.show-sql=true +spring.jpa.hibernate.ddl-auto=validate + # Security jwt.issuer-url=${AUTHENTIK_ISSUER_URL} \ No newline at end of file diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.0__CreateTransactionTables.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.0__CreateTransactionTables.sql new file mode 100644 index 0000000..5d91a75 --- /dev/null +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.0__CreateTransactionTables.sql @@ -0,0 +1,31 @@ +CREATE SCHEMA IF NOT EXISTS transactions; + +CREATE TABLE IF NOT EXISTS transactions.raw_statement ( + id BIGSERIAL, + time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT PK_raw_statement PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS transactions.raw_transaction_value_group ( + id BIGSERIAL, + statement_id BIGINT, + name VARCHAR(255), + type SMALLINT, + time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT PK_raw_transaction_value_group PRIMARY KEY (id), + CONSTRAINT FK_raw_transaction_value_group_raw_statement FOREIGN KEY (statement_id) REFERENCES transactions.raw_statement(id) +); + +CREATE TABLE IF NOT EXISTS transactions.raw_transaction_value ( + id BIGSERIAL, + group_id BIGINT, + string_value VARCHAR(1024), + timestamp_value TIMESTAMP, + numeric_value FLOAT, + time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT PK_raw_transaction_value PRIMARY KEY (id), + CONSTRAINT FK_raw_transaction_value_raw_transaction_value_group FOREIGN KEY (group_id) REFERENCES transactions.raw_transaction_value_group(id) +) \ No newline at end of file diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.1__AddUserIdToRawStatement.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.1__AddUserIdToRawStatement.sql new file mode 100644 index 0000000..58ecdce --- /dev/null +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.1__AddUserIdToRawStatement.sql @@ -0,0 +1 @@ +ALTER TABLE transactions.raw_statement ADD user_id INTEGER NOT NULL; \ No newline at end of file diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.2__AddBooleanValueToRawTransactionValue.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.2__AddBooleanValueToRawTransactionValue.sql new file mode 100644 index 0000000..14f479c --- /dev/null +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.2__AddBooleanValueToRawTransactionValue.sql @@ -0,0 +1 @@ +ALTER TABLE transactions.raw_transaction_value ADD boolean_value BOOLEAN; \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 7165e84..ca2763b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,5 @@ rootProject.name = 'personal-finances' include 'PersonalFinancesService' include 'APIGateway' +include 'Common'