diff --git a/.editorconfig b/.editorconfig index 01210260..3ab62438 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,5 +2,5 @@ root = true [*.java] indent_style = space -indent_size = 2 +indent_size = 4 ij_java_continuation_indent_size = 4 diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/ProcessMapper.java b/bpmn-process/src/main/java/com/jongsoft/finance/ProcessMapper.java index 7f6101d6..1037b879 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/ProcessMapper.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/ProcessMapper.java @@ -2,39 +2,42 @@ import com.jongsoft.lang.Control; import com.jongsoft.lang.control.Try; + import io.micronaut.serde.ObjectMapper; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton public class ProcessMapper { - private final ObjectMapper objectMapper; - - public ProcessMapper(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - - public String writeSafe(T entity) { - return Control.Try(() -> objectMapper.writeValueAsString(entity)) - .recover(x -> { - log.warn("Could not serialize entity {}", entity, x); - return null; - }) - .get(); - } - - public T readSafe(String json, Class clazz) { - return Control.Try(() -> objectMapper.readValue(json, clazz)) - .recover(x -> { - log.warn("Could not deserialize json {}", json, x); - return null; - }) - .get(); - } - - public Try read(String json, Class clazz) { - return Control.Try(() -> objectMapper.readValue(json, clazz)); - } + private final ObjectMapper objectMapper; + + public ProcessMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String writeSafe(T entity) { + return Control.Try(() -> objectMapper.writeValueAsString(entity)) + .recover(x -> { + log.warn("Could not serialize entity {}", entity, x); + return null; + }) + .get(); + } + + public T readSafe(String json, Class clazz) { + return Control.Try(() -> objectMapper.readValue(json, clazz)) + .recover(x -> { + log.warn("Could not deserialize json {}", json, x); + return null; + }) + .get(); + } + + public Try read(String json, Class clazz) { + return Control.Try(() -> objectMapper.readValue(json, clazz)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/KnownProcesses.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/KnownProcesses.java index 7d25fb35..56b80a7c 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/KnownProcesses.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/KnownProcesses.java @@ -2,16 +2,16 @@ public class KnownProcesses { - private KnownProcesses() { - // private constructor - } + private KnownProcesses() { + // private constructor + } - /** - * The process identifier for the process that monitors for contract ending and sends the user a - * notification before hand. - */ - public static final String CONTRACT_WARN_EXPIRY = "ContractEndWarning"; + /** + * The process identifier for the process that monitors for contract ending and sends the user a + * notification before hand. + */ + public static final String CONTRACT_WARN_EXPIRY = "ContractEndWarning"; - /** The process identifier for the process that handles any type of scheduling. */ - public static final String PROCESS_SCHEDULE = "ProcessScheduler"; + /** The process identifier for the process that handles any type of scheduling. */ + public static final String PROCESS_SCHEDULE = "ProcessScheduler"; } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/ProcessEngineConfiguration.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/ProcessEngineConfiguration.java index 39b2f6aa..b73edf1f 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/ProcessEngineConfiguration.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/ProcessEngineConfiguration.java @@ -5,14 +5,15 @@ import com.jongsoft.finance.ProcessVariable; import com.jongsoft.finance.bpmn.camunda.*; import com.jongsoft.finance.importer.api.TransactionDTO; + import io.micronaut.context.ApplicationContext; import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Requires; import io.micronaut.serde.ObjectMapper; -import java.io.IOException; -import java.util.List; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.HistoryService; import org.camunda.bpm.engine.ProcessEngine; import org.camunda.bpm.engine.RuntimeService; @@ -21,89 +22,93 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import java.io.IOException; +import java.util.List; + @Slf4j @Factory @Requires(notEnv = "no-camunda") public class ProcessEngineConfiguration { - private final ApplicationContext applicationContext; - private final CamundaDatasourceConfiguration camundaDatasourceConfiguration; - - public ProcessEngineConfiguration( - ApplicationContext applicationContext, - CamundaDatasourceConfiguration camundaDatasourceConfiguration) { - this.applicationContext = applicationContext; - this.camundaDatasourceConfiguration = camundaDatasourceConfiguration; - } - - @Bean - public ProcessEngine processEngine() throws IOException { - var configuration = new StandaloneProcessEngineConfiguration(); - - configuration - .setHistory(camundaDatasourceConfiguration.getHistoryLevel()) - .setJobExecutorActivate(true) - .setMetricsEnabled(true) - .setJdbcDriver(camundaDatasourceConfiguration.getDriverClassName()) - .setJdbcUrl(camundaDatasourceConfiguration.getUrl()) - .setJdbcUsername(camundaDatasourceConfiguration.getUsername()) - .setJdbcPassword(camundaDatasourceConfiguration.getPassword()) - .setDatabaseSchemaUpdate(camundaDatasourceConfiguration.getAutoUpdate()) - .setProcessEngineName("fintrack") - .setHistoryCleanupEnabled(true) - .setSkipIsolationLevelCheck(true) - .setExpressionManager( - new MicronautExpressionManager(new MicronautElResolver(applicationContext))); - - configuration.setHistoryCleanupBatchSize(250); - configuration.setHistoryCleanupBatchWindowStartTime("01:00"); - configuration.setHistoryCleanupBatchWindowEndTime("03:00"); - configuration.setHistoryTimeToLive("P1D"); - configuration.setResolverFactories(List.of(new MicronautBeanResolver(applicationContext))); - configuration.setCustomPreVariableSerializers(List.of( - new JsonRecordSerializer<>( - applicationContext.getBean(ObjectMapper.class), ProcessVariable.class), - new JsonRecordSerializer<>( - applicationContext.getBean(ObjectMapper.class), TransactionDTO.class))); - - var processEngine = configuration.buildProcessEngine(); - log.info("Created camunda process engine"); - - deployResources(processEngine); - return processEngine; - } - - @Bean - public HistoryService historyService(ProcessEngine processEngine) { - return processEngine.getHistoryService(); - } - - @Bean - public TaskService taskService(ProcessEngine processEngine) { - return processEngine.getTaskService(); - } - - @Bean - public RuntimeService runtimeService(ProcessEngine processEngine) { - return processEngine.getRuntimeService(); - } - - private void deployResources(ProcessEngine processEngine) throws IOException { - log.info("Searching for deployable camunda processes"); - - PathMatchingResourcePatternResolver resourceLoader = new PathMatchingResourcePatternResolver(); - for (String extension : List.of("dmn", "cmmn", "bpmn")) { - for (Resource resource : - resourceLoader.getResources(CLASSPATH_ALL_URL_PREFIX + extension + "/*/*." + extension)) { - log.info("Deploying model: {}", resource.getFilename()); - processEngine - .getRepositoryService() - .createDeployment() - .name("MicronautAutoDeployment") - .addInputStream(resource.getFilename(), resource.getInputStream()) - .enableDuplicateFiltering(true) - .deploy(); - } + private final ApplicationContext applicationContext; + private final CamundaDatasourceConfiguration camundaDatasourceConfiguration; + + public ProcessEngineConfiguration( + ApplicationContext applicationContext, + CamundaDatasourceConfiguration camundaDatasourceConfiguration) { + this.applicationContext = applicationContext; + this.camundaDatasourceConfiguration = camundaDatasourceConfiguration; + } + + @Bean + public ProcessEngine processEngine() throws IOException { + var configuration = new StandaloneProcessEngineConfiguration(); + + configuration + .setHistory(camundaDatasourceConfiguration.getHistoryLevel()) + .setJobExecutorActivate(true) + .setMetricsEnabled(true) + .setJdbcDriver(camundaDatasourceConfiguration.getDriverClassName()) + .setJdbcUrl(camundaDatasourceConfiguration.getUrl()) + .setJdbcUsername(camundaDatasourceConfiguration.getUsername()) + .setJdbcPassword(camundaDatasourceConfiguration.getPassword()) + .setDatabaseSchemaUpdate(camundaDatasourceConfiguration.getAutoUpdate()) + .setProcessEngineName("fintrack") + .setHistoryCleanupEnabled(true) + .setSkipIsolationLevelCheck(true) + .setExpressionManager(new MicronautExpressionManager( + new MicronautElResolver(applicationContext))); + + configuration.setHistoryCleanupBatchSize(250); + configuration.setHistoryCleanupBatchWindowStartTime("01:00"); + configuration.setHistoryCleanupBatchWindowEndTime("03:00"); + configuration.setHistoryTimeToLive("P1D"); + configuration.setResolverFactories(List.of(new MicronautBeanResolver(applicationContext))); + configuration.setCustomPreVariableSerializers(List.of( + new JsonRecordSerializer<>( + applicationContext.getBean(ObjectMapper.class), ProcessVariable.class), + new JsonRecordSerializer<>( + applicationContext.getBean(ObjectMapper.class), TransactionDTO.class))); + + var processEngine = configuration.buildProcessEngine(); + log.info("Created camunda process engine"); + + deployResources(processEngine); + return processEngine; + } + + @Bean + public HistoryService historyService(ProcessEngine processEngine) { + return processEngine.getHistoryService(); + } + + @Bean + public TaskService taskService(ProcessEngine processEngine) { + return processEngine.getTaskService(); + } + + @Bean + public RuntimeService runtimeService(ProcessEngine processEngine) { + return processEngine.getRuntimeService(); + } + + private void deployResources(ProcessEngine processEngine) throws IOException { + log.info("Searching for deployable camunda processes"); + + PathMatchingResourcePatternResolver resourceLoader = + new PathMatchingResourcePatternResolver(); + for (String extension : List.of("dmn", "cmmn", "bpmn")) { + for (Resource resource : resourceLoader.getResources( + CLASSPATH_ALL_URL_PREFIX + extension + "/*/*." + extension)) { + log.info("Deploying model: {}", resource.getFilename()); + processEngine + .getRepositoryService() + .createDeployment() + .name("MicronautAutoDeployment") + .addInputStream(resource.getFilename(), resource.getInputStream()) + .enableDuplicateFiltering(true) + .deploy(); + } + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/CamundaDatasourceConfiguration.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/CamundaDatasourceConfiguration.java index 8e2ff379..9562a918 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/CamundaDatasourceConfiguration.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/CamundaDatasourceConfiguration.java @@ -2,32 +2,33 @@ import io.micronaut.context.annotation.ConfigurationProperties; import io.micronaut.core.bind.annotation.Bindable; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @ConfigurationProperties("datasources.default") public interface CamundaDatasourceConfiguration { - @Bindable(defaultValue = "jdbc:h2:mem:fintrack;DB_CLOSE_DELAY=1000") - @NotBlank - String getUrl(); + @Bindable + @NotBlank + String getUrl(); - @Bindable(defaultValue = "sa") - @NotBlank - String getUsername(); + @Bindable + @NotBlank + String getUsername(); - @Bindable(defaultValue = "") - @NotNull - String getPassword(); + @Bindable + @NotNull + String getPassword(); - @Bindable(defaultValue = "org.h2.Driver") - @NotBlank - String getDriverClassName(); + @Bindable + @NotBlank + String getDriverClassName(); - @Bindable(defaultValue = "false") - @NotBlank - String getAutoUpdate(); + @Bindable(defaultValue = "false") + @NotBlank + String getAutoUpdate(); - @Bindable(defaultValue = "auto") - String getHistoryLevel(); + @Bindable(defaultValue = "auto") + String getHistoryLevel(); } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/JsonRecordSerializer.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/JsonRecordSerializer.java index 29b243cc..ef29a4d3 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/JsonRecordSerializer.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/JsonRecordSerializer.java @@ -1,7 +1,7 @@ package com.jongsoft.finance.bpmn.camunda; import io.micronaut.serde.ObjectMapper; -import java.io.IOException; + import org.camunda.bpm.engine.impl.variable.serializer.AbstractTypedValueSerializer; import org.camunda.bpm.engine.impl.variable.serializer.ValueFields; import org.camunda.bpm.engine.variable.Variables; @@ -12,82 +12,84 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; + public class JsonRecordSerializer extends AbstractTypedValueSerializer { - private static final Logger logger = LoggerFactory.getLogger(JsonRecordSerializer.class); - - final ObjectMapper objectMapper; - final Class supportedClass; - - public JsonRecordSerializer(ObjectMapper objectMapper, Class supportedClass) { - super(new ObjectTypeImpl()); - this.objectMapper = objectMapper; - this.supportedClass = supportedClass; - } - - @Override - public String getName() { - return supportedClass.getName(); - } - - @Override - public String getSerializationDataformat() { - return getName(); - } - - @Override - public TypedValue convertToTypedValue(UntypedValueImpl untypedValue) { - logger.trace( - "Converting untyped value to typed value: {}", - untypedValue.getValue().getClass().getSimpleName()); - - try { - var jsonString = objectMapper.writeValueAsString(untypedValue.getValue()); - return Variables.serializedObjectValue(jsonString) - .objectTypeName(supportedClass.getName()) - .serializationDataFormat("application/json") - .create(); - } catch (IOException e) { - throw new RuntimeException("Could not serialize ImportJobSettings", e); + private static final Logger logger = LoggerFactory.getLogger(JsonRecordSerializer.class); + + final ObjectMapper objectMapper; + final Class supportedClass; + + public JsonRecordSerializer(ObjectMapper objectMapper, Class supportedClass) { + super(new ObjectTypeImpl()); + this.objectMapper = objectMapper; + this.supportedClass = supportedClass; + } + + @Override + public String getName() { + return supportedClass.getName(); } - } - - @Override - public void writeValue(TypedValue typedValue, ValueFields valueFields) { - ObjectValue objectValue = (ObjectValue) typedValue; - valueFields.setByteArrayValue(objectValue.getValueSerialized().getBytes()); - } - - @Override - public TypedValue readValue(ValueFields valueFields, boolean b, boolean b1) { - logger.trace("Reading value from value fields: {}", valueFields.getName()); - try { - return Variables.objectValue( - objectMapper.readValue(new String(valueFields.getByteArrayValue()), supportedClass)) - .serializationDataFormat("application/json") - .create(); - } catch (IOException e) { - throw new RuntimeException("Could not deserialize ImportJobSettings", e); + + @Override + public String getSerializationDataformat() { + return getName(); + } + + @Override + public TypedValue convertToTypedValue(UntypedValueImpl untypedValue) { + logger.trace( + "Converting untyped value to typed value: {}", + untypedValue.getValue().getClass().getSimpleName()); + + try { + var jsonString = objectMapper.writeValueAsString(untypedValue.getValue()); + return Variables.serializedObjectValue(jsonString) + .objectTypeName(supportedClass.getName()) + .serializationDataFormat("application/json") + .create(); + } catch (IOException e) { + throw new RuntimeException("Could not serialize ImportJobSettings", e); + } } - } - @Override - public boolean canHandle(TypedValue value) { - if (value instanceof ObjectValue objectValue) { - return supportedClass.getName().equals(objectValue.getObjectTypeName()); + @Override + public void writeValue(TypedValue typedValue, ValueFields valueFields) { + ObjectValue objectValue = (ObjectValue) typedValue; + valueFields.setByteArrayValue(objectValue.getValueSerialized().getBytes()); } - return canWriteValue(value); - } + @Override + public TypedValue readValue(ValueFields valueFields, boolean b, boolean b1) { + logger.trace("Reading value from value fields: {}", valueFields.getName()); + try { + return Variables.objectValue(objectMapper.readValue( + new String(valueFields.getByteArrayValue()), supportedClass)) + .serializationDataFormat("application/json") + .create(); + } catch (IOException e) { + throw new RuntimeException("Could not deserialize ImportJobSettings", e); + } + } + + @Override + public boolean canHandle(TypedValue value) { + if (value instanceof ObjectValue objectValue) { + return supportedClass.getName().equals(objectValue.getObjectTypeName()); + } - @Override - protected boolean canWriteValue(TypedValue typedValue) { - if (typedValue instanceof UntypedValueImpl) { - logger.trace( - "Checking if un-typed value can be written: {}", - typedValue.getValue().getClass().getSimpleName()); - return supportedClass.isInstance(typedValue.getValue()); + return canWriteValue(value); } - return false; - } + @Override + protected boolean canWriteValue(TypedValue typedValue) { + if (typedValue instanceof UntypedValueImpl) { + logger.trace( + "Checking if un-typed value can be written: {}", + typedValue.getValue().getClass().getSimpleName()); + return supportedClass.isInstance(typedValue.getValue()); + } + + return false; + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautBeanResolver.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautBeanResolver.java index 7b5b8933..82bdacdc 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautBeanResolver.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautBeanResolver.java @@ -7,80 +7,79 @@ import io.micronaut.core.naming.NameResolver; import io.micronaut.inject.BeanDefinition; import io.micronaut.inject.qualifiers.Qualifiers; -import java.util.Set; -import java.util.stream.Collectors; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.VariableScope; import org.camunda.bpm.engine.impl.scripting.engine.Resolver; import org.camunda.bpm.engine.impl.scripting.engine.ResolverFactory; +import java.util.Set; +import java.util.stream.Collectors; + /** Resolves beans from the Micronaut application context. */ @Slf4j public class MicronautBeanResolver implements ResolverFactory, Resolver { - private final ApplicationContext applicationContext; - protected Set keySet; + private final ApplicationContext applicationContext; - public MicronautBeanResolver(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } + public MicronautBeanResolver(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } - @Override - public boolean containsKey(Object key) { - log.debug("Looking up key {} in {}", key, getKeySet()); - return key instanceof String && getKeySet().contains(key); - } + @Override + public boolean containsKey(Object key) { + log.debug("Looking up key {} in {}", key, getKeySet()); + return key instanceof String && getKeySet().contains(key); + } - @Override - public Object get(Object key) { - if (key instanceof String) { - log.debug("Looking up bean {} in {}", key, getKeySet()); - Qualifier qualifier = Qualifiers.byName((String) key); - if (applicationContext.containsBean(Object.class, qualifier)) { - return applicationContext.getBean(Object.class, qualifier); - } + @Override + public Object get(Object key) { + if (key instanceof String) { + log.debug("Looking up bean {} in {}", key, getKeySet()); + Qualifier qualifier = Qualifiers.byName((String) key); + if (applicationContext.containsBean(Object.class, qualifier)) { + return applicationContext.getBean(Object.class, qualifier); + } + } + return null; } - return null; - } - @Override - public Set keySet() { - return getKeySet(); - } + @Override + public Set keySet() { + return getKeySet(); + } - @Override - public Resolver createResolver(VariableScope variableScope) { - log.debug("Creating resolver for {}", variableScope); - return this; - } + @Override + public Resolver createResolver(VariableScope variableScope) { + log.debug("Creating resolver for {}", variableScope); + return this; + } - protected synchronized Set getKeySet() { - if (keySet == null) { - keySet = applicationContext.getAllBeanDefinitions().stream() - .filter( - beanDefinition -> !beanDefinition.getClass().getName().startsWith("io.micronaut.")) - .map(this::getBeanName) - .collect(Collectors.toSet()); + protected synchronized Set getKeySet() { + return applicationContext.getAllBeanDefinitions().stream() + .filter(beanDefinition -> + !beanDefinition.getClass().getName().startsWith("io.micronaut.")) + .map(this::getBeanName) + .collect(Collectors.toSet()); } - return keySet; - } - protected String getBeanName(BeanDefinition beanDefinition) { - var beanQualifier = beanDefinition - .getAnnotationMetadata() - .findDeclaredAnnotation(AnnotationUtil.NAMED) - .flatMap(AnnotationValue::stringValue); - return beanQualifier.orElseGet(() -> { - if (beanDefinition instanceof NameResolver resolver) { - return resolver.resolveName().orElse(getBeanNameFromType(beanDefinition)); - } - return getBeanNameFromType(beanDefinition); - }); - } + protected String getBeanName(BeanDefinition beanDefinition) { + var beanQualifier = beanDefinition + .getAnnotationMetadata() + .findDeclaredAnnotation(AnnotationUtil.NAMED) + .flatMap(AnnotationValue::stringValue); + return beanQualifier.orElseGet(() -> { + if (beanDefinition instanceof NameResolver resolver) { + return resolver.resolveName().orElse(getBeanNameFromType(beanDefinition)); + } + return getBeanNameFromType(beanDefinition); + }); + } - protected String getBeanNameFromType(BeanDefinition beanDefinition) { - String beanName = beanDefinition.getBeanType().getSimpleName(); - // lower the first character - return Character.toLowerCase(beanName.charAt(0)) + beanName.substring(1); - } + protected String getBeanNameFromType(BeanDefinition beanDefinition) { + String beanName = beanDefinition.getBeanType().getSimpleName(); + // lower the first character + return Character.toLowerCase(beanName.charAt(0)) + beanName.substring(1); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautElResolver.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautElResolver.java index 20c64b6d..b72a28bb 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautElResolver.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautElResolver.java @@ -1,83 +1,86 @@ package com.jongsoft.finance.bpmn.camunda; import com.jongsoft.finance.core.JavaBean; + import io.micronaut.context.ApplicationContext; import io.micronaut.core.type.Argument; import io.micronaut.inject.qualifiers.Qualifiers; -import java.beans.FeatureDescriptor; -import java.util.Iterator; -import java.util.Optional; + import org.camunda.bpm.engine.ProcessEngineException; import org.camunda.bpm.impl.juel.jakarta.el.ELContext; import org.camunda.bpm.impl.juel.jakarta.el.ELResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.beans.FeatureDescriptor; +import java.util.Iterator; +import java.util.Optional; + /** * This ELResolver implementation allows to resolve beans from the Micronaut application-context. */ public class MicronautElResolver extends ELResolver { - private static final Argument TYPE = Argument.of(JavaBean.class); + private static final Argument TYPE = Argument.of(JavaBean.class); + + private static final Logger log = LoggerFactory.getLogger(MicronautElResolver.class); - private static final Logger log = LoggerFactory.getLogger(MicronautElResolver.class); + protected final ApplicationContext applicationContext; - protected final ApplicationContext applicationContext; + public MicronautElResolver(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public Object getValue(ELContext context, Object base, Object property) { + if (base == null) { + log.debug("Looking up bean '{}' in Micronaut application-context", property); + var resolvedBean = getBeanForKey(property.toString()); + if (resolvedBean.isPresent()) { + context.setPropertyResolved(true); + return resolvedBean.get(); + } + } + + return null; + } + + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + return true; + } - public MicronautElResolver(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + if (base == null + && !applicationContext.containsBean(TYPE, Qualifiers.byName(property.toString()))) { + throw new ProcessEngineException("Cannot set value of '" + + property + + "', it resolves to a bean defined in the Micronaut" + + " application-context."); + } + } - @Override - public Object getValue(ELContext context, Object base, Object property) { - if (base == null) { - log.debug("Looking up bean '{}' in Micronaut application-context", property); - var resolvedBean = getBeanForKey(property.toString()); - if (resolvedBean.isPresent()) { - context.setPropertyResolved(true); - return resolvedBean.get(); - } + @Override + public Class getCommonPropertyType(ELContext context, Object arg) { + return Object.class; } - return null; - } - - @Override - public boolean isReadOnly(ELContext context, Object base, Object property) { - return true; - } - - @Override - public void setValue(ELContext context, Object base, Object property, Object value) { - if (base == null - && !applicationContext.containsBean(TYPE, Qualifiers.byName(property.toString()))) { - throw new ProcessEngineException("Cannot set value of '" - + property - + "', it resolves to a bean defined in the Micronaut" - + " application-context."); + @Override + public Iterator getFeatureDescriptors(ELContext context, Object arg) { + return null; } - } - - @Override - public Class getCommonPropertyType(ELContext context, Object arg) { - return Object.class; - } - - @Override - public Iterator getFeatureDescriptors(ELContext context, Object arg) { - return null; - } - - @Override - public Class getType(ELContext context, Object arg1, Object arg2) { - return Object.class; - } - - private Optional getBeanForKey(String key) { - if (applicationContext.containsBean(TYPE, Qualifiers.byName(key))) { - return Optional.of(applicationContext.getBean(TYPE, Qualifiers.byName(key))); + + @Override + public Class getType(ELContext context, Object arg1, Object arg2) { + return Object.class; } - return Optional.empty(); - } + private Optional getBeanForKey(String key) { + if (applicationContext.containsBean(TYPE, Qualifiers.byName(key))) { + return Optional.of(applicationContext.getBean(TYPE, Qualifiers.byName(key))); + } + + return Optional.empty(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautExpressionManager.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautExpressionManager.java index 91f66d05..58a1a52f 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautExpressionManager.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/camunda/MicronautExpressionManager.java @@ -1,32 +1,33 @@ package com.jongsoft.finance.bpmn.camunda; -import java.time.LocalDate; import org.camunda.bpm.engine.impl.el.JuelExpressionManager; import org.camunda.bpm.impl.juel.jakarta.el.CompositeELResolver; import org.camunda.bpm.impl.juel.jakarta.el.ELResolver; +import java.time.LocalDate; + public class MicronautExpressionManager extends JuelExpressionManager { - private final MicronautElResolver micronautElResolver; + private final MicronautElResolver micronautElResolver; - public MicronautExpressionManager(MicronautElResolver micronautElResolver) { - this.micronautElResolver = micronautElResolver; + public MicronautExpressionManager(MicronautElResolver micronautElResolver) { + this.micronautElResolver = micronautElResolver; - try { - addFunction("math:double", Double.class.getMethod("parseDouble", String.class)); - addFunction("date:parse", LocalDate.class.getMethod("parse", CharSequence.class)); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); + try { + addFunction("math:double", Double.class.getMethod("parseDouble", String.class)); + addFunction("date:parse", LocalDate.class.getMethod("parse", CharSequence.class)); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } } - } - @Override - protected ELResolver createElResolver() { - var resolver = super.createElResolver(); - if (resolver instanceof CompositeELResolver e) { - e.add(micronautElResolver); - } + @Override + protected ELResolver createElResolver() { + var resolver = super.createElResolver(); + if (resolver instanceof CompositeELResolver e) { + e.add(micronautElResolver); + } - return resolver; - } + return resolver; + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/ComputeBalanceDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/ComputeBalanceDelegate.java index 59ea4a1f..3d6344ed 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/ComputeBalanceDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/ComputeBalanceDelegate.java @@ -6,63 +6,68 @@ import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.lang.Collections; import com.jongsoft.lang.Dates; + import jakarta.inject.Singleton; -import java.math.BigDecimal; -import java.time.LocalDate; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.BooleanValue; import org.camunda.bpm.engine.variable.value.StringValue; +import java.math.BigDecimal; +import java.time.LocalDate; + @Slf4j @Singleton public class ComputeBalanceDelegate implements JavaDelegate, JavaBean { - private final FilterFactory filterFactory; - private final TransactionProvider transactionProvider; + private final FilterFactory filterFactory; + private final TransactionProvider transactionProvider; - public ComputeBalanceDelegate( - FilterFactory filterFactory, TransactionProvider transactionProvider) { - this.filterFactory = filterFactory; - this.transactionProvider = transactionProvider; - } + public ComputeBalanceDelegate( + FilterFactory filterFactory, TransactionProvider transactionProvider) { + this.filterFactory = filterFactory; + this.transactionProvider = transactionProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var requestBuilder = filterFactory.transaction(); + @Override + public void execute(DelegateExecution execution) throws Exception { + var requestBuilder = filterFactory.transaction(); - if (execution.hasVariableLocal("accountId")) { - Long accountId = ((Number) execution.getVariableLocal("accountId")).longValue(); - requestBuilder.accounts(Collections.List(new EntityRef(accountId))); - } + if (execution.hasVariableLocal("accountId")) { + Long accountId = ((Number) execution.getVariableLocal("accountId")).longValue(); + requestBuilder.accounts(Collections.List(new EntityRef(accountId))); + } - if (execution.hasVariableLocal("date")) { - String isoDate = execution.getVariableLocalTyped("date").getValue(); - requestBuilder.range(Dates.range(LocalDate.of(1900, 1, 1), LocalDate.parse(isoDate))); - } else { - requestBuilder.range(Dates.range(LocalDate.of(1900, 1, 1), LocalDate.of(2900, 1, 1))); - } + if (execution.hasVariableLocal("date")) { + String isoDate = + execution.getVariableLocalTyped("date").getValue(); + requestBuilder.range(Dates.range(LocalDate.of(1900, 1, 1), LocalDate.parse(isoDate))); + } else { + requestBuilder.range(Dates.range(LocalDate.of(1900, 1, 1), LocalDate.of(2900, 1, 1))); + } - if (execution.hasVariableLocal("onlyIncome")) { - boolean onlyIncome = - execution.getVariableLocalTyped("onlyIncome").getValue(); - requestBuilder.onlyIncome(onlyIncome); - } + if (execution.hasVariableLocal("onlyIncome")) { + boolean onlyIncome = + execution.getVariableLocalTyped("onlyIncome").getValue(); + requestBuilder.onlyIncome(onlyIncome); + } - if (log.isTraceEnabled()) { - log.trace( - "{}: Computing the balance based upon {}", - execution.getCurrentActivityName(), - requestBuilder); - } else { - log.debug( - "{}: Computing the balance for accountId {}", - execution.getCurrentActivityName(), - execution.getVariableLocal("accountId")); - } + if (log.isTraceEnabled()) { + log.trace( + "{}: Computing the balance based upon {}", + execution.getCurrentActivityName(), + requestBuilder); + } else { + log.debug( + "{}: Computing the balance for accountId {}", + execution.getCurrentActivityName(), + execution.getVariableLocal("accountId")); + } - var result = transactionProvider.balance(requestBuilder); - execution.setVariableLocal("result", result.getOrSupply(() -> BigDecimal.ZERO)); - } + var result = transactionProvider.balance(requestBuilder); + execution.setVariableLocal("result", result.getOrSupply(() -> BigDecimal.ZERO)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/PropertyConversionDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/PropertyConversionDelegate.java index f0d660a5..6b231893 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/PropertyConversionDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/PropertyConversionDelegate.java @@ -3,29 +3,32 @@ import static org.slf4j.LoggerFactory.getLogger; import com.jongsoft.finance.core.JavaBean; + import jakarta.inject.Singleton; -import java.util.Properties; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.slf4j.Logger; +import java.util.Properties; + @Singleton public class PropertyConversionDelegate implements JavaDelegate, JavaBean { - private static final Logger log = getLogger(PropertyConversionDelegate.class); + private static final Logger log = getLogger(PropertyConversionDelegate.class); - @Override - public void execute(DelegateExecution execution) { - log.debug( - "{}: Converting the provided local properties into a PropertyMap.", - execution.getCurrentActivityName()); + @Override + public void execute(DelegateExecution execution) { + log.debug( + "{}: Converting the provided local properties into a PropertyMap.", + execution.getCurrentActivityName()); - var converted = new Properties(); + var converted = new Properties(); - execution.getVariableNamesLocal().stream() - .filter(n -> execution.getVariableLocal(n) != null) - .forEach(n -> converted.put(n, execution.getVariableLocal(n))); + execution.getVariableNamesLocal().stream() + .filter(n -> execution.getVariableLocal(n) != null) + .forEach(n -> converted.put(n, execution.getVariableLocal(n))); - execution.setVariable("propertyConversionResult", converted); - } + execution.setVariable("propertyConversionResult", converted); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/SendMailDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/SendMailDelegate.java new file mode 100644 index 00000000..1dc662a2 --- /dev/null +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/SendMailDelegate.java @@ -0,0 +1,30 @@ +package com.jongsoft.finance.bpmn.delegate; + +import com.jongsoft.finance.core.JavaBean; +import com.jongsoft.finance.core.MailDaemon; + +import jakarta.inject.Singleton; + +import org.camunda.bpm.engine.delegate.DelegateExecution; +import org.camunda.bpm.engine.delegate.JavaDelegate; + +import java.util.Properties; + +@Singleton +public class SendMailDelegate implements JavaDelegate, JavaBean { + + private final MailDaemon mailDaemon; + + public SendMailDelegate(MailDaemon mailDaemon) { + this.mailDaemon = mailDaemon; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + var email = (String) execution.getVariableLocal("email"); + var template = (String) execution.getVariableLocal("mailTemplate"); + var variables = (Properties) execution.getVariableLocal("variables"); + + mailDaemon.send(email, template, variables); + } +} diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/AccountSynonymLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/AccountSynonymLookupDelegate.java index a6d9b085..67734ab3 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/AccountSynonymLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/AccountSynonymLookupDelegate.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.domain.account.Account; import com.jongsoft.finance.providers.AccountProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; @@ -30,23 +33,24 @@ @Singleton public class AccountSynonymLookupDelegate implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; + private final AccountProvider accountProvider; - AccountSynonymLookupDelegate(AccountProvider accountProvider) { - this.accountProvider = accountProvider; - } + AccountSynonymLookupDelegate(AccountProvider accountProvider) { + this.accountProvider = accountProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing account lookup using synonym '{}'", - execution.getCurrentActivityName(), - execution.getVariable("name")); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing account lookup using synonym '{}'", + execution.getCurrentActivityName(), + execution.getVariable("name")); - var synonym = execution.getVariableLocalTyped("name").getValue(); + var synonym = execution.getVariableLocalTyped("name").getValue(); - var accountId = accountProvider.synonymOf(synonym).map(Account::getId).getOrSupply(() -> null); + var accountId = + accountProvider.synonymOf(synonym).map(Account::getId).getOrSupply(() -> null); - execution.setVariableLocal("id", accountId); - } + execution.setVariableLocal("id", accountId); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountCreationDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountCreationDelegate.java index e9f0be95..6d9582fd 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountCreationDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountCreationDelegate.java @@ -7,8 +7,11 @@ import com.jongsoft.finance.providers.AccountProvider; import com.jongsoft.finance.security.CurrentUserProvider; import com.jongsoft.finance.serialized.AccountJson; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.bouncycastle.util.encoders.Hex; import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -28,64 +31,68 @@ @Singleton public class ProcessAccountCreationDelegate implements JavaDelegate, JavaBean { - private final CurrentUserProvider userProvider; - private final AccountProvider accountProvider; - private final StorageService storageService; - private final ProcessMapper mapper; + private final CurrentUserProvider userProvider; + private final AccountProvider accountProvider; + private final StorageService storageService; + private final ProcessMapper mapper; + + ProcessAccountCreationDelegate( + CurrentUserProvider userProvider, + AccountProvider accountProvider, + StorageService storageService, + ProcessMapper mapper) { + this.userProvider = userProvider; + this.accountProvider = accountProvider; + this.storageService = storageService; + this.mapper = mapper; + } - ProcessAccountCreationDelegate( - CurrentUserProvider userProvider, - AccountProvider accountProvider, - StorageService storageService, - ProcessMapper mapper) { - this.userProvider = userProvider; - this.accountProvider = accountProvider; - this.storageService = storageService; - this.mapper = mapper; - } + @Override + public void execute(DelegateExecution execution) { + var accountJson = mapper.readSafe( + execution.getVariableLocalTyped("account").getValue(), + AccountJson.class); - @Override - public void execute(DelegateExecution execution) { - var accountJson = mapper.readSafe( - execution.getVariableLocalTyped("account").getValue(), AccountJson.class); + log.debug( + "{}: Processing account creation from json '{}'", + execution.getCurrentActivityName(), + accountJson.getName()); - log.debug( - "{}: Processing account creation from json '{}'", - execution.getCurrentActivityName(), - accountJson.getName()); + accountProvider.lookup(accountJson.getName()).ifNotPresent(() -> { + userProvider + .currentUser() + .createAccount( + accountJson.getName(), + accountJson.getCurrency(), + accountJson.getType()); - accountProvider.lookup(accountJson.getName()).ifNotPresent(() -> { - userProvider - .currentUser() - .createAccount(accountJson.getName(), accountJson.getCurrency(), accountJson.getType()); + accountProvider.lookup(accountJson.getName()).ifPresent(account -> { + account.changeAccount( + handleEmptyAsNull(accountJson.getIban()), + handleEmptyAsNull(accountJson.getBic()), + handleEmptyAsNull(accountJson.getNumber())); + account.rename( + accountJson.getName(), + accountJson.getDescription(), + accountJson.getCurrency(), + accountJson.getType()); - accountProvider.lookup(accountJson.getName()).ifPresent(account -> { - account.changeAccount( - handleEmptyAsNull(accountJson.getIban()), - handleEmptyAsNull(accountJson.getBic()), - handleEmptyAsNull(accountJson.getNumber())); - account.rename( - accountJson.getName(), - accountJson.getDescription(), - accountJson.getCurrency(), - accountJson.getType()); + if (accountJson.getPeriodicity() != null) { + account.interest(accountJson.getInterest(), accountJson.getPeriodicity()); + } - if (accountJson.getPeriodicity() != null) { - account.interest(accountJson.getInterest(), accountJson.getPeriodicity()); - } + if (accountJson.getIcon() != null) { + account.registerIcon(storageService.store(Hex.decode(accountJson.getIcon()))); + } + }); + }); + } - if (accountJson.getIcon() != null) { - account.registerIcon(storageService.store(Hex.decode(accountJson.getIcon()))); + private String handleEmptyAsNull(String value) { + if (value != null && value.trim().length() > 0) { + return value; } - }); - }); - } - private String handleEmptyAsNull(String value) { - if (value != null && value.trim().length() > 0) { - return value; + return null; } - - return null; - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountLookupDelegate.java index 2b570df7..fd726806 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ProcessAccountLookupDelegate.java @@ -6,8 +6,11 @@ import com.jongsoft.finance.providers.AccountProvider; import com.jongsoft.lang.Control; import com.jongsoft.lang.control.Optional; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -31,48 +34,51 @@ @Singleton public class ProcessAccountLookupDelegate implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; - private final FilterFactory accountFilterFactory; + private final AccountProvider accountProvider; + private final FilterFactory accountFilterFactory; + + ProcessAccountLookupDelegate( + AccountProvider accountProvider, FilterFactory accountFilterFactory) { + this.accountProvider = accountProvider; + this.accountFilterFactory = accountFilterFactory; + } - ProcessAccountLookupDelegate( - AccountProvider accountProvider, FilterFactory accountFilterFactory) { - this.accountProvider = accountProvider; - this.accountFilterFactory = accountFilterFactory; - } + @Override + public void execute(DelegateExecution execution) { + log.debug( + "{}: Processing account lookup '{}' - {} [{}]", + execution.getCurrentActivityName(), + execution.getVariableLocal("name"), + execution.getVariableLocal("iban"), + execution.getVariableLocal("id")); - @Override - public void execute(DelegateExecution execution) { - log.debug( - "{}: Processing account lookup '{}' - {} [{}]", - execution.getCurrentActivityName(), - execution.getVariableLocal("name"), - execution.getVariableLocal("iban"), - execution.getVariableLocal("id")); + Optional matchedAccount = Control.Option(); + if (execution.hasVariableLocal("id") && execution.getVariableLocal("id") != null) { + matchedAccount = accountProvider.lookup((Long) execution.getVariableLocal("id")); + } - Optional matchedAccount = Control.Option(); - if (execution.hasVariableLocal("id") && execution.getVariableLocal("id") != null) { - matchedAccount = accountProvider.lookup((Long) execution.getVariableLocal("id")); - } + if (!matchedAccount.isPresent() && execution.hasVariableLocal("iban")) { + final String iban = (String) execution.getVariableLocal("iban"); + if (iban != null && !iban.trim().isEmpty()) { + matchedAccount = accountProvider + .lookup(accountFilterFactory.account().iban(iban, true)) + .content() + .first(ignored -> true); + } + } - if (!matchedAccount.isPresent() && execution.hasVariableLocal("iban")) { - final String iban = (String) execution.getVariableLocal("iban"); - if (iban != null && !iban.trim().isEmpty()) { - matchedAccount = accountProvider - .lookup(accountFilterFactory.account().iban(iban, true)) - .content() - .first(ignored -> true); - } - } + if (!matchedAccount.isPresent() && execution.hasVariableLocal("name")) { + final String accountName = (String) execution.getVariableLocal("name"); + if (accountName != null && !accountName.trim().isEmpty()) { + matchedAccount = accountProvider.lookup(accountName); + } + } - if (!matchedAccount.isPresent() && execution.hasVariableLocal("name")) { - final String accountName = (String) execution.getVariableLocal("name"); - if (accountName != null && !accountName.trim().isEmpty()) { - matchedAccount = accountProvider.lookup(accountName); - } + log.trace( + "{}: Processing account located {}", + execution.getCurrentActivityName(), + matchedAccount); + execution.setVariableLocal( + "id", matchedAccount.map(Account::getId).getOrSupply(() -> null)); } - - log.trace( - "{}: Processing account located {}", execution.getCurrentActivityName(), matchedAccount); - execution.setVariableLocal("id", matchedAccount.map(Account::getId).getOrSupply(() -> null)); - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ReconcileAccountDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ReconcileAccountDelegate.java index df608a2a..ce369b0b 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ReconcileAccountDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/account/ReconcileAccountDelegate.java @@ -8,15 +8,19 @@ import com.jongsoft.finance.messaging.commands.transaction.CreateTransactionCommand; import com.jongsoft.finance.messaging.handlers.TransactionCreationHandler; import com.jongsoft.finance.providers.AccountProvider; + import jakarta.inject.Singleton; -import java.math.BigDecimal; -import java.time.LocalDate; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.ObjectValue; import org.camunda.bpm.engine.variable.value.StringValue; +import java.math.BigDecimal; +import java.time.LocalDate; + /** * This delegate will create a reconciliation transaction into a selected account. This can be used * to correct missing funds in an account. @@ -34,42 +38,45 @@ @Singleton public class ReconcileAccountDelegate implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; - private final TransactionCreationHandler creationHandler; + private final AccountProvider accountProvider; + private final TransactionCreationHandler creationHandler; - ReconcileAccountDelegate( - AccountProvider accountProvider, TransactionCreationHandler creationHandler) { - this.accountProvider = accountProvider; - this.creationHandler = creationHandler; - } + ReconcileAccountDelegate( + AccountProvider accountProvider, TransactionCreationHandler creationHandler) { + this.accountProvider = accountProvider; + this.creationHandler = creationHandler; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var accountId = ((Number) execution.getVariableLocal("accountId")).longValue(); - var amount = execution.getVariableLocalTyped("amount").getValue(BigDecimal.class); - var isoBookDate = execution.getVariableLocalTyped("bookDate").getValue(); + @Override + public void execute(DelegateExecution execution) throws Exception { + var accountId = ((Number) execution.getVariableLocal("accountId")).longValue(); + var amount = + execution.getVariableLocalTyped("amount").getValue(BigDecimal.class); + var isoBookDate = + execution.getVariableLocalTyped("bookDate").getValue(); - var transactionDate = LocalDate.parse(isoBookDate).minusDays(1); - log.debug( - "{}: Reconciling account {} for book date {} with amount {}", - execution.getCurrentActivityName(), - accountId, - transactionDate, - amount); + var transactionDate = LocalDate.parse(isoBookDate).minusDays(1); + log.debug( + "{}: Reconciling account {} for book date {} with amount {}", + execution.getCurrentActivityName(), + accountId, + transactionDate, + amount); - Account toReconcile = accountProvider.lookup(accountId).get(); - Account reconcileAccount = accountProvider - .lookup(SystemAccountTypes.RECONCILE) - .getOrThrow(() -> StatusException.badRequest("Reconcile account not found")); + Account toReconcile = accountProvider.lookup(accountId).get(); + Account reconcileAccount = accountProvider + .lookup(SystemAccountTypes.RECONCILE) + .getOrThrow(() -> StatusException.badRequest("Reconcile account not found")); - Transaction.Type type = - amount.compareTo(BigDecimal.ZERO) >= 0 ? Transaction.Type.CREDIT : Transaction.Type.DEBIT; - Transaction transaction = toReconcile.createTransaction( - reconcileAccount, amount.abs().doubleValue(), type, t -> t.description( - "Reconcile transaction") - .currency(toReconcile.getCurrency()) - .date(transactionDate)); + Transaction.Type type = amount.compareTo(BigDecimal.ZERO) >= 0 + ? Transaction.Type.CREDIT + : Transaction.Type.DEBIT; + Transaction transaction = toReconcile.createTransaction( + reconcileAccount, amount.abs().doubleValue(), type, t -> t.description( + "Reconcile transaction") + .currency(toReconcile.getCurrency()) + .date(transactionDate)); - creationHandler.handleCreatedEvent(new CreateTransactionCommand(transaction)); - } + creationHandler.handleCreatedEvent(new CreateTransactionCommand(transaction)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetAnalysisDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetAnalysisDelegate.java index 5936182e..1838416e 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetAnalysisDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetAnalysisDelegate.java @@ -8,17 +8,21 @@ import com.jongsoft.finance.providers.SettingProvider; import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.lang.Collections; + import jakarta.inject.Singleton; -import java.math.BigDecimal; -import java.math.MathContext; -import java.math.RoundingMode; -import java.time.LocalDate; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.ObjectValue; import org.camunda.bpm.engine.variable.value.StringValue; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; + /** * This delegate analyzes the transactions recorded with a specific budget type in the past 3 months * to determine if the total amount spent is in line with the expected amount spent. If it deviates @@ -31,60 +35,64 @@ @Singleton public class ProcessBudgetAnalysisDelegate implements JavaDelegate, JavaBean { - private final TransactionProvider transactionProvider; - private final FilterFactory filterFactory; - private final SettingProvider settingProvider; - - ProcessBudgetAnalysisDelegate( - TransactionProvider transactionProvider, - FilterFactory filterFactory, - SettingProvider settingProvider) { - this.transactionProvider = transactionProvider; - this.filterFactory = filterFactory; - this.settingProvider = settingProvider; - } - - @Override - public void execute(DelegateExecution execution) { - var forExpense = - execution.getVariableLocalTyped("expense").getValue(Budget.Expense.class); - var runningDate = - LocalDate.parse(execution.getVariableLocalTyped("date").getValue()); - - log.debug("Running budget '{}' analysis for {}", forExpense.getName(), runningDate); - - runningDate = runningDate.minusMonths(1); - - var deviation = 0d; - var dateRange = DateUtils.forMonth(runningDate.getYear(), runningDate.getMonthValue()); - var budgetAnalysisMonths = settingProvider.getBudgetAnalysisMonths(); - var searchCommand = - filterFactory.transaction().expenses(Collections.List(new EntityRef(forExpense.getId()))); - - for (int i = budgetAnalysisMonths; i > 0; i--) { - var transactions = transactionProvider.lookup(searchCommand.range(dateRange)); - - var spentInMonth = transactions - .content() - .map(transaction -> transaction.computeAmount(transaction.computeTo())) - .sum() - .get(); - - deviation += forExpense.computeBudget() - spentInMonth; - dateRange = dateRange.previous(); + private final TransactionProvider transactionProvider; + private final FilterFactory filterFactory; + private final SettingProvider settingProvider; + + ProcessBudgetAnalysisDelegate( + TransactionProvider transactionProvider, + FilterFactory filterFactory, + SettingProvider settingProvider) { + this.transactionProvider = transactionProvider; + this.filterFactory = filterFactory; + this.settingProvider = settingProvider; } - var averageDeviation = BigDecimal.valueOf(deviation) - .divide(BigDecimal.valueOf(budgetAnalysisMonths), new MathContext(6, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP) - .doubleValue(); - if (Math.abs(averageDeviation) / forExpense.computeBudget() - > settingProvider.getMaximumBudgetDeviation()) { - execution.setVariableLocal("deviation", averageDeviation); - execution.setVariableLocal("deviates", true); - } else { - execution.setVariableLocal("deviates", false); - execution.setVariableLocal("deviation", 0); + @Override + public void execute(DelegateExecution execution) { + var forExpense = execution + .getVariableLocalTyped("expense") + .getValue(Budget.Expense.class); + var runningDate = LocalDate.parse( + execution.getVariableLocalTyped("date").getValue()); + + log.debug("Running budget '{}' analysis for {}", forExpense.getName(), runningDate); + + runningDate = runningDate.minusMonths(1); + + var deviation = 0d; + var dateRange = DateUtils.forMonth(runningDate.getYear(), runningDate.getMonthValue()); + var budgetAnalysisMonths = settingProvider.getBudgetAnalysisMonths(); + var searchCommand = filterFactory + .transaction() + .expenses(Collections.List(new EntityRef(forExpense.getId()))); + + for (int i = budgetAnalysisMonths; i > 0; i--) { + var transactions = transactionProvider.lookup(searchCommand.range(dateRange)); + + var spentInMonth = transactions + .content() + .map(transaction -> transaction.computeAmount(transaction.computeTo())) + .sum() + .get(); + + deviation += forExpense.computeBudget() - spentInMonth; + dateRange = dateRange.previous(); + } + + var averageDeviation = BigDecimal.valueOf(deviation) + .divide( + BigDecimal.valueOf(budgetAnalysisMonths), + new MathContext(6, RoundingMode.HALF_UP)) + .setScale(2, RoundingMode.HALF_UP) + .doubleValue(); + if (Math.abs(averageDeviation) / forExpense.computeBudget() + > settingProvider.getMaximumBudgetDeviation()) { + execution.setVariableLocal("deviation", averageDeviation); + execution.setVariableLocal("deviates", true); + } else { + execution.setVariableLocal("deviates", false); + execution.setVariableLocal("deviation", 0); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetCreateDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetCreateDelegate.java index 5c51850a..d4a32cf8 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetCreateDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetCreateDelegate.java @@ -9,8 +9,11 @@ import com.jongsoft.finance.providers.BudgetProvider; import com.jongsoft.finance.serialized.BudgetJson; import com.jongsoft.lang.Control; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; @@ -29,72 +32,75 @@ @Singleton public class ProcessBudgetCreateDelegate implements JavaDelegate, JavaBean { - private final BudgetProvider budgetProvider; - private final ProcessMapper mapper; + private final BudgetProvider budgetProvider; + private final ProcessMapper mapper; - ProcessBudgetCreateDelegate(BudgetProvider budgetProvider, ProcessMapper mapper) { - this.budgetProvider = budgetProvider; - this.mapper = mapper; - } + ProcessBudgetCreateDelegate(BudgetProvider budgetProvider, ProcessMapper mapper) { + this.budgetProvider = budgetProvider; + this.mapper = mapper; + } - @Override - public void execute(DelegateExecution execution) { - var budgetJson = mapper.readSafe( - execution.getVariableLocalTyped("budget").getValue(), BudgetJson.class); + @Override + public void execute(DelegateExecution execution) { + var budgetJson = mapper.readSafe( + execution.getVariableLocalTyped("budget").getValue(), + BudgetJson.class); - log.debug( - "{}: Processing budget creation from json for period '{}'", - execution.getCurrentActivityName(), - budgetJson.getStart()); + log.debug( + "{}: Processing budget creation from json for period '{}'", + execution.getCurrentActivityName(), + budgetJson.getStart()); - var start = budgetJson.getStart().withDayOfMonth(1); - var year = budgetJson.getStart().getYear(); - var month = budgetJson.getStart().getMonthValue(); + var start = budgetJson.getStart().withDayOfMonth(1); + var year = budgetJson.getStart().getYear(); + var month = budgetJson.getStart().getMonthValue(); - // create or update the budget period - var oldBudget = budgetProvider.lookup(year, month); - if (oldBudget.isPresent()) { - log.debug( - "{}: Budget period already exists for period '{}' with start '{}'", - execution.getCurrentActivityName(), - start, - oldBudget.get().getStart()); - // close the existing budget - EventBus.getBus().send(new CloseBudgetCommand(oldBudget.get().getId(), start)); - // create new budget - EventBus.getBus() - .send(new CreateBudgetCommand(Budget.builder() - .start(start) - .expectedIncome(budgetJson.getExpectedIncome()) - .expenses(oldBudget.get().getExpenses()) - .build())); - } else { - log.debug( - "{}: Creating new budget period for period '{}'", - execution.getCurrentActivityName(), - start); - EventBus.getBus() - .send(new CreateBudgetCommand(Budget.builder() - .start(start) - .expectedIncome(budgetJson.getExpectedIncome()) - .build())); - } + // create or update the budget period + var oldBudget = budgetProvider.lookup(year, month); + if (oldBudget.isPresent()) { + log.debug( + "{}: Budget period already exists for period '{}' with start '{}'", + execution.getCurrentActivityName(), + start, + oldBudget.get().getStart()); + // close the existing budget + EventBus.getBus().send(new CloseBudgetCommand(oldBudget.get().getId(), start)); + // create new budget + EventBus.getBus() + .send(new CreateBudgetCommand(Budget.builder() + .start(start) + .expectedIncome(budgetJson.getExpectedIncome()) + .expenses(oldBudget.get().getExpenses()) + .build())); + } else { + log.debug( + "{}: Creating new budget period for period '{}'", + execution.getCurrentActivityName(), + start); + EventBus.getBus() + .send(new CreateBudgetCommand(Budget.builder() + .start(start) + .expectedIncome(budgetJson.getExpectedIncome()) + .build())); + } - log.trace( - "{}: Budget period updated for period '{}'", - execution.getCurrentActivityName(), - budgetJson.getStart()); + log.trace( + "{}: Budget period updated for period '{}'", + execution.getCurrentActivityName(), + budgetJson.getStart()); - var budget = budgetProvider - .lookup(year, month) - .getOrThrow(() -> new IllegalStateException("Budget period not found for period " + start)); + var budget = budgetProvider + .lookup(year, month) + .getOrThrow(() -> + new IllegalStateException("Budget period not found for period " + start)); - budgetJson - .getExpenses() - // update or create the expenses - .forEach(e -> Control.Option(budget.determineExpense(e.getName())) - .ifPresent(currentExpense -> currentExpense.updateExpense(e.getUpperBound())) - .elseRun( - () -> budget.createExpense(e.getName(), e.getLowerBound(), e.getUpperBound()))); - } + budgetJson + .getExpenses() + // update or create the expenses + .forEach(e -> Control.Option(budget.determineExpense(e.getName())) + .ifPresent( + currentExpense -> currentExpense.updateExpense(e.getUpperBound())) + .elseRun(() -> budget.createExpense( + e.getName(), e.getLowerBound(), e.getUpperBound()))); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetLookupDelegate.java index 82a42a4d..78b603ba 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetLookupDelegate.java @@ -4,8 +4,11 @@ import com.jongsoft.finance.domain.user.Budget; import com.jongsoft.finance.factory.FilterFactory; import com.jongsoft.finance.providers.ExpenseProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -27,28 +30,29 @@ @Singleton public class ProcessBudgetLookupDelegate implements JavaDelegate, JavaBean { - private final FilterFactory filterFactory; - private final ExpenseProvider expenseProvider; - - ProcessBudgetLookupDelegate(FilterFactory filterFactory, ExpenseProvider expenseProvider) { - this.filterFactory = filterFactory; - this.expenseProvider = expenseProvider; - } - - @Override - public void execute(DelegateExecution execution) { - log.debug( - "{}: Processing budget lookup '{}'", - execution.getCurrentActivityName(), - execution.getVariableLocal("name")); - - var filter = filterFactory.expense().name((String) execution.getVariableLocal("name"), true); - var expense = expenseProvider.lookup(filter); - if (expense.total() == 0) { - throw new IllegalStateException( - "Budget cannot be found for name " + execution.getVariableLocal("name")); + private final FilterFactory filterFactory; + private final ExpenseProvider expenseProvider; + + ProcessBudgetLookupDelegate(FilterFactory filterFactory, ExpenseProvider expenseProvider) { + this.filterFactory = filterFactory; + this.expenseProvider = expenseProvider; } - execution.setVariable("budget", expense.content().head()); - } + @Override + public void execute(DelegateExecution execution) { + log.debug( + "{}: Processing budget lookup '{}'", + execution.getCurrentActivityName(), + execution.getVariableLocal("name")); + + var filter = + filterFactory.expense().name((String) execution.getVariableLocal("name"), true); + var expense = expenseProvider.lookup(filter); + if (expense.total() == 0) { + throw new IllegalStateException( + "Budget cannot be found for name " + execution.getVariableLocal("name")); + } + + execution.setVariable("budget", expense.content().head()); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetMonthSelect.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetMonthSelect.java index 03b830e5..a0fa3771 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetMonthSelect.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/budget/ProcessBudgetMonthSelect.java @@ -2,9 +2,12 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.providers.BudgetProvider; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -12,25 +15,25 @@ @Singleton public class ProcessBudgetMonthSelect implements JavaDelegate, JavaBean { - private final BudgetProvider budgetProvider; + private final BudgetProvider budgetProvider; - @Inject - public ProcessBudgetMonthSelect(BudgetProvider budgetProvider) { - this.budgetProvider = budgetProvider; - } + @Inject + public ProcessBudgetMonthSelect(BudgetProvider budgetProvider) { + this.budgetProvider = budgetProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var year = Integer.parseInt(execution.getVariableLocal("year").toString()); - var month = Integer.parseInt(execution.getVariableLocal("month").toString()); + @Override + public void execute(DelegateExecution execution) throws Exception { + var year = Integer.parseInt(execution.getVariableLocal("year").toString()); + var month = Integer.parseInt(execution.getVariableLocal("month").toString()); - log.debug("Processing budget month select for {}-{}", year, month); + log.debug("Processing budget month select for {}-{}", year, month); - budgetProvider - .lookup(year, month) - .ifPresent( - budget -> execution.setVariable("expenses", budget.getExpenses().toJava())) - .elseThrow(() -> new IllegalStateException( - "Budget cannot be found for year " + year + " and month " + month)); - } + budgetProvider + .lookup(year, month) + .ifPresent(budget -> + execution.setVariable("expenses", budget.getExpenses().toJava())) + .elseThrow(() -> new IllegalStateException( + "Budget cannot be found for year " + year + " and month " + month)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCategoryLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCategoryLookupDelegate.java index ed2704ed..d49de2b8 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCategoryLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCategoryLookupDelegate.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.domain.user.Category; import com.jongsoft.finance.providers.CategoryProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -28,29 +31,30 @@ @Singleton public class ProcessCategoryLookupDelegate implements JavaDelegate, JavaBean { - private final CategoryProvider categoryProvider; - - ProcessCategoryLookupDelegate(CategoryProvider categoryProvider) { - this.categoryProvider = categoryProvider; - } + private final CategoryProvider categoryProvider; - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing category lookup '{}'", - execution.getCurrentActivityName(), - execution.getVariableLocal("name")); - - final Category category; - if (execution.hasVariableLocal("name")) { - var label = (String) execution.getVariableLocal("name"); - - category = categoryProvider.lookup(label).get(); - } else { - category = - categoryProvider.lookup((Long) execution.getVariableLocal("id")).get(); + ProcessCategoryLookupDelegate(CategoryProvider categoryProvider) { + this.categoryProvider = categoryProvider; } - execution.setVariable("category", category); - } + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing category lookup '{}'", + execution.getCurrentActivityName(), + execution.getVariableLocal("name")); + + final Category category; + if (execution.hasVariableLocal("name")) { + var label = (String) execution.getVariableLocal("name"); + + category = categoryProvider.lookup(label).get(); + } else { + category = categoryProvider + .lookup((Long) execution.getVariableLocal("id")) + .get(); + } + + execution.setVariable("category", category); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCreateCategoryDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCreateCategoryDelegate.java index 0987dc00..0c730af8 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCreateCategoryDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/category/ProcessCreateCategoryDelegate.java @@ -6,8 +6,11 @@ import com.jongsoft.finance.providers.CategoryProvider; import com.jongsoft.finance.security.CurrentUserProvider; import com.jongsoft.finance.serialized.CategoryJson; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; @@ -26,36 +29,37 @@ @Singleton public class ProcessCreateCategoryDelegate implements JavaDelegate, JavaBean { - private final CurrentUserProvider currentUserProvider; - private final CategoryProvider categoryProvider; - private final ProcessMapper mapper; - - ProcessCreateCategoryDelegate( - CurrentUserProvider currentUserProvider, - CategoryProvider categoryProvider, - ProcessMapper mapper) { - this.currentUserProvider = currentUserProvider; - this.categoryProvider = categoryProvider; - this.mapper = mapper; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - var categoryJson = mapper.readSafe( - execution.getVariableLocalTyped("category").getValue(), CategoryJson.class); - - log.debug( - "{}: Processing category creation from json '{}'", - execution.getCurrentActivityName(), - categoryJson.getLabel()); - - categoryProvider.lookup(categoryJson.getLabel()).ifNotPresent(() -> { - currentUserProvider.currentUser().createCategory(categoryJson.getLabel()); - - categoryProvider - .lookup(categoryJson.getLabel()) - .ifPresent( - category -> category.rename(categoryJson.getLabel(), categoryJson.getDescription())); - }); - } + private final CurrentUserProvider currentUserProvider; + private final CategoryProvider categoryProvider; + private final ProcessMapper mapper; + + ProcessCreateCategoryDelegate( + CurrentUserProvider currentUserProvider, + CategoryProvider categoryProvider, + ProcessMapper mapper) { + this.currentUserProvider = currentUserProvider; + this.categoryProvider = categoryProvider; + this.mapper = mapper; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + var categoryJson = mapper.readSafe( + execution.getVariableLocalTyped("category").getValue(), + CategoryJson.class); + + log.debug( + "{}: Processing category creation from json '{}'", + execution.getCurrentActivityName(), + categoryJson.getLabel()); + + categoryProvider.lookup(categoryJson.getLabel()).ifNotPresent(() -> { + currentUserProvider.currentUser().createCategory(categoryJson.getLabel()); + + categoryProvider + .lookup(categoryJson.getLabel()) + .ifPresent(category -> category.rename( + categoryJson.getLabel(), categoryJson.getDescription())); + }); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractCreateDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractCreateDelegate.java index 7dc093d9..46b74d89 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractCreateDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractCreateDelegate.java @@ -8,73 +8,79 @@ import com.jongsoft.finance.providers.AccountProvider; import com.jongsoft.finance.providers.ContractProvider; import com.jongsoft.finance.serialized.ContractJson; + import jakarta.inject.Singleton; -import java.util.function.Function; + import lombok.extern.slf4j.Slf4j; + import org.bouncycastle.util.encoders.Hex; import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; +import java.util.function.Function; + @Slf4j @Singleton public class ProcessContractCreateDelegate implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; - private final ContractProvider contractProvider; - private final StorageService storageService; - private final ProcessMapper mapper; + private final AccountProvider accountProvider; + private final ContractProvider contractProvider; + private final StorageService storageService; + private final ProcessMapper mapper; - ProcessContractCreateDelegate( - AccountProvider accountProvider, - ContractProvider contractProvider, - StorageService storageService, - ProcessMapper mapper) { - this.accountProvider = accountProvider; - this.contractProvider = contractProvider; - this.storageService = storageService; - this.mapper = mapper; - } + ProcessContractCreateDelegate( + AccountProvider accountProvider, + ContractProvider contractProvider, + StorageService storageService, + ProcessMapper mapper) { + this.accountProvider = accountProvider; + this.contractProvider = contractProvider; + this.storageService = storageService; + this.mapper = mapper; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var contractJson = mapper.readSafe( - execution.getVariableLocalTyped("contract").getValue(), ContractJson.class); + @Override + public void execute(DelegateExecution execution) throws Exception { + var contractJson = mapper.readSafe( + execution.getVariableLocalTyped("contract").getValue(), + ContractJson.class); - log.debug( - "{}: Processing contract creation from json '{}'", - execution.getCurrentActivityName(), - contractJson.getName()); + log.debug( + "{}: Processing contract creation from json '{}'", + execution.getCurrentActivityName(), + contractJson.getName()); - contractProvider - .lookup(contractJson.getName()) - .ifNotPresent(() -> createContract(contractJson)); - } + contractProvider + .lookup(contractJson.getName()) + .ifNotPresent(() -> createContract(contractJson)); + } - private void createContract(ContractJson contractJson) { - accountProvider - .lookup(contractJson.getCompany()) - .map(createContractForAccount(contractJson)) - .ifPresent(ignored -> adjustContract(contractJson)); - } + private void createContract(ContractJson contractJson) { + accountProvider + .lookup(contractJson.getCompany()) + .map(createContractForAccount(contractJson)) + .ifPresent(ignored -> adjustContract(contractJson)); + } - private Function createContractForAccount(ContractJson contractJson) { - return account -> account.createContract( - contractJson.getName(), - contractJson.getDescription(), - contractJson.getStart(), - contractJson.getEnd()); - } + private Function createContractForAccount(ContractJson contractJson) { + return account -> account.createContract( + contractJson.getName(), + contractJson.getDescription(), + contractJson.getStart(), + contractJson.getEnd()); + } - private void adjustContract(ContractJson contractJson) { - contractProvider.lookup(contractJson.getName()).ifPresent(contract -> { - if (contractJson.getContract() != null) { - contract.registerUpload(storageService.store(Hex.decode(contractJson.getContract()))); - } + private void adjustContract(ContractJson contractJson) { + contractProvider.lookup(contractJson.getName()).ifPresent(contract -> { + if (contractJson.getContract() != null) { + contract.registerUpload( + storageService.store(Hex.decode(contractJson.getContract()))); + } - if (contractJson.isTerminated()) { - contract.terminate(); - } - }); - } + if (contractJson.isTerminated()) { + contract.terminate(); + } + }); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractLookupDelegate.java index 213d0eee..fde8bc60 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/contract/ProcessContractLookupDelegate.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.domain.account.Contract; import com.jongsoft.finance.providers.ContractProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -12,30 +15,31 @@ @Singleton public class ProcessContractLookupDelegate implements JavaDelegate, JavaBean { - private final ContractProvider contractProvider; - - ProcessContractLookupDelegate(ContractProvider contractProvider) { - this.contractProvider = contractProvider; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing contract lookup '{}'", - execution.getCurrentActivityName(), - execution.getVariableLocal("name")); - - final Contract contract; - if (execution.hasVariableLocal("name")) { - var name = (String) execution.getVariableLocal("name"); - contract = contractProvider - .lookup(name) - .getOrSupply(() -> Contract.builder().name(name).build()); - } else { - contract = - contractProvider.lookup((Long) execution.getVariableLocal("id")).getOrSupply(() -> null); + private final ContractProvider contractProvider; + + ProcessContractLookupDelegate(ContractProvider contractProvider) { + this.contractProvider = contractProvider; } - execution.setVariable("contract", contract); - } + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing contract lookup '{}'", + execution.getCurrentActivityName(), + execution.getVariableLocal("name")); + + final Contract contract; + if (execution.hasVariableLocal("name")) { + var name = (String) execution.getVariableLocal("name"); + contract = contractProvider + .lookup(name) + .getOrSupply(() -> Contract.builder().name(name).build()); + } else { + contract = contractProvider + .lookup((Long) execution.getVariableLocal("id")) + .getOrSupply(() -> null); + } + + execution.setVariable("contract", contract); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AccountRuleDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AccountRuleDelegate.java index abfde887..781ffee2 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AccountRuleDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AccountRuleDelegate.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.domain.account.Account; import com.jongsoft.finance.rule.RuleDataSet; import com.jongsoft.finance.rule.RuleEngine; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -15,33 +18,33 @@ @Singleton public class AccountRuleDelegate implements JavaDelegate, JavaBean { - private final RuleEngine ruleEngine; - - @Inject - public AccountRuleDelegate(RuleEngine ruleEngine) { - this.ruleEngine = ruleEngine; - } - - public void execute(DelegateExecution execution) throws Exception { - var name = (String) execution.getVariable("name"); - var description = (String) execution.getVariable("description"); - - log.debug( - "{}: Locating account using rule system '{}' - {}", - execution.getCurrentActivityName(), - name, - description); - - var inputSet = new RuleDataSet(); - inputSet.put(RuleColumn.TO_ACCOUNT, name); - inputSet.put(RuleColumn.DESCRIPTION, description); - - var outputSet = ruleEngine.run(inputSet); - if (outputSet.containsKey(RuleColumn.TO_ACCOUNT)) { - var account = outputSet.getCasted(RuleColumn.TO_ACCOUNT); - execution.setVariableLocal("accountId", account.getId()); - } else { - execution.setVariableLocal("accountId", null); + private final RuleEngine ruleEngine; + + @Inject + public AccountRuleDelegate(RuleEngine ruleEngine) { + this.ruleEngine = ruleEngine; + } + + public void execute(DelegateExecution execution) throws Exception { + var name = (String) execution.getVariable("name"); + var description = (String) execution.getVariable("description"); + + log.debug( + "{}: Locating account using rule system '{}' - {}", + execution.getCurrentActivityName(), + name, + description); + + var inputSet = new RuleDataSet(); + inputSet.put(RuleColumn.TO_ACCOUNT, name); + inputSet.put(RuleColumn.DESCRIPTION, description); + + var outputSet = ruleEngine.run(inputSet); + if (outputSet.containsKey(RuleColumn.TO_ACCOUNT)) { + var account = outputSet.getCasted(RuleColumn.TO_ACCOUNT); + execution.setVariableLocal("accountId", account.getId()); + } else { + execution.setVariableLocal("accountId", null); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AddToAccountMapping.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AddToAccountMapping.java index 3fa99c9e..7a73ef02 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AddToAccountMapping.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/AddToAccountMapping.java @@ -1,33 +1,37 @@ package com.jongsoft.finance.bpmn.delegate.importer; import com.jongsoft.finance.core.JavaBean; + import jakarta.inject.Singleton; -import java.util.Collection; -import java.util.HashSet; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; +import java.util.Collection; +import java.util.HashSet; + @Slf4j @Singleton public class AddToAccountMapping implements JavaDelegate, JavaBean { - @Override - public void execute(DelegateExecution execution) throws Exception { - var accountName = (String) execution.getVariableLocal("name"); - var accountId = (Number) execution.getVariableLocal("accountId"); - - log.debug( - "{}: Adding account mapping for '{}' with id {}.", - execution.getCurrentActivityName(), - accountName, - accountId); - - @SuppressWarnings("unchecked") - var mappings = - new HashSet<>((Collection) execution.getVariable("accountMappings")); - mappings.removeIf(mapping -> mapping.getName().equals(accountName)); - mappings.add(new ExtractionMapping(accountName, accountId.longValue())); - - execution.setVariable("accountMappings", mappings); - } + @Override + public void execute(DelegateExecution execution) throws Exception { + var accountName = (String) execution.getVariableLocal("name"); + var accountId = (Number) execution.getVariableLocal("accountId"); + + log.debug( + "{}: Adding account mapping for '{}' with id {}.", + execution.getCurrentActivityName(), + accountName, + accountId); + + @SuppressWarnings("unchecked") + var mappings = new HashSet<>( + (Collection) execution.getVariable("accountMappings")); + mappings.removeIf(mapping -> mapping.getName().equals(accountName)); + mappings.add(new ExtractionMapping(accountName, accountId.longValue())); + + execution.setVariable("accountMappings", mappings); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ExtractionMapping.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ExtractionMapping.java index 02a52a55..dd2daf03 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ExtractionMapping.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ExtractionMapping.java @@ -1,28 +1,31 @@ package com.jongsoft.finance.bpmn.delegate.importer; import com.jongsoft.finance.ProcessVariable; + import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; + import lombok.EqualsAndHashCode; +import java.io.Serializable; + /** Represents a mapping between an account name and an account ID. */ @Serdeable @EqualsAndHashCode(of = {"name"}) public class ExtractionMapping implements ProcessVariable, Serializable { - private final String name; - private final Long accountId; + private final String name; + private final Long accountId; - public ExtractionMapping(String name, Long accountId) { - this.name = name; - this.accountId = accountId; - } + public ExtractionMapping(String name, Long accountId) { + this.name = name; + this.accountId = accountId; + } - public String getName() { - return name; - } + public String getName() { + return name; + } - public Long getAccountId() { - return accountId; - } + public Long getAccountId() { + return accountId; + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportAccountExtractorDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportAccountExtractorDelegate.java index 1cc29379..df3d59fa 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportAccountExtractorDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportAccountExtractorDelegate.java @@ -1,13 +1,16 @@ package com.jongsoft.finance.bpmn.delegate.importer; import com.jongsoft.finance.core.JavaBean; -import java.util.HashSet; -import java.util.Set; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; +import java.util.HashSet; +import java.util.Set; + /** * Extracts account mappings from the import transaction. * @@ -20,26 +23,26 @@ @Slf4j public class ImportAccountExtractorDelegate implements JavaDelegate, JavaBean { - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Creating import transaction account mapping '{}' - {}", - execution.getCurrentActivityName(), - execution.getVariableLocal("name"), - execution.getVariableLocal("accountId")); - - var mapping = new ExtractionMapping( - execution.getVariableLocalTyped("name").getValue(), - (Long) execution.getVariableLocal("accountId")); - - getAccountMappings(execution).add(mapping); - } - - @SuppressWarnings("unchecked") - private Set getAccountMappings(DelegateExecution execution) { - if (!execution.hasVariable("accountMappings")) { - execution.setVariable("accountMappings", new HashSet<>()); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Creating import transaction account mapping '{}' - {}", + execution.getCurrentActivityName(), + execution.getVariableLocal("name"), + execution.getVariableLocal("accountId")); + + var mapping = new ExtractionMapping( + execution.getVariableLocalTyped("name").getValue(), + (Long) execution.getVariableLocal("accountId")); + + getAccountMappings(execution).add(mapping); + } + + @SuppressWarnings("unchecked") + private Set getAccountMappings(DelegateExecution execution) { + if (!execution.hasVariable("accountMappings")) { + execution.setVariable("accountMappings", new HashSet<>()); + } + return (Set) execution.getVariable("accountMappings"); } - return (Set) execution.getVariable("accountMappings"); - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportFinishedDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportFinishedDelegate.java index cb3dd26b..e32a3a0a 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportFinishedDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ImportFinishedDelegate.java @@ -3,36 +3,40 @@ import com.jongsoft.finance.StorageService; import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.providers.ImportProvider; + import jakarta.inject.Singleton; -import java.util.Date; -import java.util.List; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; +import java.util.Date; +import java.util.List; + @Slf4j @Singleton public class ImportFinishedDelegate implements JavaDelegate, JavaBean { - private final StorageService storageService; - private final ImportProvider importProvider; + private final StorageService storageService; + private final ImportProvider importProvider; - ImportFinishedDelegate(StorageService storageService, ImportProvider importProvider) { - this.storageService = storageService; - this.importProvider = importProvider; - } + ImportFinishedDelegate(StorageService storageService, ImportProvider importProvider) { + this.storageService = storageService; + this.importProvider = importProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var slug = execution.getVariableLocalTyped("importSlug").getValue(); - @SuppressWarnings("unchecked") - var importTokens = (List) execution.getVariable("storageToken"); + @Override + public void execute(DelegateExecution execution) throws Exception { + var slug = execution.getVariableLocalTyped("importSlug").getValue(); + @SuppressWarnings("unchecked") + var importTokens = (List) execution.getVariable("storageToken"); - log.debug("{}: Finalizing importer job {}", execution.getCurrentActivityName(), slug); + log.debug("{}: Finalizing importer job {}", execution.getCurrentActivityName(), slug); - importProvider.lookup(slug).ifPresent(entity -> entity.finish(new Date())); + importProvider.lookup(slug).ifPresent(entity -> entity.finish(new Date())); - importTokens.forEach(storageService::remove); - } + importTokens.forEach(storageService::remove); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LoadImporterConfiguration.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LoadImporterConfiguration.java index 39546d64..2c2cd71d 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LoadImporterConfiguration.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LoadImporterConfiguration.java @@ -4,12 +4,16 @@ import com.jongsoft.finance.importer.ImporterProvider; import com.jongsoft.finance.providers.ImportProvider; import com.jongsoft.finance.serialized.ImportJobSettings; + import jakarta.inject.Singleton; -import java.util.List; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; +import java.util.List; + /** * Loads the importer configuration for the given batch import. The importer configuration is loaded * from the importer provider that matches the import type and is stored in the {@code importConfig} @@ -19,41 +23,44 @@ @Singleton public class LoadImporterConfiguration implements JavaDelegate, JavaBean { - private final ImportProvider importProvider; - private final List> importerProvider; - - public LoadImporterConfiguration( - ImportProvider importProvider, List> importerProvider) { - this.importProvider = importProvider; - this.importerProvider = importerProvider; - } - - @Override - public void execute(DelegateExecution delegateExecution) throws Exception { - var batchImportSlug = (String) delegateExecution.getVariableLocal("batchImportSlug"); - - log.debug( - "{}: Loading default import configuration for {}.", - delegateExecution.getCurrentActivityName(), - batchImportSlug); - - var importJob = importProvider - .lookup(batchImportSlug) - .getOrThrow(() -> - new IllegalStateException("Cannot find batch import with slug " + batchImportSlug)); - - importerProvider.stream() - .filter(importer -> - importer.getImporterType().equalsIgnoreCase(importJob.getConfig().getType())) - .findFirst() - .ifPresentOrElse( - importer -> delegateExecution.setVariableLocal( - "importConfig", - new ImportJobSettings( - importer.loadConfiguration(importJob.getConfig()), false, false, null)), - () -> { - throw new IllegalStateException( - "Cannot find importer for type " + importJob.getConfig().getType()); - }); - } + private final ImportProvider importProvider; + private final List> importerProvider; + + public LoadImporterConfiguration( + ImportProvider importProvider, List> importerProvider) { + this.importProvider = importProvider; + this.importerProvider = importerProvider; + } + + @Override + public void execute(DelegateExecution delegateExecution) throws Exception { + var batchImportSlug = (String) delegateExecution.getVariableLocal("batchImportSlug"); + + log.debug( + "{}: Loading default import configuration for {}.", + delegateExecution.getCurrentActivityName(), + batchImportSlug); + + var importJob = importProvider + .lookup(batchImportSlug) + .getOrThrow(() -> new IllegalStateException( + "Cannot find batch import with slug " + batchImportSlug)); + + importerProvider.stream() + .filter(importer -> importer.getImporterType() + .equalsIgnoreCase(importJob.getConfig().getType())) + .findFirst() + .ifPresentOrElse( + importer -> delegateExecution.setVariableLocal( + "importConfig", + new ImportJobSettings( + importer.loadConfiguration(importJob.getConfig()), + false, + false, + null)), + () -> { + throw new IllegalStateException("Cannot find importer for type " + + importJob.getConfig().getType()); + }); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LocateAccountInMapping.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LocateAccountInMapping.java index 6f3da5ae..d755c936 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LocateAccountInMapping.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/LocateAccountInMapping.java @@ -2,12 +2,16 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.providers.AccountProvider; + import jakarta.inject.Singleton; -import java.util.Collection; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; +import java.util.Collection; + /** * Locates an account in a mapping and sets the account ID as a process variable. * @@ -22,48 +26,49 @@ @Singleton public class LocateAccountInMapping implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; - - public LocateAccountInMapping(AccountProvider accountProvider) { - this.accountProvider = accountProvider; - } + private final AccountProvider accountProvider; - @Override - public void execute(DelegateExecution delegateExecution) throws Exception { - var accountName = (String) delegateExecution.getVariableLocal("name"); - @SuppressWarnings("unchecked") - var mappings = (Collection) delegateExecution.getVariable("accountMappings"); + public LocateAccountInMapping(AccountProvider accountProvider) { + this.accountProvider = accountProvider; + } - log.debug( - "{}: Locating account mapping for {}.", - delegateExecution.getCurrentActivityName(), - accountName); + @Override + public void execute(DelegateExecution delegateExecution) throws Exception { + var accountName = (String) delegateExecution.getVariableLocal("name"); + @SuppressWarnings("unchecked") + var mappings = + (Collection) delegateExecution.getVariable("accountMappings"); - var accountId = mappings.stream() - .filter(mapping -> mapping.getName().equals(accountName)) - .findFirst() - .map(ExtractionMapping::getAccountId) - .orElse(null); + log.debug( + "{}: Locating account mapping for {}.", + delegateExecution.getCurrentActivityName(), + accountName); - determineSynonym(accountName, accountId); + var accountId = mappings.stream() + .filter(mapping -> mapping.getName().equals(accountName)) + .findFirst() + .map(ExtractionMapping::getAccountId) + .orElse(null); - delegateExecution.setVariableLocal("accountId", accountId); - } + determineSynonym(accountName, accountId); - private void determineSynonym(String accountName, Long accountId) { - if (accountId == null) { - return; + delegateExecution.setVariableLocal("accountId", accountId); } - var account = accountProvider - .lookup(accountId) - .getOrThrow(() -> new IllegalStateException("Account not found: " + accountId)); - if (!account.getName().equals(accountName)) { - log.info( - "Account name '{}' does not match the account name in the mapping '{}'.", - account.getName(), - accountName); - account.registerSynonym(accountName); + private void determineSynonym(String accountName, Long accountId) { + if (accountId == null) { + return; + } + + var account = accountProvider + .lookup(accountId) + .getOrThrow(() -> new IllegalStateException("Account not found: " + accountId)); + if (!account.getName().equals(accountName)) { + log.info( + "Account name '{}' does not match the account name in the mapping '{}'.", + account.getName(), + accountName); + account.registerSynonym(accountName); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionFromStorage.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionFromStorage.java index 08c36b12..410ed71b 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionFromStorage.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionFromStorage.java @@ -4,12 +4,15 @@ import com.jongsoft.finance.StorageService; import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.importer.api.TransactionDTO; + import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.nio.charset.StandardCharsets; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; +import java.nio.charset.StandardCharsets; + /** * Reads a transaction from storage. * @@ -20,25 +23,25 @@ @Singleton public class ReadTransactionFromStorage implements JavaDelegate, JavaBean { - private final StorageService storageService; - private final ProcessMapper processMapper; + private final StorageService storageService; + private final ProcessMapper processMapper; - @Inject - public ReadTransactionFromStorage(StorageService storageService, ProcessMapper processMapper) { - this.storageService = storageService; - this.processMapper = processMapper; - } + @Inject + public ReadTransactionFromStorage(StorageService storageService, ProcessMapper processMapper) { + this.storageService = storageService; + this.processMapper = processMapper; + } - @Override - public void execute(DelegateExecution delegateExecution) throws Exception { - var storageToken = (String) delegateExecution.getVariableLocal("storageToken"); + @Override + public void execute(DelegateExecution delegateExecution) throws Exception { + var storageToken = (String) delegateExecution.getVariableLocal("storageToken"); - var transaction = storageService - .read(storageToken) - .map(byteArray -> new String(byteArray, StandardCharsets.UTF_8)) - .map(json -> processMapper.readSafe(json, TransactionDTO.class)) - .getOrThrow(() -> new RuntimeException("Failed to read transaction from storage")); + var transaction = storageService + .read(storageToken) + .map(byteArray -> new String(byteArray, StandardCharsets.UTF_8)) + .map(json -> processMapper.readSafe(json, TransactionDTO.class)) + .getOrThrow(() -> new RuntimeException("Failed to read transaction from storage")); - delegateExecution.setVariableLocal("transaction", transaction); - } + delegateExecution.setVariableLocal("transaction", transaction); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionLogDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionLogDelegate.java index 9c505f9e..d53e8c4d 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionLogDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/importer/ReadTransactionLogDelegate.java @@ -8,16 +8,20 @@ import com.jongsoft.finance.providers.ImportProvider; import com.jongsoft.finance.serialized.ExtractedAccountLookup; import com.jongsoft.finance.serialized.ImportJobSettings; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + +import org.camunda.bpm.engine.delegate.DelegateExecution; +import org.camunda.bpm.engine.delegate.JavaDelegate; + import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; -import lombok.extern.slf4j.Slf4j; -import org.camunda.bpm.engine.delegate.DelegateExecution; -import org.camunda.bpm.engine.delegate.JavaDelegate; /** * Delegate to trigger the actual {@link ImporterProvider} to start the job of fetching and @@ -37,69 +41,69 @@ @Singleton public class ReadTransactionLogDelegate implements JavaDelegate, JavaBean { - private final List> importerProviders; - private final ImportProvider importProvider; - private final StorageService storageService; - private final ProcessMapper mapper; + private final List> importerProviders; + private final ImportProvider importProvider; + private final StorageService storageService; + private final ProcessMapper mapper; - @Inject - public ReadTransactionLogDelegate( - List> importerProviders, - ImportProvider importProvider, - StorageService storageService, - ProcessMapper mapper) { - this.importerProviders = importerProviders; - this.importProvider = importProvider; - this.storageService = storageService; - this.mapper = mapper; - } + @Inject + public ReadTransactionLogDelegate( + List> importerProviders, + ImportProvider importProvider, + StorageService storageService, + ProcessMapper mapper) { + this.importerProviders = importerProviders; + this.importProvider = importProvider; + this.storageService = storageService; + this.mapper = mapper; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var batchImportSlug = (String) execution.getVariableLocal("batchImportSlug"); - var importJobSettings = (ImportJobSettings) execution.getVariable("importConfig"); - log.debug( - "{}: Processing transaction import {}", - execution.getCurrentActivityName(), - batchImportSlug); + @Override + public void execute(DelegateExecution execution) throws Exception { + var batchImportSlug = (String) execution.getVariableLocal("batchImportSlug"); + var importJobSettings = (ImportJobSettings) execution.getVariable("importConfig"); + log.debug( + "{}: Processing transaction import {}", + execution.getCurrentActivityName(), + batchImportSlug); - var importJob = importProvider.lookup(batchImportSlug).get(); - List storageTokens = new ArrayList<>(); - Set locatable = new HashSet<>(); + var importJob = importProvider.lookup(batchImportSlug).get(); + List storageTokens = new ArrayList<>(); + Set locatable = new HashSet<>(); - importerProviders.stream() - .filter(provider -> provider.supports(importJobSettings.importConfiguration())) - .findFirst() - .ifPresentOrElse( - provider -> provider.readTransactions( - transactionDTO -> { - // write the serialized transaction to storage and store - // the token - var serialized = - mapper.writeSafe(transactionDTO).getBytes(StandardCharsets.UTF_8); - storageTokens.add(storageService.store(serialized)); + importerProviders.stream() + .filter(provider -> provider.supports(importJobSettings.importConfiguration())) + .findFirst() + .ifPresentOrElse( + provider -> provider.readTransactions( + transactionDTO -> { + // write the serialized transaction to storage and store + // the token + var serialized = mapper.writeSafe(transactionDTO) + .getBytes(StandardCharsets.UTF_8); + storageTokens.add(storageService.store(serialized)); - // write the extracted account lookup to the locatable - // set - locatable.add(new ExtractedAccountLookup( - transactionDTO.opposingName(), - transactionDTO.opposingIBAN(), - transactionDTO.description())); - }, - importJobSettings.importConfiguration(), - importJob), - () -> log.warn( - "No importer provider found for configuration: {}", - importJobSettings.importConfiguration())); + // write the extracted account lookup to the locatable + // set + locatable.add(new ExtractedAccountLookup( + transactionDTO.opposingName(), + transactionDTO.opposingIBAN(), + transactionDTO.description())); + }, + importJobSettings.importConfiguration(), + importJob), + () -> log.warn( + "No importer provider found for configuration: {}", + importJobSettings.importConfiguration())); - if (locatable.isEmpty()) { - log.warn("No accounts found for import job {}", batchImportSlug); - } + if (locatable.isEmpty()) { + log.warn("No accounts found for import job {}", batchImportSlug); + } - execution.setVariableLocal("locatable", locatable); - execution.setVariableLocal("generateAccounts", importJobSettings.generateAccounts()); - execution.setVariableLocal("applyRules", importJobSettings.applyRules()); - execution.setVariableLocal("targetAccountId", importJobSettings.accountId()); - execution.setVariableLocal("storageTokens", storageTokens); - } + execution.setVariableLocal("locatable", locatable); + execution.setVariableLocal("generateAccounts", importJobSettings.generateAccounts()); + execution.setVariableLocal("applyRules", importJobSettings.applyRules()); + execution.setVariableLocal("targetAccountId", importJobSettings.accountId()); + execution.setVariableLocal("storageTokens", storageTokens); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ExtractorRuleRunDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ExtractorRuleRunDelegate.java index 1e94c9ea..499fd466 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ExtractorRuleRunDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ExtractorRuleRunDelegate.java @@ -5,7 +5,9 @@ import com.jongsoft.finance.rule.RuleDataSet; import com.jongsoft.finance.rule.RuleEngine; import com.jongsoft.lang.collection.tuple.Triplet; + import jakarta.inject.Singleton; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -13,27 +15,27 @@ @Singleton public class ExtractorRuleRunDelegate implements JavaDelegate { - private final RuleEngine ruleEngine; + private final RuleEngine ruleEngine; - ExtractorRuleRunDelegate(RuleEngine ruleEngine) { - this.ruleEngine = ruleEngine; - } + ExtractorRuleRunDelegate(RuleEngine ruleEngine) { + this.ruleEngine = ruleEngine; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var nameIbanPair = - (Triplet) execution.getVariableLocal("accountLookup"); + @Override + public void execute(DelegateExecution execution) throws Exception { + var nameIbanPair = + (Triplet) execution.getVariableLocal("accountLookup"); - var inputSet = new RuleDataSet(); - inputSet.put(RuleColumn.TO_ACCOUNT, nameIbanPair.getFirst()); - inputSet.put(RuleColumn.DESCRIPTION, nameIbanPair.getThird()); + var inputSet = new RuleDataSet(); + inputSet.put(RuleColumn.TO_ACCOUNT, nameIbanPair.getFirst()); + inputSet.put(RuleColumn.DESCRIPTION, nameIbanPair.getThird()); - var outputSet = ruleEngine.run(inputSet); - if (outputSet.containsKey(RuleColumn.TO_ACCOUNT)) { - var account = outputSet.getCasted(RuleColumn.TO_ACCOUNT); - execution.setVariableLocal("id", account.getId()); - } else { - execution.setVariableLocal("id", null); + var outputSet = ruleEngine.run(inputSet); + if (outputSet.containsKey(RuleColumn.TO_ACCOUNT)) { + var account = outputSet.getCasted(RuleColumn.TO_ACCOUNT); + execution.setVariableLocal("id", account.getId()); + } else { + execution.setVariableLocal("id", null); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ParseTransactionRuleDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ParseTransactionRuleDelegate.java index cb45636d..90fafdc5 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ParseTransactionRuleDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ParseTransactionRuleDelegate.java @@ -4,8 +4,11 @@ import com.jongsoft.finance.StorageService; import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.serialized.RuleConfigJson; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -13,30 +16,30 @@ @Singleton public class ParseTransactionRuleDelegate implements JavaDelegate, JavaBean { - private final StorageService storageService; - private final ProcessMapper mapper; + private final StorageService storageService; + private final ProcessMapper mapper; - ParseTransactionRuleDelegate(StorageService storageService, ProcessMapper mapper) { - this.storageService = storageService; - this.mapper = mapper; - } + ParseTransactionRuleDelegate(StorageService storageService, ProcessMapper mapper) { + this.storageService = storageService; + this.mapper = mapper; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing raw json file in {}", - execution.getCurrentActivityName(), - execution.getActivityInstanceId()); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing raw json file in {}", + execution.getCurrentActivityName(), + execution.getActivityInstanceId()); - String storageToken = (String) execution.getVariableLocal("storageToken"); + String storageToken = (String) execution.getVariableLocal("storageToken"); - var rules = storageService - .read(storageToken) - .map(String::new) - .map(json -> mapper.readSafe(json, RuleConfigJson.class)) - .map(RuleConfigJson::getRules) - .getOrThrow(() -> new RuntimeException("Failed to read json file")); + var rules = storageService + .read(storageToken) + .map(String::new) + .map(json -> mapper.readSafe(json, RuleConfigJson.class)) + .map(RuleConfigJson::getRules) + .getOrThrow(() -> new RuntimeException("Failed to read json file")); - execution.setVariable("ruleLines", rules); - } + execution.setVariable("ruleLines", rules); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/PersistTransactionRuleDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/PersistTransactionRuleDelegate.java index d935ee3d..396d10e7 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/PersistTransactionRuleDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/PersistTransactionRuleDelegate.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.domain.transaction.TransactionRule; import com.jongsoft.finance.providers.TransactionRuleProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -12,22 +15,22 @@ @Singleton public class PersistTransactionRuleDelegate implements JavaDelegate, JavaBean { - private final TransactionRuleProvider ruleProvider; + private final TransactionRuleProvider ruleProvider; - PersistTransactionRuleDelegate(TransactionRuleProvider ruleProvider) { - this.ruleProvider = ruleProvider; - } + PersistTransactionRuleDelegate(TransactionRuleProvider ruleProvider) { + this.ruleProvider = ruleProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - TransactionRule transactionRule = - (TransactionRule) execution.getVariableLocal("transactionRule"); + @Override + public void execute(DelegateExecution execution) throws Exception { + TransactionRule transactionRule = + (TransactionRule) execution.getVariableLocal("transactionRule"); - log.debug( - "{}: Processing transaction rule save {}", - execution.getCurrentActivityName(), - transactionRule.getName()); + log.debug( + "{}: Processing transaction rule save {}", + execution.getCurrentActivityName(), + transactionRule.getName()); - ruleProvider.save(transactionRule); - } + ruleProvider.save(transactionRule); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessRuleChangeCreationDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessRuleChangeCreationDelegate.java index 06f52a59..c5cb93f7 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessRuleChangeCreationDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessRuleChangeCreationDelegate.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.core.RuleColumn; import com.jongsoft.finance.domain.transaction.TransactionRule; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -12,19 +15,19 @@ @Singleton public class ProcessRuleChangeCreationDelegate implements JavaDelegate, JavaBean { - @Override - public void execute(DelegateExecution execution) throws Exception { - TransactionRule transactionRule = (TransactionRule) execution.getVariableLocal("entity"); - String id = (String) execution.getVariableLocal("value"); - RuleColumn field = (RuleColumn) execution.getVariableLocal("field"); - - log.debug( - "{}: Processing transaction rule {} change addition {}|{}", - execution.getCurrentActivityName(), - transactionRule.getName(), - field, - id); - - transactionRule.registerChange(field, id); - } + @Override + public void execute(DelegateExecution execution) throws Exception { + TransactionRule transactionRule = (TransactionRule) execution.getVariableLocal("entity"); + String id = (String) execution.getVariableLocal("value"); + RuleColumn field = (RuleColumn) execution.getVariableLocal("field"); + + log.debug( + "{}: Processing transaction rule {} change addition {}|{}", + execution.getCurrentActivityName(), + transactionRule.getName(), + field, + id); + + transactionRule.registerChange(field, id); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessTransactionRuleDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessTransactionRuleDelegate.java index 4557d54e..ba94f168 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessTransactionRuleDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/ProcessTransactionRuleDelegate.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.security.CurrentUserProvider; import com.jongsoft.finance.serialized.RuleConfigJson; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -12,37 +15,36 @@ @Singleton public class ProcessTransactionRuleDelegate implements JavaDelegate, JavaBean { - private final CurrentUserProvider currentUserProvider; + private final CurrentUserProvider currentUserProvider; - ProcessTransactionRuleDelegate(CurrentUserProvider currentUserProvider) { - this.currentUserProvider = currentUserProvider; - } + ProcessTransactionRuleDelegate(CurrentUserProvider currentUserProvider) { + this.currentUserProvider = currentUserProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing transaction rule configuration {}", - execution.getCurrentActivityName(), - execution.getActivityInstanceId()); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing transaction rule configuration {}", + execution.getCurrentActivityName(), + execution.getActivityInstanceId()); - var ruleJson = (RuleConfigJson.RuleJson) execution.getVariableLocal("ruleConfiguration"); + var ruleJson = (RuleConfigJson.RuleJson) execution.getVariableLocal("ruleConfiguration"); - var userAccount = currentUserProvider.currentUser(); - var transactionRule = userAccount.createRule(ruleJson.getName(), ruleJson.isRestrictive()); + var userAccount = currentUserProvider.currentUser(); + var transactionRule = userAccount.createRule(ruleJson.getName(), ruleJson.isRestrictive()); - ruleJson - .getConditions() - .forEach( - c -> transactionRule.registerCondition(c.getField(), c.getOperation(), c.getValue())); + ruleJson.getConditions() + .forEach(c -> transactionRule.registerCondition( + c.getField(), c.getOperation(), c.getValue())); - transactionRule.change( - ruleJson.getName(), - ruleJson.getDescription(), - ruleJson.isRestrictive(), - ruleJson.isActive()); + transactionRule.change( + ruleJson.getName(), + ruleJson.getDescription(), + ruleJson.isRestrictive(), + ruleJson.isActive()); - transactionRule.changeOrder(ruleJson.getSort()); + transactionRule.changeOrder(ruleJson.getSort()); - execution.setVariable("transactionRule", transactionRule); - } + execution.setVariable("transactionRule", transactionRule); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/RuleGroupLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/RuleGroupLookupDelegate.java index bae7694c..d09ea271 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/RuleGroupLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/RuleGroupLookupDelegate.java @@ -5,8 +5,11 @@ import com.jongsoft.finance.messaging.EventBus; import com.jongsoft.finance.messaging.commands.rule.CreateRuleGroupCommand; import com.jongsoft.finance.providers.TransactionRuleGroupProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -14,32 +17,32 @@ @Singleton public class RuleGroupLookupDelegate implements JavaDelegate, JavaBean { - private final TransactionRuleGroupProvider ruleGroupProvider; + private final TransactionRuleGroupProvider ruleGroupProvider; - RuleGroupLookupDelegate(TransactionRuleGroupProvider ruleGroupProvider) { - this.ruleGroupProvider = ruleGroupProvider; - } + RuleGroupLookupDelegate(TransactionRuleGroupProvider ruleGroupProvider) { + this.ruleGroupProvider = ruleGroupProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing rule group lookup {}", - execution.getCurrentActivityName(), - execution.getVariableLocal("name")); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing rule group lookup {}", + execution.getCurrentActivityName(), + execution.getVariableLocal("name")); - String name = (String) execution.getVariableLocal("name"); + String name = (String) execution.getVariableLocal("name"); - var group = ruleGroupProvider.lookup(name).getOrSupply(() -> createRuleGroup(name)); + var group = ruleGroupProvider.lookup(name).getOrSupply(() -> createRuleGroup(name)); - execution.setVariable("group", group); - } + execution.setVariable("group", group); + } - private TransactionRuleGroup createRuleGroup(String name) { - EventBus.getBus().send(new CreateRuleGroupCommand(name)); + private TransactionRuleGroup createRuleGroup(String name) { + EventBus.getBus().send(new CreateRuleGroupCommand(name)); - return ruleGroupProvider - .lookup(name) - .getOrThrow( - () -> new IllegalStateException("Failed to create rule group with name " + name)); - } + return ruleGroupProvider + .lookup(name) + .getOrThrow(() -> + new IllegalStateException("Failed to create rule group with name " + name)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleLookupDelegate.java index 08e1950b..a518e7c1 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleLookupDelegate.java @@ -2,34 +2,39 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.providers.TransactionRuleProvider; + import jakarta.inject.Singleton; -import java.util.Objects; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; +import java.util.Objects; + @Slf4j @Singleton public class TransactionRuleLookupDelegate implements JavaDelegate, JavaBean { - private final TransactionRuleProvider transactionRuleProvider; + private final TransactionRuleProvider transactionRuleProvider; - TransactionRuleLookupDelegate(TransactionRuleProvider transactionRuleProvider) { - this.transactionRuleProvider = transactionRuleProvider; - } + TransactionRuleLookupDelegate(TransactionRuleProvider transactionRuleProvider) { + this.transactionRuleProvider = transactionRuleProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Lookup transaction rule by name {}", - execution.getCurrentActivityName(), - execution.getVariableLocal("name")); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Lookup transaction rule by name {}", + execution.getCurrentActivityName(), + execution.getVariableLocal("name")); - var ruleName = (String) execution.getVariableLocal("name"); + var ruleName = (String) execution.getVariableLocal("name"); - var existing = - transactionRuleProvider.lookup().count(rule -> Objects.equals(rule.getName(), ruleName)); + var existing = transactionRuleProvider + .lookup() + .count(rule -> Objects.equals(rule.getName(), ruleName)); - execution.setVariableLocal("exists", existing != 0); - } + execution.setVariableLocal("exists", existing != 0); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleMatcherDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleMatcherDelegate.java index 98e82eab..a9f50a87 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleMatcherDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/rule/TransactionRuleMatcherDelegate.java @@ -6,13 +6,17 @@ import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.finance.rule.RuleDataSet; import com.jongsoft.finance.rule.RuleEngine; + import jakarta.inject.Singleton; -import java.util.Map; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.LongValue; +import java.util.Map; + /** * This delegate is responsible for matching a transaction against the rules engine. * @@ -25,47 +29,49 @@ @Singleton public class TransactionRuleMatcherDelegate implements JavaDelegate, JavaBean { - private final RuleEngine ruleEngine; - private final TransactionProvider transactionProvider; + private final RuleEngine ruleEngine; + private final TransactionProvider transactionProvider; - TransactionRuleMatcherDelegate(RuleEngine ruleEngine, TransactionProvider transactionProvider) { - this.ruleEngine = ruleEngine; - this.transactionProvider = transactionProvider; - } + TransactionRuleMatcherDelegate(RuleEngine ruleEngine, TransactionProvider transactionProvider) { + this.ruleEngine = ruleEngine; + this.transactionProvider = transactionProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var id = execution.getVariableLocalTyped("transactionId").getValue(); + @Override + public void execute(DelegateExecution execution) throws Exception { + var id = execution.getVariableLocalTyped("transactionId").getValue(); - var transaction = transactionProvider - .lookup(id) - .getOrThrow(() -> new IllegalStateException("Cannot locate transaction with id " + id)); + var transaction = transactionProvider + .lookup(id) + .getOrThrow( + () -> new IllegalStateException("Cannot locate transaction with id " + id)); - log.debug( - "{}: Processing transaction rules on transaction {}", - execution.getCurrentActivityName(), - transaction.getId()); + log.debug( + "{}: Processing transaction rules on transaction {}", + execution.getCurrentActivityName(), + transaction.getId()); - var inputSet = new RuleDataSet(); - inputSet.put(RuleColumn.TO_ACCOUNT, transaction.computeTo().getName()); - inputSet.put(RuleColumn.SOURCE_ACCOUNT, transaction.computeFrom().getName()); - inputSet.put(RuleColumn.AMOUNT, transaction.computeAmount(transaction.computeTo())); - inputSet.put(RuleColumn.DESCRIPTION, transaction.getDescription()); + var inputSet = new RuleDataSet(); + inputSet.put(RuleColumn.TO_ACCOUNT, transaction.computeTo().getName()); + inputSet.put(RuleColumn.SOURCE_ACCOUNT, transaction.computeFrom().getName()); + inputSet.put(RuleColumn.AMOUNT, transaction.computeAmount(transaction.computeTo())); + inputSet.put(RuleColumn.DESCRIPTION, transaction.getDescription()); - var outputSet = ruleEngine.run(inputSet); + var outputSet = ruleEngine.run(inputSet); - for (Map.Entry entry : outputSet.entrySet()) { - switch (entry.getKey()) { - case CATEGORY -> transaction.linkToCategory((String) entry.getValue()); - case TO_ACCOUNT, CHANGE_TRANSFER_TO -> - transaction.changeAccount(false, (Account) entry.getValue()); - case SOURCE_ACCOUNT, CHANGE_TRANSFER_FROM -> - transaction.changeAccount(true, (Account) entry.getValue()); - case CONTRACT -> transaction.linkToContract((String) entry.getValue()); - case BUDGET -> transaction.linkToBudget((String) entry.getValue()); - default -> - throw new IllegalArgumentException("Unsupported rule column provided " + entry.getKey()); - } + for (Map.Entry entry : outputSet.entrySet()) { + switch (entry.getKey()) { + case CATEGORY -> transaction.linkToCategory((String) entry.getValue()); + case TO_ACCOUNT, CHANGE_TRANSFER_TO -> + transaction.changeAccount(false, (Account) entry.getValue()); + case SOURCE_ACCOUNT, CHANGE_TRANSFER_FROM -> + transaction.changeAccount(true, (Account) entry.getValue()); + case CONTRACT -> transaction.linkToContract((String) entry.getValue()); + case BUDGET -> transaction.linkToBudget((String) entry.getValue()); + default -> + throw new IllegalArgumentException( + "Unsupported rule column provided " + entry.getKey()); + } + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/DetermineDelayDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/DetermineDelayDelegate.java index b5b3e64c..7a84f8b3 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/DetermineDelayDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/DetermineDelayDelegate.java @@ -2,51 +2,54 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.schedule.Periodicity; + +import lombok.extern.slf4j.Slf4j; + +import org.camunda.bpm.engine.delegate.DelegateExecution; +import org.camunda.bpm.engine.delegate.JavaDelegate; + import java.time.LocalDate; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.Date; -import lombok.extern.slf4j.Slf4j; -import org.camunda.bpm.engine.delegate.DelegateExecution; -import org.camunda.bpm.engine.delegate.JavaDelegate; @Slf4j public class DetermineDelayDelegate implements JavaDelegate, JavaBean { - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Calculating delay based upon start {} and periodicity {} with interval {}", - execution.getCurrentActivityName(), - execution.getVariable("start"), - execution.getVariable("periodicity"), - execution.getVariable("interval")); - - var interval = (Integer) execution.getVariable("interval"); - var periodicity = (Periodicity) execution.getVariable("periodicity"); - var startCalculation = LocalDate.parse(execution.getVariable("start").toString()); - var datePart = periodicity.toChronoUnit(); - - var nextRun = nextRun(startCalculation, interval, datePart); - execution.setVariable("nextRun", convert(nextRun)); - } - - private LocalDate nextRun(LocalDate currentRun, int interval, ChronoUnit temporalUnit) { - var now = LocalDate.now().plusDays(1); - var nextRun = currentRun.plus(interval, temporalUnit); - - while (now.isAfter(nextRun)) { - nextRun = nextRun.plus(interval, temporalUnit); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Calculating delay based upon start {} and periodicity {} with interval {}", + execution.getCurrentActivityName(), + execution.getVariable("start"), + execution.getVariable("periodicity"), + execution.getVariable("interval")); + + var interval = (Integer) execution.getVariable("interval"); + var periodicity = (Periodicity) execution.getVariable("periodicity"); + var startCalculation = LocalDate.parse(execution.getVariable("start").toString()); + var datePart = periodicity.toChronoUnit(); + + var nextRun = nextRun(startCalculation, interval, datePart); + execution.setVariable("nextRun", convert(nextRun)); } - return nextRun; - } + private LocalDate nextRun(LocalDate currentRun, int interval, ChronoUnit temporalUnit) { + var now = LocalDate.now().plusDays(1); + var nextRun = currentRun.plus(interval, temporalUnit); - private Date convert(LocalDate localDate) { - if (localDate == null) { - return null; + while (now.isAfter(nextRun)) { + nextRun = nextRun.plus(interval, temporalUnit); + } + + return nextRun; } - return Date.from(localDate.atStartOfDay().toInstant(ZoneOffset.UTC)); - } + private Date convert(LocalDate localDate) { + if (localDate == null) { + return null; + } + + return Date.from(localDate.atStartOfDay().toInstant(ZoneOffset.UTC)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/GenerateTransactionJsonDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/GenerateTransactionJsonDelegate.java index 034349a9..09267107 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/GenerateTransactionJsonDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/GenerateTransactionJsonDelegate.java @@ -5,58 +5,62 @@ import com.jongsoft.finance.domain.transaction.ScheduledTransaction; import com.jongsoft.finance.importer.api.TransactionDTO; import com.jongsoft.finance.providers.TransactionScheduleProvider; + import jakarta.inject.Singleton; -import java.time.LocalDate; -import java.util.List; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.LongValue; import org.camunda.bpm.engine.variable.value.StringValue; +import java.time.LocalDate; +import java.util.List; + @Singleton public class GenerateTransactionJsonDelegate implements JavaDelegate, JavaBean { - private final TransactionScheduleProvider transactionScheduleProvider; - - GenerateTransactionJsonDelegate(TransactionScheduleProvider transactionScheduleProvider) { - this.transactionScheduleProvider = transactionScheduleProvider; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - var scheduledTransactionId = - execution.getVariableLocalTyped("id").getValue(); - var isoDate = execution.getVariableLocalTyped("scheduled").getValue(); - - transactionScheduleProvider - .lookup(scheduledTransactionId) - .ifPresent(schedule -> { - var transaction = new TransactionDTO( - schedule.getAmount(), - TransactionType.CREDIT, - generateTransactionDescription(schedule), - LocalDate.parse(isoDate), - null, - null, - schedule.getDestination().getIban(), - schedule.getDestination().getName(), - null, - null, - List.of()); - - execution.setVariable("destinationId", schedule.getDestination().getId()); - execution.setVariable("sourceId", schedule.getSource().getId()); - execution.setVariable("transaction", transaction); - }) - .elseThrow(() -> - new IllegalStateException("Cannot find schedule with id " + scheduledTransactionId)); - } - - private String generateTransactionDescription(ScheduledTransaction scheduledTransaction) { - var descriptionBuilder = new StringBuilder(scheduledTransaction.getName()); - if (scheduledTransaction.getDescription() != null) { - descriptionBuilder.append(": ").append(scheduledTransaction.getDescription()); + private final TransactionScheduleProvider transactionScheduleProvider; + + GenerateTransactionJsonDelegate(TransactionScheduleProvider transactionScheduleProvider) { + this.transactionScheduleProvider = transactionScheduleProvider; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + var scheduledTransactionId = + execution.getVariableLocalTyped("id").getValue(); + var isoDate = execution.getVariableLocalTyped("scheduled").getValue(); + + transactionScheduleProvider + .lookup(scheduledTransactionId) + .ifPresent(schedule -> { + var transaction = new TransactionDTO( + schedule.getAmount(), + TransactionType.CREDIT, + generateTransactionDescription(schedule), + LocalDate.parse(isoDate), + null, + null, + schedule.getDestination().getIban(), + schedule.getDestination().getName(), + null, + null, + List.of()); + + execution.setVariable( + "destinationId", schedule.getDestination().getId()); + execution.setVariable("sourceId", schedule.getSource().getId()); + execution.setVariable("transaction", transaction); + }) + .elseThrow(() -> new IllegalStateException( + "Cannot find schedule with id " + scheduledTransactionId)); + } + + private String generateTransactionDescription(ScheduledTransaction scheduledTransaction) { + var descriptionBuilder = new StringBuilder(scheduledTransaction.getName()); + if (scheduledTransaction.getDescription() != null) { + descriptionBuilder.append(": ").append(scheduledTransaction.getDescription()); + } + return descriptionBuilder.toString(); } - return descriptionBuilder.toString(); - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/ScheduleToContinueDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/ScheduleToContinueDelegate.java index eb64d9f4..a7458bf1 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/ScheduleToContinueDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/ScheduleToContinueDelegate.java @@ -1,34 +1,37 @@ package com.jongsoft.finance.bpmn.delegate.scheduler; import com.jongsoft.finance.core.JavaBean; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.Date; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; + @Slf4j public class ScheduleToContinueDelegate implements JavaDelegate, JavaBean { - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Determine if the schedule should continue {} to run or not with end date {}", - execution.getCurrentActivityName(), - execution.getVariable("nextRun"), - execution.getVariable("end")); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Determine if the schedule should continue {} to run or not with end date {}", + execution.getCurrentActivityName(), + execution.getVariable("nextRun"), + execution.getVariable("end")); - var nextRun = (Date) execution.getVariable("nextRun"); - var end = LocalDate.parse(execution.getVariable("end").toString()); + var nextRun = (Date) execution.getVariable("nextRun"); + var end = LocalDate.parse(execution.getVariable("end").toString()); - execution.setVariable("continue", end.isAfter(convert(nextRun))); - } + execution.setVariable("continue", end.isAfter(convert(nextRun))); + } - private LocalDate convert(Date date) { - if (date == null) { - return null; + private LocalDate convert(Date date) { + if (date == null) { + return null; + } + return LocalDate.ofInstant(date.toInstant(), ZoneId.of("UTC")); } - return LocalDate.ofInstant(date.toInstant(), ZoneId.of("UTC")); - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/SchedulerVariableMappingDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/SchedulerVariableMappingDelegate.java index b827454e..7943d5b2 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/SchedulerVariableMappingDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/scheduler/SchedulerVariableMappingDelegate.java @@ -1,44 +1,46 @@ package com.jongsoft.finance.bpmn.delegate.scheduler; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.Date; -import java.util.Map; import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.DelegateVariableMapping; import org.camunda.bpm.engine.delegate.VariableScope; import org.camunda.bpm.engine.variable.VariableMap; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.Map; + @Slf4j public class SchedulerVariableMappingDelegate implements DelegateVariableMapping { - private static final String SUB_PROCESS = "subProcess"; + private static final String SUB_PROCESS = "subProcess"; + + @Override + public void mapInputVariables(DelegateExecution superExecution, VariableMap subVariables) { + log.debug( + "{}: Mapping input variables for sub process {}", + superExecution.getCurrentActivityName(), + superExecution.getVariable(SUB_PROCESS)); - @Override - public void mapInputVariables(DelegateExecution superExecution, VariableMap subVariables) { - log.debug( - "{}: Mapping input variables for sub process {}", - superExecution.getCurrentActivityName(), - superExecution.getVariable(SUB_PROCESS)); + var subProcess = superExecution.getVariable(SUB_PROCESS).toString(); + if (superExecution.hasVariable(subProcess)) { + var variableMap = (Map) superExecution.getVariable(subProcess); + variableMap.forEach(subVariables::putValue); + } - var subProcess = superExecution.getVariable(SUB_PROCESS).toString(); - if (superExecution.hasVariable(subProcess)) { - var variableMap = (Map) superExecution.getVariable(subProcess); - variableMap.forEach(subVariables::putValue); + var scheduledDate = LocalDate.ofInstant( + ((Date) superExecution.getVariable("nextRun")).toInstant(), ZoneId.systemDefault()); + subVariables.putValue("scheduled", scheduledDate.toString()); + subVariables.putValue("username", superExecution.getVariable("username")); } - var scheduledDate = LocalDate.ofInstant( - ((Date) superExecution.getVariable("nextRun")).toInstant(), ZoneId.systemDefault()); - subVariables.putValue("scheduled", scheduledDate.toString()); - subVariables.putValue("username", superExecution.getVariable("username")); - } - - @Override - public void mapOutputVariables(DelegateExecution superExecution, VariableScope subInstance) { - log.debug( - "{}: Mapping output variables for sub process {}", - superExecution.getCurrentActivityName(), - superExecution.getVariable(SUB_PROCESS)); - } + @Override + public void mapOutputVariables(DelegateExecution superExecution, VariableScope subInstance) { + log.debug( + "{}: Mapping output variables for sub process {}", + superExecution.getCurrentActivityName(), + superExecution.getVariable(SUB_PROCESS)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/CreateTransactionDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/CreateTransactionDelegate.java index 57f52935..21af8492 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/CreateTransactionDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/CreateTransactionDelegate.java @@ -9,8 +9,11 @@ import com.jongsoft.finance.providers.AccountProvider; import com.jongsoft.lang.Collections; import com.jongsoft.lang.Control; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -36,59 +39,61 @@ @Singleton public class CreateTransactionDelegate implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; - private final TransactionCreationHandler creationHandler; + private final AccountProvider accountProvider; + private final TransactionCreationHandler creationHandler; - CreateTransactionDelegate( - AccountProvider accountProvider, TransactionCreationHandler creationHandler) { - this.accountProvider = accountProvider; - this.creationHandler = creationHandler; - } + CreateTransactionDelegate( + AccountProvider accountProvider, TransactionCreationHandler creationHandler) { + this.accountProvider = accountProvider; + this.creationHandler = creationHandler; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - var batchImportSlug = (String) execution.getVariable("importJobSlug"); - var parsedTransaction = (TransactionDTO) execution.getVariableLocal("transaction"); - var toAccount = lookupAccount(execution, "accountId"); - var targetAccount = lookupAccount(execution, "targetAccount"); + @Override + public void execute(DelegateExecution execution) throws Exception { + var batchImportSlug = (String) execution.getVariable("importJobSlug"); + var parsedTransaction = (TransactionDTO) execution.getVariableLocal("transaction"); + var toAccount = lookupAccount(execution, "accountId"); + var targetAccount = lookupAccount(execution, "targetAccount"); - log.debug( - "{}: Creating transaction into {} from {} with amount {}", - execution.getCurrentActivityName(), - targetAccount.getName(), - toAccount.getName(), - parsedTransaction.amount()); + log.debug( + "{}: Creating transaction into {} from {} with amount {}", + execution.getCurrentActivityName(), + targetAccount.getName(), + toAccount.getName(), + parsedTransaction.amount()); - var type = - switch (parsedTransaction.type()) { - case DEBIT -> Transaction.Type.DEBIT; - case CREDIT -> Transaction.Type.CREDIT; - case TRANSFER -> Transaction.Type.TRANSFER; - }; + var type = + switch (parsedTransaction.type()) { + case DEBIT -> Transaction.Type.DEBIT; + case CREDIT -> Transaction.Type.CREDIT; + case TRANSFER -> Transaction.Type.TRANSFER; + }; - Transaction transaction = targetAccount.createTransaction( - toAccount, parsedTransaction.amount(), type, t -> t.currency(targetAccount.getCurrency()) - .date(parsedTransaction.transactionDate()) - .bookDate(parsedTransaction.bookDate()) - .interestDate(parsedTransaction.interestDate()) - .description(parsedTransaction.description()) - .category(parsedTransaction.category()) - .budget(parsedTransaction.budget()) - .tags(Control.Option(parsedTransaction.tags()) - .map(Collections::List) - .getOrSupply(() -> null)) - .importSlug(batchImportSlug)); + Transaction transaction = targetAccount.createTransaction( + toAccount, parsedTransaction.amount(), type, t -> t.currency( + targetAccount.getCurrency()) + .date(parsedTransaction.transactionDate()) + .bookDate(parsedTransaction.bookDate()) + .interestDate(parsedTransaction.interestDate()) + .description(parsedTransaction.description()) + .category(parsedTransaction.category()) + .budget(parsedTransaction.budget()) + .tags(Control.Option(parsedTransaction.tags()) + .map(Collections::List) + .getOrSupply(() -> null)) + .importSlug(batchImportSlug)); - long transactionId = - creationHandler.handleCreatedEvent(new CreateTransactionCommand(transaction)); + long transactionId = + creationHandler.handleCreatedEvent(new CreateTransactionCommand(transaction)); - execution.setVariable("transactionId", transactionId); - } + execution.setVariable("transactionId", transactionId); + } - private Account lookupAccount(DelegateExecution execution, String variableName) { - var accountId = (Number) execution.getVariableLocal(variableName); - return accountProvider - .lookup(accountId.longValue()) - .getOrThrow(() -> new IllegalStateException("Unable to find account with id " + accountId)); - } + private Account lookupAccount(DelegateExecution execution, String variableName) { + var accountId = (Number) execution.getVariableLocal(variableName); + return accountProvider + .lookup(accountId.longValue()) + .getOrThrow(() -> + new IllegalStateException("Unable to find account with id " + accountId)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/DuplicateTransactionFinderDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/DuplicateTransactionFinderDelegate.java index eb37f06f..cf99ea10 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/DuplicateTransactionFinderDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/DuplicateTransactionFinderDelegate.java @@ -6,8 +6,11 @@ import com.jongsoft.finance.domain.transaction.Transaction; import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.lang.collection.List; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.LongValue; @@ -16,34 +19,35 @@ @Singleton public class DuplicateTransactionFinderDelegate implements JavaDelegate, JavaBean { - private final TransactionProvider transactionProvider; - - DuplicateTransactionFinderDelegate(TransactionProvider transactionProvider) { - this.transactionProvider = transactionProvider; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - if (execution.hasVariableLocal("transactionId")) { - var id = execution.getVariableLocalTyped("transactionId").getValue(); - Transaction transaction = transactionProvider - .lookup(id) - .getOrThrow(() -> new IllegalStateException("Unable to find transaction with id " + id)); - - var amount = transaction.computeAmount(transaction.computeFrom()); - - List matches = transactionProvider - .similar( - new EntityRef(transaction.computeFrom().getId()), - new EntityRef(transaction.computeTo().getId()), - amount, - transaction.getDate()) - .reject(t -> t.getId().equals(id)); - - if (!matches.isEmpty()) { - log.warn("Marking potential duplicate transaction {}", transaction); - transaction.registerFailure(FailureCode.POSSIBLE_DUPLICATE); - } + private final TransactionProvider transactionProvider; + + DuplicateTransactionFinderDelegate(TransactionProvider transactionProvider) { + this.transactionProvider = transactionProvider; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + if (execution.hasVariableLocal("transactionId")) { + var id = execution.getVariableLocalTyped("transactionId").getValue(); + Transaction transaction = transactionProvider + .lookup(id) + .getOrThrow(() -> + new IllegalStateException("Unable to find transaction with id " + id)); + + var amount = transaction.computeAmount(transaction.computeFrom()); + + List matches = transactionProvider + .similar( + new EntityRef(transaction.computeFrom().getId()), + new EntityRef(transaction.computeTo().getId()), + amount, + transaction.getDate()) + .reject(t -> t.getId().equals(id)); + + if (!matches.isEmpty()) { + log.warn("Marking potential duplicate transaction {}", transaction); + transaction.registerFailure(FailureCode.POSSIBLE_DUPLICATE); + } + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/ImportTransactionJsonDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/ImportTransactionJsonDelegate.java index cb3b88b2..0891ba7a 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/ImportTransactionJsonDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/ImportTransactionJsonDelegate.java @@ -6,8 +6,11 @@ import com.jongsoft.finance.messaging.handlers.TransactionCreationHandler; import com.jongsoft.finance.providers.AccountProvider; import com.jongsoft.finance.serialized.TransactionJson; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -15,43 +18,44 @@ @Singleton public class ImportTransactionJsonDelegate implements JavaDelegate, JavaBean { - private final AccountProvider accountProvider; - private final TransactionCreationHandler creationHandler; - - public ImportTransactionJsonDelegate( - AccountProvider accountProvider, TransactionCreationHandler creationHandler) { - this.accountProvider = accountProvider; - this.creationHandler = creationHandler; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - var transaction = (TransactionJson) execution.getVariableLocal("transaction"); - - log.debug( - "{}: Importing a transaction from '{}' to '{}'.", - execution.getCurrentActivityName(), - transaction.getFromAccount(), - transaction.getToAccount()); - - var fromAccount = accountProvider - .lookup(transaction.getFromAccount()) - .getOrThrow(() -> new IllegalStateException( - "Unable to find account with name " + transaction.getFromAccount())); - var toAccount = accountProvider - .lookup(transaction.getToAccount()) - .getOrThrow(() -> new IllegalStateException( - "Unable to find account with name " + transaction.getToAccount())); - - var created = fromAccount.createTransaction( - toAccount, transaction.getAmount(), Transaction.Type.CREDIT, t -> t.currency( - transaction.getCurrency()) - .date(transaction.getDate()) - .bookDate(transaction.getBookDate()) - .interestDate(transaction.getInterestDate()) - .description(transaction.getDescription())); - - long transactionId = creationHandler.handleCreatedEvent(new CreateTransactionCommand(created)); - execution.setVariable("transactionId", transactionId); - } + private final AccountProvider accountProvider; + private final TransactionCreationHandler creationHandler; + + public ImportTransactionJsonDelegate( + AccountProvider accountProvider, TransactionCreationHandler creationHandler) { + this.accountProvider = accountProvider; + this.creationHandler = creationHandler; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + var transaction = (TransactionJson) execution.getVariableLocal("transaction"); + + log.debug( + "{}: Importing a transaction from '{}' to '{}'.", + execution.getCurrentActivityName(), + transaction.getFromAccount(), + transaction.getToAccount()); + + var fromAccount = accountProvider + .lookup(transaction.getFromAccount()) + .getOrThrow(() -> new IllegalStateException( + "Unable to find account with name " + transaction.getFromAccount())); + var toAccount = accountProvider + .lookup(transaction.getToAccount()) + .getOrThrow(() -> new IllegalStateException( + "Unable to find account with name " + transaction.getToAccount())); + + var created = fromAccount.createTransaction( + toAccount, transaction.getAmount(), Transaction.Type.CREDIT, t -> t.currency( + transaction.getCurrency()) + .date(transaction.getDate()) + .bookDate(transaction.getBookDate()) + .interestDate(transaction.getInterestDate()) + .description(transaction.getDescription())); + + long transactionId = + creationHandler.handleCreatedEvent(new CreateTransactionCommand(created)); + execution.setVariable("transactionId", transactionId); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/PrepareAccountGenerationDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/PrepareAccountGenerationDelegate.java index 4cb267d4..39557289 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/PrepareAccountGenerationDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/PrepareAccountGenerationDelegate.java @@ -5,8 +5,11 @@ import com.jongsoft.finance.core.TransactionType; import com.jongsoft.finance.importer.api.TransactionDTO; import com.jongsoft.finance.serialized.AccountJson; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; @@ -18,28 +21,28 @@ @Slf4j @Singleton public class PrepareAccountGenerationDelegate implements JavaDelegate, JavaBean { - private final ProcessMapper mapper; - - PrepareAccountGenerationDelegate(ProcessMapper mapper) { - this.mapper = mapper; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - var transaction = (TransactionDTO) execution.getVariableLocal("transaction"); - - log.debug( - "{}: Extracting the account to be created from the transaction {}.", - execution.getCurrentActivityName(), - transaction.opposingName()); - - var accountJson = AccountJson.builder() - .name(transaction.opposingName()) - .iban(transaction.opposingIBAN()) - .type(transaction.type() == TransactionType.CREDIT ? "creditor" : "debtor") - .currency("EUR") // todo this needs to be fixed later on - .build(); - - execution.setVariableLocal("accountJson", mapper.writeSafe(accountJson)); - } + private final ProcessMapper mapper; + + PrepareAccountGenerationDelegate(ProcessMapper mapper) { + this.mapper = mapper; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + var transaction = (TransactionDTO) execution.getVariableLocal("transaction"); + + log.debug( + "{}: Extracting the account to be created from the transaction {}.", + execution.getCurrentActivityName(), + transaction.opposingName()); + + var accountJson = AccountJson.builder() + .name(transaction.opposingName()) + .iban(transaction.opposingIBAN()) + .type(transaction.type() == TransactionType.CREDIT ? "creditor" : "debtor") + .currency("EUR") // todo this needs to be fixed later on + .build(); + + execution.setVariableLocal("accountJson", mapper.writeSafe(accountJson)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/TagLookupDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/TagLookupDelegate.java index 2e2d4767..6cb85a33 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/TagLookupDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/transaction/TagLookupDelegate.java @@ -5,8 +5,11 @@ import com.jongsoft.finance.domain.transaction.Tag; import com.jongsoft.finance.providers.TagProvider; import com.jongsoft.finance.security.CurrentUserProvider; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.StringValue; @@ -15,32 +18,33 @@ @Singleton public class TagLookupDelegate implements JavaDelegate, JavaBean { - private final CurrentUserProvider currentUserProvider; - private final TagProvider tagProvider; + private final CurrentUserProvider currentUserProvider; + private final TagProvider tagProvider; - TagLookupDelegate(CurrentUserProvider currentUserProvider, TagProvider tagProvider) { - this.currentUserProvider = currentUserProvider; - this.tagProvider = tagProvider; - } + TagLookupDelegate(CurrentUserProvider currentUserProvider, TagProvider tagProvider) { + this.currentUserProvider = currentUserProvider; + this.tagProvider = tagProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Looking up tag {} for current user", - execution.getCurrentActivityName(), - execution.getVariableLocal("name")); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Looking up tag {} for current user", + execution.getCurrentActivityName(), + execution.getVariableLocal("name")); - var name = execution.getVariableLocalTyped("name").getValue(); - var tag = tagProvider.lookup(name).getOrSupply(() -> create(name)); + var name = execution.getVariableLocalTyped("name").getValue(); + var tag = tagProvider.lookup(name).getOrSupply(() -> create(name)); - execution.setVariableLocal("id", tag.name()); - } + execution.setVariableLocal("id", tag.name()); + } - private Tag create(String name) { - currentUserProvider.currentUser().createTag(name); + private Tag create(String name) { + currentUserProvider.currentUser().createTag(name); - return tagProvider - .lookup(name) - .getOrThrow(() -> StatusException.internalError("Could not locate tag after creating it")); - } + return tagProvider + .lookup(name) + .getOrThrow(() -> + StatusException.internalError("Could not locate tag after creating it")); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/CreateUserDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/CreateUserDelegate.java index a7650874..fcd63ed9 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/CreateUserDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/CreateUserDelegate.java @@ -6,7 +6,9 @@ import com.jongsoft.finance.domain.FinTrack; import com.jongsoft.finance.domain.user.UserIdentifier; import com.jongsoft.finance.serialized.AccountJson; + import jakarta.inject.Singleton; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.camunda.bpm.engine.variable.value.ObjectValue; @@ -15,30 +17,31 @@ @Singleton public class CreateUserDelegate implements JavaDelegate, JavaBean { - private final FinTrack application; - private final ProcessMapper mapper; - - CreateUserDelegate(FinTrack application, ProcessMapper mapper) { - this.application = application; - this.mapper = mapper; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - var toCreateUsername = - execution.getVariableLocalTyped("username").getValue(UserIdentifier.class); - var toCreatePassword = - execution.getVariableLocalTyped("password").getValue(); - - application.createUser(toCreateUsername.email(), toCreatePassword); - - var reconcileAccount = AccountJson.builder() - .currency("EUR") - .description("Reconcile Account") - .name("Reconcile Account") - .type(SystemAccountTypes.RECONCILE.label()) - .build(); - - execution.setVariableLocal("account", mapper.writeSafe(reconcileAccount)); - } + private final FinTrack application; + private final ProcessMapper mapper; + + CreateUserDelegate(FinTrack application, ProcessMapper mapper) { + this.application = application; + this.mapper = mapper; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + var toCreateUsername = execution + .getVariableLocalTyped("username") + .getValue(UserIdentifier.class); + var toCreatePassword = + execution.getVariableLocalTyped("password").getValue(); + + application.createUser(toCreateUsername.email(), toCreatePassword); + + var reconcileAccount = AccountJson.builder() + .currency("EUR") + .description("Reconcile Account") + .name("Reconcile Account") + .type(SystemAccountTypes.RECONCILE.label()) + .build(); + + execution.setVariableLocal("account", mapper.writeSafe(reconcileAccount)); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/ParseUserConfigurationDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/ParseUserConfigurationDelegate.java index be900fef..cb32bb2e 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/ParseUserConfigurationDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/ParseUserConfigurationDelegate.java @@ -7,81 +7,86 @@ import com.jongsoft.finance.serialized.ExportJson; import com.jongsoft.finance.serialized.RuleConfigJson; import com.jongsoft.lang.Control; + import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + +import org.camunda.bpm.engine.delegate.DelegateExecution; +import org.camunda.bpm.engine.delegate.JavaDelegate; + import java.nio.charset.StandardCharsets; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -import org.camunda.bpm.engine.delegate.DelegateExecution; -import org.camunda.bpm.engine.delegate.JavaDelegate; @Slf4j @Singleton public class ParseUserConfigurationDelegate implements JavaDelegate, JavaBean { - private final StorageService storageService; - private final ProcessMapper mapper; - - ParseUserConfigurationDelegate(StorageService storageService, ProcessMapper mapper) { - this.storageService = storageService; - this.mapper = mapper; - } - - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Processing raw json file in {}", - execution.getCurrentActivityName(), - execution.getActivityInstanceId()); - - String storageToken = (String) execution.getVariable("storageToken"); - - var profileJson = storageService - .read(storageToken) - .map(String::new) - .map(json -> mapper.readSafe(json, ExportJson.class)) - .getOrThrow(() -> new RuntimeException("Unable to parse json file")); - - if (profileJson.getRules() != null && !profileJson.getRules().isEmpty()) { - String ruleStorageToken = storageService.store(mapper - .writeSafe(RuleConfigJson.builder() - .slug("profile-import") - .rules(profileJson.getRules()) - .build()) - .getBytes(StandardCharsets.UTF_8)); - execution.setVariableLocal("ruleStorageToken", ruleStorageToken); - } else { - execution.setVariableLocal("ruleStorageToken", null); - } + private final StorageService storageService; + private final ProcessMapper mapper; - if (profileJson.getBudgetPeriods() != null) { - var sortedBudgets = profileJson.getBudgetPeriods().stream() - .sorted(Comparator.comparing(BudgetJson::getStart)) - .toList(); - execution.setVariableLocal("budgetPeriods", serialize(sortedBudgets)); - } else { - execution.setVariableLocal("budgetPeriods", List.of()); + ParseUserConfigurationDelegate(StorageService storageService, ProcessMapper mapper) { + this.storageService = storageService; + this.mapper = mapper; } - execution.setVariableLocal( - "transactions", Control.Option(profileJson.getTransactions()).getOrSupply(List::of)); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Processing raw json file in {}", + execution.getCurrentActivityName(), + execution.getActivityInstanceId()); + + String storageToken = (String) execution.getVariable("storageToken"); + + var profileJson = storageService + .read(storageToken) + .map(String::new) + .map(json -> mapper.readSafe(json, ExportJson.class)) + .getOrThrow(() -> new RuntimeException("Unable to parse json file")); - execution.setVariableLocal("accounts", serialize(profileJson.getAccounts())); - execution.setVariableLocal("contracts", serialize(profileJson.getContracts())); - execution.setVariableLocal("categories", serialize(profileJson.getCategories())); - execution.setVariableLocal("tags", Control.Option(profileJson.getTags()).getOrSupply(List::of)); - } + if (profileJson.getRules() != null && !profileJson.getRules().isEmpty()) { + String ruleStorageToken = storageService.store(mapper.writeSafe(RuleConfigJson.builder() + .slug("profile-import") + .rules(profileJson.getRules()) + .build()) + .getBytes(StandardCharsets.UTF_8)); + execution.setVariableLocal("ruleStorageToken", ruleStorageToken); + } else { + execution.setVariableLocal("ruleStorageToken", null); + } - List serialize(List input) { - if (input == null) { - return List.of(); + if (profileJson.getBudgetPeriods() != null) { + var sortedBudgets = profileJson.getBudgetPeriods().stream() + .sorted(Comparator.comparing(BudgetJson::getStart)) + .toList(); + execution.setVariableLocal("budgetPeriods", serialize(sortedBudgets)); + } else { + execution.setVariableLocal("budgetPeriods", List.of()); + } + + execution.setVariableLocal( + "transactions", + Control.Option(profileJson.getTransactions()).getOrSupply(List::of)); + + execution.setVariableLocal("accounts", serialize(profileJson.getAccounts())); + execution.setVariableLocal("contracts", serialize(profileJson.getContracts())); + execution.setVariableLocal("categories", serialize(profileJson.getCategories())); + execution.setVariableLocal( + "tags", Control.Option(profileJson.getTags()).getOrSupply(List::of)); } - return input.stream() - .map(mapper::writeSafe) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - } + List serialize(List input) { + if (input == null) { + return List.of(); + } + + return input.stream() + .map(mapper::writeSafe) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/UsernameAvailableDelegate.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/UsernameAvailableDelegate.java index ffb3f5cf..152bfc5b 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/UsernameAvailableDelegate.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/delegate/user/UsernameAvailableDelegate.java @@ -3,7 +3,9 @@ import com.jongsoft.finance.core.JavaBean; import com.jongsoft.finance.domain.user.UserIdentifier; import com.jongsoft.finance.providers.UserProvider; + import jakarta.inject.Singleton; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.JavaDelegate; import org.slf4j.Logger; @@ -12,23 +14,23 @@ @Singleton public class UsernameAvailableDelegate implements JavaDelegate, JavaBean { - private static final String USERNAME = "username"; - private static final Logger log = LoggerFactory.getLogger(UsernameAvailableDelegate.class); + private static final String USERNAME = "username"; + private static final Logger log = LoggerFactory.getLogger(UsernameAvailableDelegate.class); - private final UserProvider userProvider; + private final UserProvider userProvider; - public UsernameAvailableDelegate(UserProvider userProvider) { - this.userProvider = userProvider; - } + public UsernameAvailableDelegate(UserProvider userProvider) { + this.userProvider = userProvider; + } - @Override - public void execute(DelegateExecution execution) throws Exception { - log.debug( - "{}: Validating if the username is still available {}", - execution.getCurrentActivityName(), - execution.getVariableLocal(USERNAME)); + @Override + public void execute(DelegateExecution execution) throws Exception { + log.debug( + "{}: Validating if the username is still available {}", + execution.getCurrentActivityName(), + execution.getVariableLocal(USERNAME)); - execution.setVariableLocal("usernameAvailable", userProvider.available((UserIdentifier) - execution.getVariableLocal(USERNAME))); - } + execution.setVariableLocal("usernameAvailable", userProvider.available((UserIdentifier) + execution.getVariableLocal(USERNAME))); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ChangeContractHandler.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ChangeContractHandler.java index b750f3c3..742de3f0 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ChangeContractHandler.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ChangeContractHandler.java @@ -5,45 +5,49 @@ import com.jongsoft.finance.core.DateUtils; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.ChangeContractCommand; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.ProcessEngine; @Slf4j @Singleton class ChangeContractHandler implements CommandHandler { - private final ProcessEngine processEngine; - - ChangeContractHandler(ProcessEngine processEngine) { - this.processEngine = processEngine; - } - - @Override - @BusinessEventListener - public void handle(ChangeContractCommand command) { - log.trace( - "[{}] - Updating existing BPMN flow for contract expired notification.", command.id()); - - var runningProcess = processEngine - .getRuntimeService() - .createProcessInstanceQuery() - .processDefinitionKey(KnownProcesses.CONTRACT_WARN_EXPIRY) - .processInstanceBusinessKey("contract_term_" + command.id()) - .singleResult(); - - if (runningProcess != null) { - var timerJob = processEngine - .getManagementService() - .createJobQuery() - .processDefinitionKey(KnownProcesses.CONTRACT_WARN_EXPIRY) - .processInstanceId(runningProcess.getProcessInstanceId()) - .singleResult(); - - var newDueDate = command.end().minusMonths(1); - processEngine - .getManagementService() - .setJobDuedate(timerJob.getId(), DateUtils.toDate(newDueDate)); + private final ProcessEngine processEngine; + + ChangeContractHandler(ProcessEngine processEngine) { + this.processEngine = processEngine; + } + + @Override + @BusinessEventListener + public void handle(ChangeContractCommand command) { + log.trace( + "[{}] - Updating existing BPMN flow for contract expired notification.", + command.id()); + + var runningProcess = processEngine + .getRuntimeService() + .createProcessInstanceQuery() + .processDefinitionKey(KnownProcesses.CONTRACT_WARN_EXPIRY) + .processInstanceBusinessKey("contract_term_" + command.id()) + .singleResult(); + + if (runningProcess != null) { + var timerJob = processEngine + .getManagementService() + .createJobQuery() + .processDefinitionKey(KnownProcesses.CONTRACT_WARN_EXPIRY) + .processInstanceId(runningProcess.getProcessInstanceId()) + .singleResult(); + + var newDueDate = command.end().minusMonths(1); + processEngine + .getManagementService() + .setJobDuedate(timerJob.getId(), DateUtils.toDate(newDueDate)); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ScheduleHandler.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ScheduleHandler.java index 6456a2a6..1915b5f4 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ScheduleHandler.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/ScheduleHandler.java @@ -5,68 +5,73 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.schedule.ScheduleCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import jakarta.inject.Singleton; -import java.util.Objects; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.ProcessEngine; +import java.util.Objects; + @Slf4j @Singleton public class ScheduleHandler implements CommandHandler { - private final AuthenticationFacade authenticationFacade; - private final ProcessEngine processEngine; - - ScheduleHandler(AuthenticationFacade authenticationFacade, ProcessEngine processEngine) { - this.authenticationFacade = authenticationFacade; - this.processEngine = processEngine; - } - - @BusinessEventListener - public void handle(ScheduleCommand command) { - log.info("Processing scheduler {} command", command.businessKey()); - deleteAnyActiveProcess(command); - startNewActivity(command); - } - - private void startNewActivity(ScheduleCommand command) { - Objects.requireNonNull(command.schedulable(), "Entity to be scheduled cannot be null."); - - var starter = processEngine - .getRuntimeService() - .createProcessInstanceByKey(KnownProcesses.PROCESS_SCHEDULE) - .businessKey(command.businessKey()) - .setVariable("username", authenticationFacade.authenticated()) - .setVariable("subProcess", command.processDefinition()) - .setVariable(command.processDefinition(), command.variables()); - - if (Objects.nonNull(command.schedulable().getStart())) { - starter.setVariable("start", command.schedulable().getStart().toString()); + private final AuthenticationFacade authenticationFacade; + private final ProcessEngine processEngine; + + ScheduleHandler(AuthenticationFacade authenticationFacade, ProcessEngine processEngine) { + this.authenticationFacade = authenticationFacade; + this.processEngine = processEngine; } - if (Objects.nonNull(command.schedulable().getEnd())) { - starter.setVariable("end", command.schedulable().getEnd().toString()); + @BusinessEventListener + public void handle(ScheduleCommand command) { + log.info("Processing scheduler {} command", command.businessKey()); + deleteAnyActiveProcess(command); + startNewActivity(command); } - if (Objects.nonNull(command.schedulable().getSchedule())) { - starter - .setVariable("interval", command.schedulable().getSchedule().interval()) - .setVariable("periodicity", command.schedulable().getSchedule().periodicity()); + private void startNewActivity(ScheduleCommand command) { + Objects.requireNonNull(command.schedulable(), "Entity to be scheduled cannot be null."); + + var starter = processEngine + .getRuntimeService() + .createProcessInstanceByKey(KnownProcesses.PROCESS_SCHEDULE) + .businessKey(command.businessKey()) + .setVariable("username", authenticationFacade.authenticated()) + .setVariable("subProcess", command.processDefinition()) + .setVariable(command.processDefinition(), command.variables()); + + if (Objects.nonNull(command.schedulable().getStart())) { + starter.setVariable("start", command.schedulable().getStart().toString()); + } + + if (Objects.nonNull(command.schedulable().getEnd())) { + starter.setVariable("end", command.schedulable().getEnd().toString()); + } + + if (Objects.nonNull(command.schedulable().getSchedule())) { + starter.setVariable("interval", command.schedulable().getSchedule().interval()) + .setVariable( + "periodicity", command.schedulable().getSchedule().periodicity()); + } + + starter.execute(); } - starter.execute(); - } - - private void deleteAnyActiveProcess(ScheduleCommand command) { - var runningProcess = processEngine - .getRuntimeService() - .createProcessInstanceQuery() - .processInstanceBusinessKey(command.businessKey()) - .singleResult(); - if (runningProcess != null) { - processEngine - .getRuntimeService() - .deleteProcessInstance(runningProcess.getProcessInstanceId(), "Schedule adjusted"); + private void deleteAnyActiveProcess(ScheduleCommand command) { + var runningProcess = processEngine + .getRuntimeService() + .createProcessInstanceQuery() + .processInstanceBusinessKey(command.businessKey()) + .singleResult(); + if (runningProcess != null) { + processEngine + .getRuntimeService() + .deleteProcessInstance( + runningProcess.getProcessInstanceId(), "Schedule adjusted"); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/StartProcessHandler.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/StartProcessHandler.java new file mode 100644 index 00000000..d104d8a1 --- /dev/null +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/StartProcessHandler.java @@ -0,0 +1,33 @@ +package com.jongsoft.finance.bpmn.handler; + +import com.jongsoft.finance.annotation.BusinessEventListener; +import com.jongsoft.finance.messaging.CommandHandler; +import com.jongsoft.finance.messaging.commands.StartProcessCommand; + +import jakarta.inject.Singleton; + +import org.camunda.bpm.engine.ProcessEngine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class StartProcessHandler implements CommandHandler { + + private final Logger logger; + private final ProcessEngine processEngine; + + public StartProcessHandler(ProcessEngine processEngine) { + this.processEngine = processEngine; + this.logger = LoggerFactory.getLogger(StartProcessHandler.class); + } + + @Override + @BusinessEventListener + public void handle(StartProcessCommand command) { + logger.info("Received start process command for {}.", command.processDefinition()); + + processEngine + .getRuntimeService() + .startProcessInstanceByKey(command.processDefinition(), command.parameters()); + } +} diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/WarnBeforeExpiryHandler.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/WarnBeforeExpiryHandler.java index 3843c308..87b1fc7c 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/WarnBeforeExpiryHandler.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/handler/WarnBeforeExpiryHandler.java @@ -6,36 +6,41 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.WarnBeforeExpiryCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.ProcessEngine; @Slf4j @Singleton class WarnBeforeExpiryHandler implements CommandHandler { - private final ProcessEngine processEngine; - private final AuthenticationFacade authenticationFacade; - - WarnBeforeExpiryHandler(ProcessEngine processEngine, AuthenticationFacade authenticationFacade) { - this.processEngine = processEngine; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(WarnBeforeExpiryCommand command) { - log.trace("[{}] - Starting the BPMN process to warn before contract expires.", command.id()); - - var newDueDate = command.endDate().minusMonths(1); - - processEngine - .getRuntimeService() - .createProcessInstanceByKey(KnownProcesses.CONTRACT_WARN_EXPIRY) - .businessKey("contract_term_" + command.id()) - .setVariable("warnAt", DateUtils.toDate(newDueDate)) - .setVariable("username", authenticationFacade.authenticated()) - .setVariable("contractId", command.id()) - .execute(); - } + private final ProcessEngine processEngine; + private final AuthenticationFacade authenticationFacade; + + WarnBeforeExpiryHandler( + ProcessEngine processEngine, AuthenticationFacade authenticationFacade) { + this.processEngine = processEngine; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(WarnBeforeExpiryCommand command) { + log.trace( + "[{}] - Starting the BPMN process to warn before contract expires.", command.id()); + + var newDueDate = command.endDate().minusMonths(1); + + processEngine + .getRuntimeService() + .createProcessInstanceByKey(KnownProcesses.CONTRACT_WARN_EXPIRY) + .businessKey("contract_term_" + command.id()) + .setVariable("warnAt", DateUtils.toDate(newDueDate)) + .setVariable("username", authenticationFacade.authenticated()) + .setVariable("contractId", command.id()) + .execute(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StartProcessListener.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StartProcessListener.java index 473763b0..0f7d166a 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StartProcessListener.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StartProcessListener.java @@ -4,9 +4,13 @@ import com.jongsoft.finance.domain.user.UserIdentifier; import com.jongsoft.finance.messaging.InternalAuthenticationEvent; import com.jongsoft.finance.security.AuthenticationFacade; + import io.micronaut.context.event.ApplicationEventPublisher; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.ExecutionListener; @@ -14,33 +18,33 @@ @Singleton public class StartProcessListener implements ExecutionListener, JavaBean { - private final ApplicationEventPublisher eventPublisher; - private final AuthenticationFacade authenticationFacade; - - StartProcessListener( - ApplicationEventPublisher eventPublisher, - AuthenticationFacade authenticationFacade) { - this.eventPublisher = eventPublisher; - this.authenticationFacade = authenticationFacade; - } - - @Override - public void notify(DelegateExecution execution) { - if (execution.hasVariable("username") && authenticationFacade.authenticated() == null) { - var username = (UserIdentifier) execution.getVariable("username"); - - log.info( - "[{}-{}] Correcting authentication to user {}", - execution.getProcessDefinitionId(), - execution.getCurrentActivityName(), - username); - - log.trace( - "[{}] Setting up security credentials for {}", - execution.getProcessDefinitionId(), - username); - - eventPublisher.publishEvent(new InternalAuthenticationEvent(this, username.email())); + private final ApplicationEventPublisher eventPublisher; + private final AuthenticationFacade authenticationFacade; + + StartProcessListener( + ApplicationEventPublisher eventPublisher, + AuthenticationFacade authenticationFacade) { + this.eventPublisher = eventPublisher; + this.authenticationFacade = authenticationFacade; + } + + @Override + public void notify(DelegateExecution execution) { + if (execution.hasVariable("username") && authenticationFacade.authenticated() == null) { + var username = (UserIdentifier) execution.getVariable("username"); + + log.info( + "[{}-{}] Correcting authentication to user {}", + execution.getProcessDefinitionId(), + execution.getCurrentActivityName(), + username); + + log.trace( + "[{}] Setting up security credentials for {}", + execution.getProcessDefinitionId(), + username); + + eventPublisher.publishEvent(new InternalAuthenticationEvent(this, username.email())); + } } - } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StopProcessListener.java b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StopProcessListener.java index d6b4fd0d..5a8f1ce3 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StopProcessListener.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/bpmn/listeners/StopProcessListener.java @@ -1,8 +1,11 @@ package com.jongsoft.finance.bpmn.listeners; import com.jongsoft.finance.core.JavaBean; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.ExecutionListener; @@ -10,10 +13,10 @@ @Singleton public class StopProcessListener implements ExecutionListener, JavaBean { - @Override - public void notify(DelegateExecution execution) { - log.info("[{}] Finish business process", execution.getProcessDefinitionId()); - execution.removeVariablesLocal(); - execution.removeVariables(); - } + @Override + public void notify(DelegateExecution execution) { + log.info("[{}] Finish business process", execution.getProcessDefinitionId()); + execution.removeVariablesLocal(); + execution.removeVariables(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/AccountJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/AccountJson.java index 69f74a6e..ca72b1fb 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/AccountJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/AccountJson.java @@ -2,65 +2,69 @@ import com.jongsoft.finance.domain.account.Account; import com.jongsoft.finance.schedule.Periodicity; + import io.micronaut.core.annotation.NonNull; import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; -import java.util.function.Supplier; + import lombok.Builder; import lombok.Data; + import org.bouncycastle.util.encoders.Hex; +import java.io.Serializable; +import java.util.function.Supplier; + @Data @Builder @Serdeable @JsonSchema( - uri = "/account", - title = "Account", - description = "A account is a financial account that can be used to record transactions.") + uri = "/account", + title = "Account", + description = "A account is a financial account that can be used to record transactions.") public class AccountJson implements Serializable { - /** The name of the account. */ - @NonNull - private String name; - - /** The description of the account. */ - private String description; - - /** The currency of the account, in a 3-letter ISO currency code. */ - @NonNull - private String currency; - - /** The icon of the account, in a base64 encoded string. */ - private String icon; - - private double interest; - private Periodicity periodicity; - - private String iban; - private String bic; - private String number; - - /** The type of the account. */ - @NonNull - private String type; - - public static AccountJson fromDomain(Account account, Supplier iconSupplier) { - var builder = AccountJson.builder() - .bic(account.getBic()) - .currency(account.getCurrency()) - .description(account.getDescription()) - .iban(account.getIban()) - .number(account.getNumber()) - .type(account.getType()) - .name(account.getName()) - .periodicity(account.getInterestPeriodicity()) - .interest(account.getInterest()); - - if (account.getImageFileToken() != null) { - builder.icon(Hex.toHexString(iconSupplier.get())); - } + /** The name of the account. */ + @NonNull + private String name; - return builder.build(); - } + /** The description of the account. */ + private String description; + + /** The currency of the account, in a 3-letter ISO currency code. */ + @NonNull + private String currency; + + /** The icon of the account, in a base64 encoded string. */ + private String icon; + + private double interest; + private Periodicity periodicity; + + private String iban; + private String bic; + private String number; + + /** The type of the account. */ + @NonNull + private String type; + + public static AccountJson fromDomain(Account account, Supplier iconSupplier) { + var builder = AccountJson.builder() + .bic(account.getBic()) + .currency(account.getCurrency()) + .description(account.getDescription()) + .iban(account.getIban()) + .number(account.getNumber()) + .type(account.getType()) + .name(account.getName()) + .periodicity(account.getInterestPeriodicity()) + .interest(account.getInterest()); + + if (account.getImageFileToken() != null) { + builder.icon(Hex.toHexString(iconSupplier.get())); + } + + return builder.build(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/BudgetJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/BudgetJson.java index f4fb03b8..fcb40201 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/BudgetJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/BudgetJson.java @@ -1,15 +1,18 @@ package com.jongsoft.finance.serialized; import com.jongsoft.finance.domain.user.Budget; + import io.micronaut.core.annotation.NonNull; import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; + +import lombok.Builder; +import lombok.Data; + import java.io.Serializable; import java.time.LocalDate; import java.util.List; import java.util.stream.Collectors; -import lombok.Builder; -import lombok.Data; @Data @Builder @@ -17,43 +20,43 @@ @JsonSchema(title = "Budget", description = "Budget for a user", uri = "/budget") public class BudgetJson implements Serializable { - @Data - @Builder - @Serdeable - @JsonSchema(title = "Expense", description = "Expense for a budget", uri = "/budget-expense") - public static class ExpenseJson implements Serializable { + @Data + @Builder + @Serdeable + @JsonSchema(title = "Expense", description = "Expense for a budget", uri = "/budget-expense") + public static class ExpenseJson implements Serializable { + @NonNull + private String name; + + private double lowerBound; + private double upperBound; + } + + /** Start date of the budget, in ISO 8601 format. */ + @NonNull + private LocalDate start; + + private LocalDate end; + + /** Expected income for the budget. */ + private double expectedIncome; + + /** List of expenses for the budget. */ @NonNull - private String name; - - private double lowerBound; - private double upperBound; - } - - /** Start date of the budget, in ISO 8601 format. */ - @NonNull - private LocalDate start; - - private LocalDate end; - - /** Expected income for the budget. */ - private double expectedIncome; - - /** List of expenses for the budget. */ - @NonNull - private List expenses; - - public static BudgetJson fromDomain(Budget budget) { - return BudgetJson.builder() - .start(budget.getStart()) - .end(budget.getEnd()) - .expectedIncome(budget.getExpectedIncome()) - .expenses(budget.getExpenses().stream() - .map(e -> ExpenseJson.builder() - .name(e.getName()) - .lowerBound(e.getLowerBound()) - .upperBound(e.getUpperBound()) - .build()) - .collect(Collectors.toList())) - .build(); - } + private List expenses; + + public static BudgetJson fromDomain(Budget budget) { + return BudgetJson.builder() + .start(budget.getStart()) + .end(budget.getEnd()) + .expectedIncome(budget.getExpectedIncome()) + .expenses(budget.getExpenses().stream() + .map(e -> ExpenseJson.builder() + .name(e.getName()) + .lowerBound(e.getLowerBound()) + .upperBound(e.getUpperBound()) + .build()) + .collect(Collectors.toList())) + .build(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/CategoryJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/CategoryJson.java index ca9fa061..6aabbfb8 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/CategoryJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/CategoryJson.java @@ -1,28 +1,31 @@ package com.jongsoft.finance.serialized; import com.jongsoft.finance.domain.user.Category; + import io.micronaut.core.annotation.NonNull; import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; + import lombok.Builder; import lombok.Data; +import java.io.Serializable; + @Data @Builder @Serdeable @JsonSchema(title = "Category", description = "Category of a transaction", uri = "/category") public class CategoryJson implements Serializable { - @NonNull - private String label; + @NonNull + private String label; - private String description; + private String description; - public static CategoryJson fromDomain(Category category) { - return CategoryJson.builder() - .label(category.getLabel()) - .description(category.getDescription()) - .build(); - } + public static CategoryJson fromDomain(Category category) { + return CategoryJson.builder() + .label(category.getLabel()) + .description(category.getDescription()) + .build(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ContractJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ContractJson.java index 8310cf49..d3b61e89 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ContractJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ContractJson.java @@ -1,54 +1,58 @@ package com.jongsoft.finance.serialized; import com.jongsoft.finance.domain.account.Contract; + import io.micronaut.core.annotation.NonNull; import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; -import java.time.LocalDate; -import java.util.function.Supplier; + import lombok.Builder; import lombok.Data; + import org.bouncycastle.util.encoders.Hex; +import java.io.Serializable; +import java.time.LocalDate; +import java.util.function.Supplier; + @Data @Builder @Serdeable @JsonSchema(title = "Contract", description = "Contract details", uri = "/contract") public class ContractJson implements Serializable { - @NonNull - private String name; + @NonNull + private String name; - private String description; + private String description; - @NonNull - private String company; + @NonNull + private String company; - /** The contract attachment as a hex string. */ - private String contract; + /** The contract attachment as a hex string. */ + private String contract; - private boolean terminated; + private boolean terminated; - @NonNull - private LocalDate start; + @NonNull + private LocalDate start; - @NonNull - private LocalDate end; + @NonNull + private LocalDate end; - public static ContractJson fromDomain(Contract contract, Supplier attachmentSupplier) { - ContractJsonBuilder builder = ContractJson.builder() - .name(contract.getName()) - .description(contract.getDescription()) - .company(contract.getCompany().getName()) - .start(contract.getStartDate()) - .end(contract.getEndDate()) - .terminated(contract.isTerminated()); + public static ContractJson fromDomain(Contract contract, Supplier attachmentSupplier) { + ContractJsonBuilder builder = ContractJson.builder() + .name(contract.getName()) + .description(contract.getDescription()) + .company(contract.getCompany().getName()) + .start(contract.getStartDate()) + .end(contract.getEndDate()) + .terminated(contract.isTerminated()); - if (contract.isUploaded()) { - builder.contract(Hex.toHexString(attachmentSupplier.get())); - } + if (contract.isUploaded()) { + builder.contract(Hex.toHexString(attachmentSupplier.get())); + } - return builder.build(); - } + return builder.build(); + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExportJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExportJson.java index 228bb9df..2a5a926d 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExportJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExportJson.java @@ -2,22 +2,24 @@ import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; -import java.util.List; + import lombok.Builder; import lombok.Data; +import java.io.Serializable; +import java.util.List; + @Data @Builder @Serdeable @JsonSchema(title = "Profile", description = "A user profile", uri = "/profile") public class ExportJson implements Serializable { - private List accounts; - private List rules; - private List categories; - private List budgetPeriods; - private List contracts; - private List tags; - private List transactions; + private List accounts; + private List rules; + private List categories; + private List budgetPeriods; + private List contracts; + private List tags; + private List transactions; } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExtractedAccountLookup.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExtractedAccountLookup.java index 49e52ef6..4d6011d2 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExtractedAccountLookup.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ExtractedAccountLookup.java @@ -1,23 +1,25 @@ package com.jongsoft.finance.serialized; import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; + import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; +import java.io.Serializable; + @Serdeable @Getter @Setter @EqualsAndHashCode(of = {"name", "iban"}) public final class ExtractedAccountLookup implements Serializable { - private final String name; - private final String iban; - private final String description; + private final String name; + private final String iban; + private final String description; - public ExtractedAccountLookup(String name, String iban, String description) { - this.name = name; - this.iban = iban; - this.description = description; - } + public ExtractedAccountLookup(String name, String iban, String description) { + this.name = name; + this.iban = iban; + this.description = description; + } } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ImportJobSettings.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ImportJobSettings.java index b247d49a..90d8a166 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ImportJobSettings.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/ImportJobSettings.java @@ -3,12 +3,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.jongsoft.finance.ProcessVariable; import com.jongsoft.finance.importer.api.ImporterConfiguration; + import io.micronaut.serde.annotation.Serdeable; @Serdeable public record ImportJobSettings( - @JsonProperty ImporterConfiguration importConfiguration, - @JsonProperty boolean applyRules, - @JsonProperty boolean generateAccounts, - @JsonProperty Long accountId) - implements ProcessVariable {} + @JsonProperty ImporterConfiguration importConfiguration, + @JsonProperty boolean applyRules, + @JsonProperty boolean generateAccounts, + @JsonProperty Long accountId) + implements ProcessVariable {} diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/RuleConfigJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/RuleConfigJson.java index 2fb752f3..7328eb67 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/RuleConfigJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/RuleConfigJson.java @@ -3,79 +3,85 @@ import com.jongsoft.finance.core.RuleColumn; import com.jongsoft.finance.core.RuleOperation; import com.jongsoft.finance.domain.transaction.TransactionRule; + import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; + +import lombok.Builder; +import lombok.Data; + import java.io.Serializable; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Collectors; -import lombok.Builder; -import lombok.Data; @Data @Builder @Serdeable @JsonSchema( - title = "Transaction Rules", - description = "Configuration for transaction rules", - uri = "/rules") + title = "Transaction Rules", + description = "Configuration for transaction rules", + uri = "/rules") public class RuleConfigJson implements Serializable { - @Data - @Builder - @Serdeable - @JsonSchema(title = "Transaction Rule", description = "A single transaction rule", uri = "/rule") - public static class RuleJson implements Serializable { - private String name; - private String description; - private boolean restrictive; - private boolean active; - private int sort; - private String group; - private List conditions; - private List changes; + @Data + @Builder + @Serdeable + @JsonSchema( + title = "Transaction Rule", + description = "A single transaction rule", + uri = "/rule") + public static class RuleJson implements Serializable { + private String name; + private String description; + private boolean restrictive; + private boolean active; + private int sort; + private String group; + private List conditions; + private List changes; - public static RuleJson fromDomain( - TransactionRule rule, BiFunction lookup) { - return RuleJson.builder() - .active(rule.isActive()) - .restrictive(rule.isRestrictive()) - .name(rule.getName()) - .group(rule.getGroup()) - .changes(rule.getChanges().stream() - .map(c -> ChangeJson.builder() - .field(c.getField()) - .value(lookup.apply(c.getField(), c.getChange())) - .build()) - .collect(Collectors.toList())) - .conditions(rule.getConditions().stream() - .map(c -> ConditionJson.builder() - .field(c.getField()) - .operation(c.getOperation()) - .value(c.getCondition()) - .build()) - .collect(Collectors.toList())) - .build(); + public static RuleJson fromDomain( + TransactionRule rule, BiFunction lookup) { + return RuleJson.builder() + .active(rule.isActive()) + .restrictive(rule.isRestrictive()) + .name(rule.getName()) + .group(rule.getGroup()) + .changes(rule.getChanges().stream() + .map(c -> ChangeJson.builder() + .field(c.getField()) + .value(lookup.apply(c.getField(), c.getChange())) + .build()) + .collect(Collectors.toList())) + .conditions(rule.getConditions().stream() + .map(c -> ConditionJson.builder() + .field(c.getField()) + .operation(c.getOperation()) + .value(c.getCondition()) + .build()) + .collect(Collectors.toList())) + .build(); + } } - } - @Data - @Builder - @Serdeable - public static class ConditionJson implements Serializable { - private RuleColumn field; - private RuleOperation operation; - private String value; - } + @Data + @Builder + @Serdeable + public static class ConditionJson implements Serializable { + private RuleColumn field; + private RuleOperation operation; + private String value; + } - @Data - @Builder - @Serdeable - public static class ChangeJson implements Serializable { - private RuleColumn field; - private String value; - } + @Data + @Builder + @Serdeable + public static class ChangeJson implements Serializable { + private RuleColumn field; + private String value; + } - private String slug; - private List rules; + private String slug; + private List rules; } diff --git a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/TransactionJson.java b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/TransactionJson.java index 4e10fc1e..33b9d9e9 100644 --- a/bpmn-process/src/main/java/com/jongsoft/finance/serialized/TransactionJson.java +++ b/bpmn-process/src/main/java/com/jongsoft/finance/serialized/TransactionJson.java @@ -1,52 +1,55 @@ package com.jongsoft.finance.serialized; import com.jongsoft.finance.domain.transaction.Transaction; + import io.micronaut.core.annotation.NonNull; import io.micronaut.jsonschema.JsonSchema; import io.micronaut.serde.annotation.Serdeable; -import java.io.Serializable; -import java.time.LocalDate; + import lombok.Builder; import lombok.Data; +import java.io.Serializable; +import java.time.LocalDate; + @Data @Builder @Serdeable @JsonSchema(title = "Transaction", description = "Transaction details", uri = "/transaction") public class TransactionJson implements Serializable { - /** The account from which the transaction was made. */ - @NonNull - private final String fromAccount; + /** The account from which the transaction was made. */ + @NonNull + private final String fromAccount; - /** The account to which the transaction was made. */ - @NonNull - private final String toAccount; + /** The account to which the transaction was made. */ + @NonNull + private final String toAccount; - @NonNull - private final String description; + @NonNull + private final String description; - @NonNull - private final String currency; + @NonNull + private final String currency; - private final double amount; + private final double amount; - @NonNull - private final LocalDate date; + @NonNull + private final LocalDate date; - private final LocalDate interestDate; - private final LocalDate bookDate; + private final LocalDate interestDate; + private final LocalDate bookDate; - public static TransactionJson fromDomain(Transaction transaction) { - return TransactionJson.builder() - .fromAccount(transaction.computeFrom().getName()) - .toAccount(transaction.computeTo().getName()) - .description(transaction.getDescription()) - .currency(transaction.getCurrency()) - .amount(transaction.computeAmount(transaction.computeFrom())) - .date(transaction.getDate()) - .interestDate(transaction.getInterestDate()) - .bookDate(transaction.getBookDate()) - .build(); - } + public static TransactionJson fromDomain(Transaction transaction) { + return TransactionJson.builder() + .fromAccount(transaction.computeFrom().getName()) + .toAccount(transaction.computeTo().getName()) + .description(transaction.getDescription()) + .currency(transaction.getCurrency()) + .amount(transaction.computeAmount(transaction.computeFrom())) + .date(transaction.getDate()) + .interestDate(transaction.getInterestDate()) + .bookDate(transaction.getBookDate()) + .build(); + } } diff --git a/bpmn-process/src/main/resources/bpmn/profile/user.profile.register.bpmn b/bpmn-process/src/main/resources/bpmn/profile/user.profile.register.bpmn index 57b3977d..0e903896 100644 --- a/bpmn-process/src/main/resources/bpmn/profile/user.profile.register.bpmn +++ b/bpmn-process/src/main/resources/bpmn/profile/user.profile.register.bpmn @@ -1,5 +1,5 @@ - + sf_locate_account @@ -15,10 +15,6 @@ ${usernameAvailable} - - SequenceFlow_1q5pqa4 - SequenceFlow_102zsei - @@ -46,10 +42,8 @@ - SequenceFlow_102zsei + Flow_156ycw4 - - @@ -58,7 +52,7 @@ Flow_0nf51zz - SequenceFlow_1q5pqa4 + Flow_090lmet @@ -71,6 +65,19 @@ Flow_0nf51zz + + + + ${username.email()} + user-registered + ${mailProperties} + + + Flow_090lmet + Flow_156ycw4 + + + @@ -84,9 +91,6 @@ - - - @@ -111,6 +115,10 @@ + + + + @@ -139,18 +147,18 @@ - - - - - - - - + + + + + + + + diff --git a/bpmn-process/src/test/java/com/jongsoft/finance/bpmn/feature/junit/ProcessTestExtension.java b/bpmn-process/src/test/java/com/jongsoft/finance/bpmn/feature/junit/ProcessTestExtension.java index 9adb9267..7ecbcdd1 100644 --- a/bpmn-process/src/test/java/com/jongsoft/finance/bpmn/feature/junit/ProcessTestExtension.java +++ b/bpmn-process/src/test/java/com/jongsoft/finance/bpmn/feature/junit/ProcessTestExtension.java @@ -7,6 +7,7 @@ import com.jongsoft.finance.domain.account.Account; import com.jongsoft.finance.messaging.commands.account.CreateAccountCommand; import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Control; import io.micronaut.context.ApplicationContext; import io.micronaut.inject.qualifiers.Qualifiers; @@ -43,6 +44,7 @@ public void beforeAll(ExtensionContext extensionContext) { applicationContext.registerSingleton(FinTrack.class, applicationBean); applicationContext.registerSingleton(StorageService.class, Mockito.spy(StorageService.class), Qualifiers.byName("storageService")); applicationContext.registerSingleton(MailDaemon.class, Mockito.spy(MailDaemon.class), Qualifiers.byName("mailDaemon")); + applicationContext.registerSingleton(AuthenticationFacade.class, Mockito.spy(AuthenticationFacade.class)); applicationContext.registerSingleton(new Consumer() { @EventListener public void accept(CreateAccountCommand accountCreatedEvent) { diff --git a/build.gradle.kts b/build.gradle.kts index 981deeee..cc04bd02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,11 +44,10 @@ subprojects { spotless { java { target("src/main/java/**") - importOrder() removeUnusedImports() trimTrailingWhitespace() endWithNewline() - palantirJavaFormat("2.68.0").style("GOOGLE") + palantirJavaFormat("2.75.0").style("AOSP") } } diff --git a/core/src/main/java/com/jongsoft/finance/annotation/BusinessEventListener.java b/core/src/main/java/com/jongsoft/finance/annotation/BusinessEventListener.java index 44e7b6b7..d6c452e5 100644 --- a/core/src/main/java/com/jongsoft/finance/annotation/BusinessEventListener.java +++ b/core/src/main/java/com/jongsoft/finance/annotation/BusinessEventListener.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.annotation; import io.micronaut.runtime.event.annotation.EventListener; + import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/core/src/main/java/com/jongsoft/finance/annotation/BusinessMethod.java b/core/src/main/java/com/jongsoft/finance/annotation/BusinessMethod.java index 99af581e..cbd89caf 100644 --- a/core/src/main/java/com/jongsoft/finance/annotation/BusinessMethod.java +++ b/core/src/main/java/com/jongsoft/finance/annotation/BusinessMethod.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.annotation; import io.micronaut.aop.Around; + import java.lang.annotation.*; /** diff --git a/core/src/main/java/com/jongsoft/finance/configuration/SecuritySettings.java b/core/src/main/java/com/jongsoft/finance/configuration/SecuritySettings.java index b5840311..fc069d84 100644 --- a/core/src/main/java/com/jongsoft/finance/configuration/SecuritySettings.java +++ b/core/src/main/java/com/jongsoft/finance/configuration/SecuritySettings.java @@ -2,16 +2,17 @@ import io.micronaut.context.annotation.ConfigurationProperties; import io.micronaut.core.bind.annotation.Bindable; + import jakarta.validation.constraints.NotNull; @ConfigurationProperties("micronaut.application.security") public interface SecuritySettings { - @NotNull - @Bindable(defaultValue = "sample-secret") - String getSecret(); + @NotNull + @Bindable(defaultValue = "sample-secret") + String getSecret(); - @NotNull - @Bindable(defaultValue = "true") - boolean isEncrypt(); + @NotNull + @Bindable(defaultValue = "true") + boolean isEncrypt(); } diff --git a/core/src/main/java/com/jongsoft/finance/configuration/StorageSettings.java b/core/src/main/java/com/jongsoft/finance/configuration/StorageSettings.java index 78363517..9e45c16d 100644 --- a/core/src/main/java/com/jongsoft/finance/configuration/StorageSettings.java +++ b/core/src/main/java/com/jongsoft/finance/configuration/StorageSettings.java @@ -2,12 +2,13 @@ import io.micronaut.context.annotation.ConfigurationProperties; import io.micronaut.core.bind.annotation.Bindable; + import jakarta.validation.constraints.NotNull; @ConfigurationProperties("micronaut.application.storage") public interface StorageSettings { - @NotNull - @Bindable - String getLocation(); + @NotNull + @Bindable + String getLocation(); } diff --git a/core/src/main/java/com/jongsoft/finance/core/AggregateBase.java b/core/src/main/java/com/jongsoft/finance/core/AggregateBase.java index 65a0d6d3..9e9ba136 100644 --- a/core/src/main/java/com/jongsoft/finance/core/AggregateBase.java +++ b/core/src/main/java/com/jongsoft/finance/core/AggregateBase.java @@ -4,6 +4,6 @@ public interface AggregateBase extends Serializable { - /** Returns the unique identifier of the aggregate. */ - Long getId(); + /** Returns the unique identifier of the aggregate. */ + Long getId(); } diff --git a/core/src/main/java/com/jongsoft/finance/core/DateUtils.java b/core/src/main/java/com/jongsoft/finance/core/DateUtils.java index 637e74e3..49efa4f3 100644 --- a/core/src/main/java/com/jongsoft/finance/core/DateUtils.java +++ b/core/src/main/java/com/jongsoft/finance/core/DateUtils.java @@ -2,6 +2,7 @@ import com.jongsoft.lang.Dates; import com.jongsoft.lang.time.Range; + import java.time.LocalDate; import java.time.ZoneId; import java.time.ZoneOffset; @@ -11,40 +12,40 @@ /** Date utilities. */ public interface DateUtils { - static LocalDate startOfMonth(int year, int month) { - return LocalDate.of(year, month, 1); - } - - /** Returns the last day of the month. */ - static LocalDate endOfMonth(int year, int month) { - return LocalDate.of(year, month, 1).plusMonths(1).minusDays(1); - } + static LocalDate startOfMonth(int year, int month) { + return LocalDate.of(year, month, 1); + } - static Long timestamp(LocalDate localDate) { - if (localDate == null) { - return null; + /** Returns the last day of the month. */ + static LocalDate endOfMonth(int year, int month) { + return LocalDate.of(year, month, 1).plusMonths(1).minusDays(1); } - return toDate(localDate).getTime(); - } + static Long timestamp(LocalDate localDate) { + if (localDate == null) { + return null; + } - static LocalDate toLocalDate(Date date) { - if (date == null) { - return null; + return toDate(localDate).getTime(); } - return LocalDate.ofInstant(date.toInstant(), ZoneId.of("UTC")); - } - static Date toDate(LocalDate localDate) { - if (localDate == null) { - return null; + static LocalDate toLocalDate(Date date) { + if (date == null) { + return null; + } + return LocalDate.ofInstant(date.toInstant(), ZoneId.of("UTC")); } - return Date.from(localDate.atStartOfDay().toInstant(ZoneOffset.UTC)); - } + static Date toDate(LocalDate localDate) { + if (localDate == null) { + return null; + } - static Range forMonth(int year, int month) { - var start = LocalDate.of(year, month, 1); - return Dates.range(start, ChronoUnit.MONTHS); - } + return Date.from(localDate.atStartOfDay().toInstant(ZoneOffset.UTC)); + } + + static Range forMonth(int year, int month) { + var start = LocalDate.of(year, month, 1); + return Dates.range(start, ChronoUnit.MONTHS); + } } diff --git a/core/src/main/java/com/jongsoft/finance/core/Encoder.java b/core/src/main/java/com/jongsoft/finance/core/Encoder.java index 17218b11..50af0ed3 100644 --- a/core/src/main/java/com/jongsoft/finance/core/Encoder.java +++ b/core/src/main/java/com/jongsoft/finance/core/Encoder.java @@ -6,20 +6,20 @@ */ public interface Encoder { - /** - * Encrypt any random string value to a hash. - * - * @param value the value to hash - * @return the hash value - */ - String encrypt(String value); + /** + * Encrypt any random string value to a hash. + * + * @param value the value to hash + * @return the hash value + */ + String encrypt(String value); - /** - * Calculate if the provided hash and value match. - * - * @param encoded the hash value - * @param value the raw value - * @return true if they match - */ - boolean matches(String encoded, String value); + /** + * Calculate if the provided hash and value match. + * + * @param encoded the hash value + * @param value the raw value + * @return true if they match + */ + boolean matches(String encoded, String value); } diff --git a/core/src/main/java/com/jongsoft/finance/core/FailureCode.java b/core/src/main/java/com/jongsoft/finance/core/FailureCode.java index 9cd6e04a..23907e78 100644 --- a/core/src/main/java/com/jongsoft/finance/core/FailureCode.java +++ b/core/src/main/java/com/jongsoft/finance/core/FailureCode.java @@ -1,7 +1,7 @@ package com.jongsoft.finance.core; public enum FailureCode { - FROM_TO_SAME, - AMOUNT_NOT_NULL, - POSSIBLE_DUPLICATE + FROM_TO_SAME, + AMOUNT_NOT_NULL, + POSSIBLE_DUPLICATE } diff --git a/core/src/main/java/com/jongsoft/finance/core/MailDaemon.java b/core/src/main/java/com/jongsoft/finance/core/MailDaemon.java index a50206c0..9deb31a3 100644 --- a/core/src/main/java/com/jongsoft/finance/core/MailDaemon.java +++ b/core/src/main/java/com/jongsoft/finance/core/MailDaemon.java @@ -4,5 +4,5 @@ public interface MailDaemon extends JavaBean { - void send(String recipient, String template, Properties mailProperties); + void send(String recipient, String template, Properties mailProperties); } diff --git a/core/src/main/java/com/jongsoft/finance/core/Process.java b/core/src/main/java/com/jongsoft/finance/core/Process.java index 6163bb44..acad7bf8 100644 --- a/core/src/main/java/com/jongsoft/finance/core/Process.java +++ b/core/src/main/java/com/jongsoft/finance/core/Process.java @@ -2,5 +2,5 @@ public interface Process { - T run(Y unitOfWork); + T run(Y unitOfWork); } diff --git a/core/src/main/java/com/jongsoft/finance/core/Removable.java b/core/src/main/java/com/jongsoft/finance/core/Removable.java index a8a84ed0..ded6c1d8 100644 --- a/core/src/main/java/com/jongsoft/finance/core/Removable.java +++ b/core/src/main/java/com/jongsoft/finance/core/Removable.java @@ -2,5 +2,5 @@ public interface Removable { - void delete(); + void delete(); } diff --git a/core/src/main/java/com/jongsoft/finance/core/RuleColumn.java b/core/src/main/java/com/jongsoft/finance/core/RuleColumn.java index 62aaadd1..a463e20e 100644 --- a/core/src/main/java/com/jongsoft/finance/core/RuleColumn.java +++ b/core/src/main/java/com/jongsoft/finance/core/RuleColumn.java @@ -1,14 +1,14 @@ package com.jongsoft.finance.core; public enum RuleColumn { - SOURCE_ACCOUNT, - TO_ACCOUNT, - DESCRIPTION, - AMOUNT, - CATEGORY, - CHANGE_TRANSFER_TO, - CHANGE_TRANSFER_FROM, - BUDGET, - CONTRACT, - TAGS + SOURCE_ACCOUNT, + TO_ACCOUNT, + DESCRIPTION, + AMOUNT, + CATEGORY, + CHANGE_TRANSFER_TO, + CHANGE_TRANSFER_FROM, + BUDGET, + CONTRACT, + TAGS } diff --git a/core/src/main/java/com/jongsoft/finance/core/RuleOperation.java b/core/src/main/java/com/jongsoft/finance/core/RuleOperation.java index d3e74cbf..29d572e8 100644 --- a/core/src/main/java/com/jongsoft/finance/core/RuleOperation.java +++ b/core/src/main/java/com/jongsoft/finance/core/RuleOperation.java @@ -1,9 +1,9 @@ package com.jongsoft.finance.core; public enum RuleOperation { - EQUALS, - CONTAINS, - STARTS_WITH, - LESS_THAN, - MORE_THAN + EQUALS, + CONTAINS, + STARTS_WITH, + LESS_THAN, + MORE_THAN } diff --git a/core/src/main/java/com/jongsoft/finance/core/SettingType.java b/core/src/main/java/com/jongsoft/finance/core/SettingType.java index ca7831d1..a1db82fc 100644 --- a/core/src/main/java/com/jongsoft/finance/core/SettingType.java +++ b/core/src/main/java/com/jongsoft/finance/core/SettingType.java @@ -1,8 +1,8 @@ package com.jongsoft.finance.core; public enum SettingType { - STRING, - NUMBER, - FLAG, - DATE + STRING, + NUMBER, + FLAG, + DATE } diff --git a/core/src/main/java/com/jongsoft/finance/core/SystemAccountTypes.java b/core/src/main/java/com/jongsoft/finance/core/SystemAccountTypes.java index 46046b77..48f55187 100644 --- a/core/src/main/java/com/jongsoft/finance/core/SystemAccountTypes.java +++ b/core/src/main/java/com/jongsoft/finance/core/SystemAccountTypes.java @@ -1,14 +1,14 @@ package com.jongsoft.finance.core; public enum SystemAccountTypes { - RECONCILE, - LOAN, - DEBT, - MORTGAGE, - DEBTOR, - CREDITOR; + RECONCILE, + LOAN, + DEBT, + MORTGAGE, + DEBTOR, + CREDITOR; - public String label() { - return this.name().toLowerCase(); - } + public String label() { + return this.name().toLowerCase(); + } } diff --git a/core/src/main/java/com/jongsoft/finance/core/TransactionType.java b/core/src/main/java/com/jongsoft/finance/core/TransactionType.java index 85b5efbd..efaab504 100644 --- a/core/src/main/java/com/jongsoft/finance/core/TransactionType.java +++ b/core/src/main/java/com/jongsoft/finance/core/TransactionType.java @@ -1,7 +1,7 @@ package com.jongsoft.finance.core; public enum TransactionType { - CREDIT, - DEBIT, - TRANSFER + CREDIT, + DEBIT, + TRANSFER } diff --git a/core/src/main/java/com/jongsoft/finance/core/exception/StatusException.java b/core/src/main/java/com/jongsoft/finance/core/exception/StatusException.java index a79e80cd..bf036c4f 100644 --- a/core/src/main/java/com/jongsoft/finance/core/exception/StatusException.java +++ b/core/src/main/java/com/jongsoft/finance/core/exception/StatusException.java @@ -2,48 +2,52 @@ public class StatusException extends RuntimeException { - private final int statusCode; - private final String localizationMessage; - - private StatusException(int statusCode, String message, String localizationMessage) { - super(message); - this.statusCode = statusCode; - this.localizationMessage = localizationMessage; - } - - public int getStatusCode() { - return statusCode; - } - - public String getLocalizationMessage() { - return localizationMessage; - } - - public static StatusException notFound(String message) { - return new StatusException(404, message, null); - } - - public static StatusException badRequest(String message) { - return new StatusException(400, message, null); - } - - public static StatusException badRequest(String message, String localizationMessage) { - return new StatusException(400, message, localizationMessage); - } - - public static StatusException notAuthorized(String message) { - return new StatusException(401, message, null); - } - - public static StatusException forbidden(String message) { - return new StatusException(403, message, null); - } - - public static StatusException internalError(String message) { - return new StatusException(500, message, null); - } - - public static StatusException internalError(String message, String localizationMessage) { - return new StatusException(500, message, localizationMessage); - } + private final int statusCode; + private final String localizationMessage; + + private StatusException(int statusCode, String message, String localizationMessage) { + super(message); + this.statusCode = statusCode; + this.localizationMessage = localizationMessage; + } + + public int getStatusCode() { + return statusCode; + } + + public String getLocalizationMessage() { + return localizationMessage; + } + + public static StatusException gone(String message) { + return new StatusException(410, message, null); + } + + public static StatusException notFound(String message) { + return new StatusException(404, message, null); + } + + public static StatusException badRequest(String message) { + return new StatusException(400, message, null); + } + + public static StatusException badRequest(String message, String localizationMessage) { + return new StatusException(400, message, localizationMessage); + } + + public static StatusException notAuthorized(String message) { + return new StatusException(401, message, null); + } + + public static StatusException forbidden(String message) { + return new StatusException(403, message, null); + } + + public static StatusException internalError(String message) { + return new StatusException(500, message, null); + } + + public static StatusException internalError(String message, String localizationMessage) { + return new StatusException(500, message, localizationMessage); + } } diff --git a/core/src/main/java/com/jongsoft/finance/math/MovingAverage.java b/core/src/main/java/com/jongsoft/finance/math/MovingAverage.java index 06b0a57b..03d3bd65 100644 --- a/core/src/main/java/com/jongsoft/finance/math/MovingAverage.java +++ b/core/src/main/java/com/jongsoft/finance/math/MovingAverage.java @@ -6,29 +6,29 @@ import java.util.Queue; public class MovingAverage { - private final Queue window = new ArrayDeque<>(); - private final int period; - private BigDecimal sum = BigDecimal.ZERO; + private final Queue window = new ArrayDeque<>(); + private final int period; + private BigDecimal sum = BigDecimal.ZERO; - public MovingAverage(int period) { - this.period = period; - } + public MovingAverage(int period) { + this.period = period; + } - public void add(BigDecimal num) { - sum = sum.add(num); - window.add(num); + public void add(BigDecimal num) { + sum = sum.add(num); + window.add(num); - if (window.size() > period) { - sum = sum.subtract(window.remove()); + if (window.size() > period) { + sum = sum.subtract(window.remove()); + } } - } - public BigDecimal getAverage() { - if (window.isEmpty()) { - return BigDecimal.ZERO; - } + public BigDecimal getAverage() { + if (window.isEmpty()) { + return BigDecimal.ZERO; + } - var divisor = BigDecimal.valueOf(window.size()); - return sum.divide(divisor, 2, RoundingMode.HALF_UP); - } + var divisor = BigDecimal.valueOf(window.size()); + return sum.divide(divisor, 2, RoundingMode.HALF_UP); + } } diff --git a/core/src/main/java/com/jongsoft/finance/schedule/Periodicity.java b/core/src/main/java/com/jongsoft/finance/schedule/Periodicity.java index 91fed7f9..30965858 100644 --- a/core/src/main/java/com/jongsoft/finance/schedule/Periodicity.java +++ b/core/src/main/java/com/jongsoft/finance/schedule/Periodicity.java @@ -1,24 +1,25 @@ package com.jongsoft.finance.schedule; import com.jongsoft.finance.core.exception.StatusException; + import java.time.temporal.ChronoUnit; public enum Periodicity { - MONTHS, - WEEKS, - YEARS; + MONTHS, + WEEKS, + YEARS; - /** - * Convert this periodicity to a {@link ChronoUnit} that can be used in the java time API. - * - * @return the ChronoUnit representing this periodicity - * @throws StatusException in case the periodicity cannot be converted to a ChronoUnit - */ - public ChronoUnit toChronoUnit() { - return switch (this) { - case WEEKS -> ChronoUnit.WEEKS; - case MONTHS -> ChronoUnit.MONTHS; - case YEARS -> ChronoUnit.YEARS; - }; - } + /** + * Convert this periodicity to a {@link ChronoUnit} that can be used in the java time API. + * + * @return the ChronoUnit representing this periodicity + * @throws StatusException in case the periodicity cannot be converted to a ChronoUnit + */ + public ChronoUnit toChronoUnit() { + return switch (this) { + case WEEKS -> ChronoUnit.WEEKS; + case MONTHS -> ChronoUnit.MONTHS; + case YEARS -> ChronoUnit.YEARS; + }; + } } diff --git a/core/src/main/java/com/jongsoft/finance/schedule/Schedulable.java b/core/src/main/java/com/jongsoft/finance/schedule/Schedulable.java index 2a5c45e2..9f6e939b 100644 --- a/core/src/main/java/com/jongsoft/finance/schedule/Schedulable.java +++ b/core/src/main/java/com/jongsoft/finance/schedule/Schedulable.java @@ -2,75 +2,76 @@ import com.jongsoft.finance.core.AggregateBase; import com.jongsoft.finance.core.exception.StatusException; + import java.time.LocalDate; public interface Schedulable extends AggregateBase { - /** - * Limits the execution of the schedule to start no earlier then the start date and execute no - * later then the provided end date. - * - * @param start the start date - * @param end the end date - */ - void limit(LocalDate start, LocalDate end); + /** + * Limits the execution of the schedule to start no earlier then the start date and execute no + * later then the provided end date. + * + * @param start the start date + * @param end the end date + */ + void limit(LocalDate start, LocalDate end); - /** - * Adjust the scheduling starting with the first next cycle. The current cycle is still - * completed by the old settings. - * - * @param periodicity the new periodicity - * @param interval the new interval of the periodicity - */ - void adjustSchedule(Periodicity periodicity, int interval); + /** + * Adjust the scheduling starting with the first next cycle. The current cycle is still + * completed by the old settings. + * + * @param periodicity the new periodicity + * @param interval the new interval of the periodicity + */ + void adjustSchedule(Periodicity periodicity, int interval); - LocalDate getStart(); + LocalDate getStart(); - LocalDate getEnd(); + LocalDate getEnd(); - Schedule getSchedule(); + Schedule getSchedule(); - /** - * Create a basic schedule without any modification options. Please note that the {@link - * #limit(LocalDate, LocalDate)} and {@link #adjustSchedule(Periodicity, int)} will always throw - * an {@link StatusException}. - * - * @param id the id of the entity - * @param endDate the end date of the schedule - * @param schedule the actual schedule - * @return - */ - static Schedulable basicSchedule(long id, LocalDate endDate, Schedule schedule) { - return new Schedulable() { - @Override - public void limit(LocalDate start, LocalDate end) { - throw StatusException.badRequest("Cannot limit schedule on a basic schedule."); - } + /** + * Create a basic schedule without any modification options. Please note that the {@link + * #limit(LocalDate, LocalDate)} and {@link #adjustSchedule(Periodicity, int)} will always throw + * an {@link StatusException}. + * + * @param id the id of the entity + * @param endDate the end date of the schedule + * @param schedule the actual schedule + * @return + */ + static Schedulable basicSchedule(long id, LocalDate endDate, Schedule schedule) { + return new Schedulable() { + @Override + public void limit(LocalDate start, LocalDate end) { + throw StatusException.badRequest("Cannot limit schedule on a basic schedule."); + } - @Override - public void adjustSchedule(Periodicity periodicity, int interval) { - throw StatusException.badRequest("Cannot adjust schedule on a basic schedule."); - } + @Override + public void adjustSchedule(Periodicity periodicity, int interval) { + throw StatusException.badRequest("Cannot adjust schedule on a basic schedule."); + } - @Override - public LocalDate getStart() { - return LocalDate.now().minusDays(1); - } + @Override + public LocalDate getStart() { + return LocalDate.now().minusDays(1); + } - @Override - public LocalDate getEnd() { - return endDate; - } + @Override + public LocalDate getEnd() { + return endDate; + } - @Override - public Schedule getSchedule() { - return schedule; - } + @Override + public Schedule getSchedule() { + return schedule; + } - @Override - public Long getId() { - return id; - } - }; - } + @Override + public Long getId() { + return id; + } + }; + } } diff --git a/core/src/main/java/com/jongsoft/finance/schedule/Schedule.java b/core/src/main/java/com/jongsoft/finance/schedule/Schedule.java index 15f02cfa..c388e6e6 100644 --- a/core/src/main/java/com/jongsoft/finance/schedule/Schedule.java +++ b/core/src/main/java/com/jongsoft/finance/schedule/Schedule.java @@ -5,27 +5,27 @@ public interface Schedule extends Serializable { - /** - * Fetch the periodicity of the schedule. Combined with the {@link #interval()} it determines - * the duration between two triggers. - * - * @return the periodicity - */ - Periodicity periodicity(); + /** + * Fetch the periodicity of the schedule. Combined with the {@link #interval()} it determines + * the duration between two triggers. + * + * @return the periodicity + */ + Periodicity periodicity(); - /** - * Fetch the interval of the schedule. Combined with the {@link #periodicity()} it determines - * the duration between two triggers. - * - * @return the interval - */ - int interval(); + /** + * Fetch the interval of the schedule. Combined with the {@link #periodicity()} it determines + * the duration between two triggers. + * + * @return the interval + */ + int interval(); - default LocalDate previous(LocalDate current) { - return current.minus(interval(), periodicity().toChronoUnit()); - } + default LocalDate previous(LocalDate current) { + return current.minus(interval(), periodicity().toChronoUnit()); + } - default LocalDate next(LocalDate current) { - return current.plus(interval(), periodicity().toChronoUnit()); - } + default LocalDate next(LocalDate current) { + return current.plus(interval(), periodicity().toChronoUnit()); + } } diff --git a/core/src/main/java/com/jongsoft/finance/security/Encryption.java b/core/src/main/java/com/jongsoft/finance/security/Encryption.java index 26845978..17e649d0 100644 --- a/core/src/main/java/com/jongsoft/finance/security/Encryption.java +++ b/core/src/main/java/com/jongsoft/finance/security/Encryption.java @@ -3,6 +3,7 @@ import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Base64; + import javax.crypto.Cipher; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; @@ -11,104 +12,110 @@ public class Encryption { - private static final String ALGORITHM_NAME = "AES/GCM/NoPadding"; - private static final int ALGORITHM_NONCE_SIZE = 12; - private static final int ALGORITHM_TAG_SIZE = 128; - private static final int ALGORITHM_KEY_SIZE = 128; - private static final String PBKDF2_NAME = "PBKDF2WithHmacSHA256"; - private static final int PBKDF2_SALT_SIZE = 16; - private static final int PBKDF2_ITERATIONS = 32767; - - private final byte[] securitySalt = new byte[PBKDF2_SALT_SIZE]; - private final byte[] nonce = new byte[ALGORITHM_NONCE_SIZE]; - - public Encryption() { - SecureRandom rand = new SecureRandom(); - rand.nextBytes(securitySalt); - rand.nextBytes(nonce); - } - - public synchronized byte[] encrypt(byte[] data, String password) { - try { - // Create an instance of PBKDF2 and derive a key. - var pwSpec = new PBEKeySpec( - password.toCharArray(), securitySalt, PBKDF2_ITERATIONS, ALGORITHM_KEY_SIZE); - var keyFactory = SecretKeyFactory.getInstance(PBKDF2_NAME); - var key = keyFactory.generateSecret(pwSpec).getEncoded(); - - // Encrypt and prepend salt. - var cipherTextAndNonce = encrypt(data, key); - var cipherTextAndNonceAndSalt = new byte[securitySalt.length + cipherTextAndNonce.length]; - System.arraycopy(securitySalt, 0, cipherTextAndNonceAndSalt, 0, securitySalt.length); - System.arraycopy( - cipherTextAndNonce, - 0, - cipherTextAndNonceAndSalt, - securitySalt.length, - cipherTextAndNonce.length); - - // Return as base64 string. - return Base64.getEncoder().encode(cipherTextAndNonceAndSalt); - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Unable to encrypt data", e); + private static final String ALGORITHM_NAME = "AES/GCM/NoPadding"; + private static final int ALGORITHM_NONCE_SIZE = 12; + private static final int ALGORITHM_TAG_SIZE = 128; + private static final int ALGORITHM_KEY_SIZE = 128; + private static final String PBKDF2_NAME = "PBKDF2WithHmacSHA256"; + private static final int PBKDF2_SALT_SIZE = 16; + private static final int PBKDF2_ITERATIONS = 32767; + + private final byte[] securitySalt = new byte[PBKDF2_SALT_SIZE]; + private final byte[] nonce = new byte[ALGORITHM_NONCE_SIZE]; + + public Encryption() { + SecureRandom rand = new SecureRandom(); + rand.nextBytes(securitySalt); + rand.nextBytes(nonce); + } + + public synchronized byte[] encrypt(byte[] data, String password) { + try { + // Create an instance of PBKDF2 and derive a key. + var pwSpec = new PBEKeySpec( + password.toCharArray(), securitySalt, PBKDF2_ITERATIONS, ALGORITHM_KEY_SIZE); + var keyFactory = SecretKeyFactory.getInstance(PBKDF2_NAME); + var key = keyFactory.generateSecret(pwSpec).getEncoded(); + + // Encrypt and prepend salt. + var cipherTextAndNonce = encrypt(data, key); + var cipherTextAndNonceAndSalt = + new byte[securitySalt.length + cipherTextAndNonce.length]; + System.arraycopy(securitySalt, 0, cipherTextAndNonceAndSalt, 0, securitySalt.length); + System.arraycopy( + cipherTextAndNonce, + 0, + cipherTextAndNonceAndSalt, + securitySalt.length, + cipherTextAndNonce.length); + + // Return as base64 string. + return Base64.getEncoder().encode(cipherTextAndNonceAndSalt); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to encrypt data", e); + } + } + + public synchronized byte[] decrypt(byte[] base64CiphertextAndNonceAndSalt, String password) { + try { + // Decode the base64. + var cipherTextAndNonceAndSalt = + Base64.getDecoder().decode(base64CiphertextAndNonceAndSalt); + + // Retrieve the salt and cipherTextAndNonce. + var salt = new byte[PBKDF2_SALT_SIZE]; + var cipherTextAndNonce = new byte[cipherTextAndNonceAndSalt.length - PBKDF2_SALT_SIZE]; + System.arraycopy(cipherTextAndNonceAndSalt, 0, salt, 0, salt.length); + System.arraycopy( + cipherTextAndNonceAndSalt, + salt.length, + cipherTextAndNonce, + 0, + cipherTextAndNonce.length); + + // Create an instance of PBKDF2 and derive the key. + var pwSpec = new PBEKeySpec( + password.toCharArray(), salt, PBKDF2_ITERATIONS, ALGORITHM_KEY_SIZE); + var keyFactory = SecretKeyFactory.getInstance(PBKDF2_NAME); + byte[] key = keyFactory.generateSecret(pwSpec).getEncoded(); + + // Decrypt and return result. + return decrypt(cipherTextAndNonce, key); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to decrypt data", e); + } } - } - - public synchronized byte[] decrypt(byte[] base64CiphertextAndNonceAndSalt, String password) { - try { - // Decode the base64. - var cipherTextAndNonceAndSalt = Base64.getDecoder().decode(base64CiphertextAndNonceAndSalt); - - // Retrieve the salt and cipherTextAndNonce. - var salt = new byte[PBKDF2_SALT_SIZE]; - var cipherTextAndNonce = new byte[cipherTextAndNonceAndSalt.length - PBKDF2_SALT_SIZE]; - System.arraycopy(cipherTextAndNonceAndSalt, 0, salt, 0, salt.length); - System.arraycopy( - cipherTextAndNonceAndSalt, salt.length, cipherTextAndNonce, 0, cipherTextAndNonce.length); - - // Create an instance of PBKDF2 and derive the key. - var pwSpec = - new PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, ALGORITHM_KEY_SIZE); - var keyFactory = SecretKeyFactory.getInstance(PBKDF2_NAME); - byte[] key = keyFactory.generateSecret(pwSpec).getEncoded(); - - // Decrypt and return result. - return decrypt(cipherTextAndNonce, key); - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Unable to decrypt data", e); + + private byte[] encrypt(byte[] plaintext, byte[] key) throws GeneralSecurityException { + // Create the cipher instance and initialize. + var encryptCipher = Cipher.getInstance(ALGORITHM_NAME); + encryptCipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(key, "AES"), + new GCMParameterSpec(ALGORITHM_TAG_SIZE, nonce)); + + // Encrypt and prepend nonce. + var cipherText = encryptCipher.doFinal(plaintext); + var cipherTextAndNonce = new byte[nonce.length + cipherText.length]; + System.arraycopy(nonce, 0, cipherTextAndNonce, 0, nonce.length); + System.arraycopy(cipherText, 0, cipherTextAndNonce, nonce.length, cipherText.length); + + return cipherTextAndNonce; + } + + private byte[] decrypt(byte[] cipherTextAndNonce, byte[] key) throws GeneralSecurityException { + var ciphertext = new byte[cipherTextAndNonce.length - ALGORITHM_NONCE_SIZE]; + System.arraycopy(cipherTextAndNonce, 0, nonce, 0, nonce.length); + System.arraycopy(cipherTextAndNonce, nonce.length, ciphertext, 0, ciphertext.length); + + // Create the cipher instance and initialize. + var decryptCipher = Cipher.getInstance(ALGORITHM_NAME); + decryptCipher.init( + Cipher.DECRYPT_MODE, + new SecretKeySpec(key, "AES"), + new GCMParameterSpec(ALGORITHM_TAG_SIZE, nonce)); + + // Decrypt and return result. + return decryptCipher.doFinal(ciphertext); } - } - - private byte[] encrypt(byte[] plaintext, byte[] key) throws GeneralSecurityException { - // Create the cipher instance and initialize. - var encryptCipher = Cipher.getInstance(ALGORITHM_NAME); - encryptCipher.init( - Cipher.ENCRYPT_MODE, - new SecretKeySpec(key, "AES"), - new GCMParameterSpec(ALGORITHM_TAG_SIZE, nonce)); - - // Encrypt and prepend nonce. - var cipherText = encryptCipher.doFinal(plaintext); - var cipherTextAndNonce = new byte[nonce.length + cipherText.length]; - System.arraycopy(nonce, 0, cipherTextAndNonce, 0, nonce.length); - System.arraycopy(cipherText, 0, cipherTextAndNonce, nonce.length, cipherText.length); - - return cipherTextAndNonce; - } - - private byte[] decrypt(byte[] cipherTextAndNonce, byte[] key) throws GeneralSecurityException { - var ciphertext = new byte[cipherTextAndNonce.length - ALGORITHM_NONCE_SIZE]; - System.arraycopy(cipherTextAndNonce, 0, nonce, 0, nonce.length); - System.arraycopy(cipherTextAndNonce, nonce.length, ciphertext, 0, ciphertext.length); - - // Create the cipher instance and initialize. - var decryptCipher = Cipher.getInstance(ALGORITHM_NAME); - decryptCipher.init( - Cipher.DECRYPT_MODE, - new SecretKeySpec(key, "AES"), - new GCMParameterSpec(ALGORITHM_TAG_SIZE, nonce)); - - // Decrypt and return result. - return decryptCipher.doFinal(ciphertext); - } } diff --git a/domain/src/main/java/com/jongsoft/finance/Exportable.java b/domain/src/main/java/com/jongsoft/finance/Exportable.java index 04ad76c0..70deffe9 100644 --- a/domain/src/main/java/com/jongsoft/finance/Exportable.java +++ b/domain/src/main/java/com/jongsoft/finance/Exportable.java @@ -4,5 +4,5 @@ public interface Exportable extends SupportIndicating { - Sequence lookup(); + Sequence lookup(); } diff --git a/domain/src/main/java/com/jongsoft/finance/ResultPage.java b/domain/src/main/java/com/jongsoft/finance/ResultPage.java index c4924c7a..845f70ac 100644 --- a/domain/src/main/java/com/jongsoft/finance/ResultPage.java +++ b/domain/src/main/java/com/jongsoft/finance/ResultPage.java @@ -2,55 +2,56 @@ import com.jongsoft.lang.Collections; import com.jongsoft.lang.collection.Sequence; + import java.util.function.Function; public interface ResultPage { - default int pageSize() { - return 20; - } - - default int pages() { - return 0; - } - - default long total() { - return content().size(); - } - - default Sequence content() { - return Collections.List(); - } - - default boolean hasPages() { - return pages() > 0; - } - - default boolean hasNext() { - return false; - } - - ResultPage map(Function mapper); - - @SuppressWarnings("unchecked") - static ResultPage empty() { - return ResultPage.of(); - } - - @SafeVarargs - static ResultPage of(T... elements) { - return new ResultPage() { - @Override - public Sequence content() { - return Collections.List(elements); - } - - @Override - @SuppressWarnings("unchecked") - public ResultPage map(Function mapper) { - return (ResultPage) - ResultPage.of(Collections.List(elements).map(mapper).iterator().toNativeArray()); - } - }; - } + default int pageSize() { + return 20; + } + + default int pages() { + return 0; + } + + default long total() { + return content().size(); + } + + default Sequence content() { + return Collections.List(); + } + + default boolean hasPages() { + return pages() > 0; + } + + default boolean hasNext() { + return false; + } + + ResultPage map(Function mapper); + + @SuppressWarnings("unchecked") + static ResultPage empty() { + return ResultPage.of(); + } + + @SafeVarargs + static ResultPage of(T... elements) { + return new ResultPage() { + @Override + public Sequence content() { + return Collections.List(elements); + } + + @Override + @SuppressWarnings("unchecked") + public ResultPage map(Function mapper) { + return (ResultPage) ResultPage.of( + Collections.List(elements).map(mapper).iterator().toNativeArray()); + } + }; + } } diff --git a/domain/src/main/java/com/jongsoft/finance/StorageService.java b/domain/src/main/java/com/jongsoft/finance/StorageService.java index fba827d2..40ab3229 100644 --- a/domain/src/main/java/com/jongsoft/finance/StorageService.java +++ b/domain/src/main/java/com/jongsoft/finance/StorageService.java @@ -5,20 +5,20 @@ public interface StorageService extends JavaBean { - /** - * Creates a file on disk containing the given content. - * - * @param content - * @return the token that can be used to retrieve the file - */ - String store(byte[] content); + /** + * Creates a file on disk containing the given content. + * + * @param content + * @return the token that can be used to retrieve the file + */ + String store(byte[] content); - Optional read(String token); + Optional read(String token); - /** - * Remove a file from storage that is no longer needed. - * - * @param token the token of the file - */ - void remove(String token); + /** + * Remove a file from storage that is no longer needed. + * + * @param token the token of the file + */ + void remove(String token); } diff --git a/domain/src/main/java/com/jongsoft/finance/SupportIndicating.java b/domain/src/main/java/com/jongsoft/finance/SupportIndicating.java index 9817f648..81807a15 100644 --- a/domain/src/main/java/com/jongsoft/finance/SupportIndicating.java +++ b/domain/src/main/java/com/jongsoft/finance/SupportIndicating.java @@ -8,5 +8,5 @@ */ public interface SupportIndicating { - boolean supports(Class supportingClass); + boolean supports(Class supportingClass); } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/FinTrack.java b/domain/src/main/java/com/jongsoft/finance/domain/FinTrack.java index 6921f394..1bb8dee4 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/FinTrack.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/FinTrack.java @@ -4,29 +4,31 @@ import com.jongsoft.finance.domain.user.UserAccount; import com.jongsoft.finance.messaging.commands.user.RegisterTokenCommand; import com.jongsoft.lang.Collections; + +import lombok.Getter; + import java.time.LocalDateTime; import java.util.List; -import lombok.Getter; public class FinTrack { - @Getter - private final Encoder hashingAlgorithm; + @Getter + private final Encoder hashingAlgorithm; - public FinTrack(Encoder hashingAlgorithm) { - this.hashingAlgorithm = hashingAlgorithm; - } + public FinTrack(Encoder hashingAlgorithm) { + this.hashingAlgorithm = hashingAlgorithm; + } - public UserAccount createUser(String username, String password) { - return new UserAccount(username, password); - } + public UserAccount createUser(String username, String password) { + return new UserAccount(username, password); + } - public UserAccount createOathUser(String username, String oathKey, List roles) { - return new UserAccount(username, oathKey, Collections.List(roles)); - } + public UserAccount createOathUser(String username, String oathKey, List roles) { + return new UserAccount(username, oathKey, Collections.List(roles)); + } - public void registerToken(String username, String token, Integer expiresIn) { - RegisterTokenCommand.tokenRegistered( - username, token, LocalDateTime.now().plusSeconds(expiresIn)); - } + public void registerToken(String username, String token, Integer expiresIn) { + RegisterTokenCommand.tokenRegistered( + username, token, LocalDateTime.now().plusSeconds(expiresIn)); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/account/Account.java b/domain/src/main/java/com/jongsoft/finance/domain/account/Account.java index 3ad4caee..2a7720f6 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/account/Account.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/account/Account.java @@ -14,216 +14,222 @@ import com.jongsoft.lang.Collections; import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.Set; + +import io.micronaut.core.annotation.Introspected; + +import lombok.*; + import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.util.function.Consumer; -import lombok.*; @Getter @Builder @Aggregate +@Introspected @EqualsAndHashCode(of = {"id"}) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Account implements AggregateBase, Serializable { - private Long id; - private UserIdentifier user; + private Long id; + private UserIdentifier user; - private String name; - private String description; - private String currency; + private String name; + private String description; + private String currency; - private String iban; - private String bic; - private String number; - private String type; + private String iban; + private String bic; + private String number; + private String type; - private String imageFileToken; + private String imageFileToken; - private double balance; - private LocalDate lastTransaction; - private LocalDate firstTransaction; + private double balance; + private LocalDate lastTransaction; + private LocalDate firstTransaction; - private double interest; - private Periodicity interestPeriodicity; - private Set savingGoals; + private double interest; + private Periodicity interestPeriodicity; + private Set savingGoals; - private boolean remove; + private boolean remove; - @BusinessMethod - public Account(UserIdentifier user, String name, String currency, String type) { - this.user = user; - this.name = name; - this.currency = currency; - this.type = type; + @BusinessMethod + public Account(UserIdentifier user, String name, String currency, String type) { + this.user = user; + this.name = name; + this.currency = currency; + this.type = type; - CreateAccountCommand.accountCreated(name, currency, type); - } + CreateAccountCommand.accountCreated(name, currency, type); + } - @BusinessMethod - public void rename(String name, String description, String currency, String type) { - var noChanges = Control.Equal(this.name, name) - .append(this.description, description) - .append(this.currency, currency) - .append(this.type, type) - .isEqual(); + @BusinessMethod + public void rename(String name, String description, String currency, String type) { + var noChanges = Control.Equal(this.name, name) + .append(this.description, description) + .append(this.currency, currency) + .append(this.type, type) + .isEqual(); + + if (!noChanges) { + this.name = name; + this.description = description; + this.currency = currency; + this.type = type; + + RenameAccountCommand.accountRenamed(id, type, name, description, currency); + } + } - if (!noChanges) { - this.name = name; - this.description = description; - this.currency = currency; - this.type = type; + @BusinessMethod + public void registerIcon(String fileCode) { + RegisterAccountIconCommand.iconChanged(id, fileCode, this.imageFileToken); + this.imageFileToken = fileCode; + } - RenameAccountCommand.accountRenamed(id, type, name, description, currency); + /** + * Change the interest rate for this account. + * + * @param interest the new interest rate + * @param periodicity the periodicity of the interest rate + */ + @BusinessMethod + public void interest(double interest, Periodicity periodicity) { + if (interest < -2 || interest > 2) { + throw new IllegalArgumentException("Highly improbable interest of more than 200%."); + } + + var changes = Control.Equal(this.interest, interest) + .append(this.interestPeriodicity, periodicity) + .isNotEqual(); + + if (changes) { + this.interest = interest; + this.interestPeriodicity = periodicity; + ChangeInterestCommand.interestChanged(id, interest, periodicity); + } } - } - - @BusinessMethod - public void registerIcon(String fileCode) { - RegisterAccountIconCommand.iconChanged(id, fileCode, this.imageFileToken); - this.imageFileToken = fileCode; - } - - /** - * Change the interest rate for this account. - * - * @param interest the new interest rate - * @param periodicity the periodicity of the interest rate - */ - @BusinessMethod - public void interest(double interest, Periodicity periodicity) { - if (interest < -2 || interest > 2) { - throw new IllegalArgumentException("Highly improbable interest of more than 200%."); + + @BusinessMethod + public void registerSynonym(String synonym) { + RegisterSynonymCommand.synonymRegistered(id, synonym); } - var changes = Control.Equal(this.interest, interest) - .append(this.interestPeriodicity, periodicity) - .isNotEqual(); + /** + * Modify the banking detail information, being account number, IBAN and BIC. + * + * @param iban the new IBAN + * @param bic the new BIC + * @param number the account number + */ + @BusinessMethod + public void changeAccount(String iban, String bic, String number) { + var noChanges = Control.Equal(this.iban, iban) + .append(this.bic, bic) + .append(this.number, number) + .isEqual(); + + if (!noChanges) { + this.iban = iban; + this.bic = bic; + this.number = number; + ChangeAccountCommand.accountChanged(id, iban, bic, number); + } + } + + /** Close this account, making it archived and no longer accessible. */ + @BusinessMethod + public void terminate() { + remove = true; + TerminateAccountCommand.accountTerminated(id); + } + + @BusinessMethod + public Transaction createTransaction( + Account to, + double amount, + Transaction.Type transactionType, + Consumer applier) { + Account destination; + Account source; + switch (transactionType) { + case DEBIT -> { + source = to; + destination = this; + } + case CREDIT, TRANSFER -> { + source = this; + destination = to; + } + default -> throw new IllegalArgumentException(); + } + + var builder = new Transaction(source, destination, amount).toBuilder(); + applier.accept(builder); + return builder.build(); + } - if (changes) { - this.interest = interest; - this.interestPeriodicity = periodicity; - ChangeInterestCommand.interestChanged(id, interest, periodicity); + /** + * Create a new scheduled transaction with the current account being the source account. + * + * @param name the name of the schedule + * @param schedule the schedule to adhere to + * @param destination the destination account + * @param amount the amount involved in the transactions + * @return the newly created scheduled transaction (not yet persisted) + */ + @BusinessMethod + public ScheduledTransaction createSchedule( + String name, Schedule schedule, Account destination, double amount) { + return new ScheduledTransaction(name, schedule, this, destination, amount); } - } - - @BusinessMethod - public void registerSynonym(String synonym) { - RegisterSynonymCommand.synonymRegistered(id, synonym); - } - - /** - * Modify the banking detail information, being account number, IBAN and BIC. - * - * @param iban the new IBAN - * @param bic the new BIC - * @param number the account number - */ - @BusinessMethod - public void changeAccount(String iban, String bic, String number) { - var noChanges = Control.Equal(this.iban, iban) - .append(this.bic, bic) - .append(this.number, number) - .isEqual(); - - if (!noChanges) { - this.iban = iban; - this.bic = bic; - this.number = number; - ChangeAccountCommand.accountChanged(id, iban, bic, number); + + /** + * Create a new contract for this account. + * + * @param name the name of the contract + * @param start the start date of the contract + * @param end the end date of the contract + * @return the newly created contract + */ + @BusinessMethod + public Contract createContract( + String name, String description, LocalDate start, LocalDate end) { + return new Contract(this, name, description, start, end); } - } - - /** Close this account, making it archived and no longer accessible. */ - @BusinessMethod - public void terminate() { - remove = true; - TerminateAccountCommand.accountTerminated(id); - } - - @BusinessMethod - public Transaction createTransaction( - Account to, - double amount, - Transaction.Type transactionType, - Consumer applier) { - Account destination; - Account source; - switch (transactionType) { - case DEBIT -> { - source = to; - destination = this; - } - case CREDIT, TRANSFER -> { - source = this; - destination = to; - } - default -> throw new IllegalArgumentException(); + + /** + * Create a new saving goal for the account. This can only be done for accounts of type SAVING + * or COMBINED_SAVING. + * + * @return the newly created saving goal + */ + @BusinessMethod + public SavingGoal createSavingGoal(String name, BigDecimal goal, LocalDate targetDate) { + if (!Collections.List("savings", "joined_savings").contains(type.toLowerCase())) { + throw StatusException.badRequest("Cannot add a savings goal to account " + id + + " it is of unsupported type " + type); + } + + return new SavingGoal(this, name, goal, targetDate); } - var builder = new Transaction(source, destination, amount).toBuilder(); - applier.accept(builder); - return builder.build(); - } - - /** - * Create a new scheduled transaction with the current account being the source account. - * - * @param name the name of the schedule - * @param schedule the schedule to adhere to - * @param destination the destination account - * @param amount the amount involved in the transactions - * @return the newly created scheduled transaction (not yet persisted) - */ - @BusinessMethod - public ScheduledTransaction createSchedule( - String name, Schedule schedule, Account destination, double amount) { - return new ScheduledTransaction(name, schedule, this, destination, amount); - } - - /** - * Create a new contract for this account. - * - * @param name the name of the contract - * @param start the start date of the contract - * @param end the end date of the contract - * @return the newly created contract - */ - @BusinessMethod - public Contract createContract(String name, String description, LocalDate start, LocalDate end) { - return new Contract(this, name, description, start, end); - } - - /** - * Create a new saving goal for the account. This can only be done for accounts of type SAVING - * or COMBINED_SAVING. - * - * @return the newly created saving goal - */ - @BusinessMethod - public SavingGoal createSavingGoal(String name, BigDecimal goal, LocalDate targetDate) { - if (!Collections.List("savings", "joined_savings").contains(type.toLowerCase())) { - throw StatusException.badRequest( - "Cannot add a savings goal to account " + id + " it is of unsupported type " + type); + /** + * A managed account is one that is owned by the user and transactions are linked to it. + * + * @return true if managed + */ + public boolean isManaged() { + return !Collections.List(SystemAccountTypes.values()) + .map(SystemAccountTypes::label) + .contains(type.toLowerCase()); } - return new SavingGoal(this, name, goal, targetDate); - } - - /** - * A managed account is one that is owned by the user and transactions are linked to it. - * - * @return true if managed - */ - public boolean isManaged() { - return !Collections.List(SystemAccountTypes.values()) - .map(SystemAccountTypes::label) - .contains(type.toLowerCase()); - } - - @Override - public String toString() { - return getName(); - } + @Override + public String toString() { + return getName(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/account/Contract.java b/domain/src/main/java/com/jongsoft/finance/domain/account/Contract.java index 1d9e447d..9b6fa824 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/account/Contract.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/account/Contract.java @@ -3,16 +3,19 @@ import com.jongsoft.finance.annotation.Aggregate; import com.jongsoft.finance.annotation.BusinessMethod; import com.jongsoft.finance.core.AggregateBase; +import com.jongsoft.finance.core.exception.StatusException; import com.jongsoft.finance.messaging.commands.contract.*; import com.jongsoft.finance.messaging.commands.schedule.CreateScheduleForContractCommand; import com.jongsoft.finance.schedule.Schedule; -import java.io.Serializable; -import java.time.LocalDate; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import java.io.Serializable; +import java.time.LocalDate; + @Getter @Builder @Aggregate @@ -20,107 +23,111 @@ @EqualsAndHashCode(of = {"id"}) public class Contract implements AggregateBase, Serializable { - private Long id; - private String name; - private String description; - private Account company; + private Long id; + private String name; + private String description; + private Account company; - private LocalDate startDate; - private LocalDate endDate; + private LocalDate startDate; + private LocalDate endDate; - private String fileToken; + private String fileToken; - private boolean uploaded; - private boolean notifyBeforeEnd; - private boolean terminated; + private boolean uploaded; + private boolean notifyBeforeEnd; + private boolean terminated; - Contract(Account company, String name, String description, LocalDate start, LocalDate end) { - if (start.isAfter(end)) { - throw new IllegalArgumentException("Start cannot be after end of contract."); - } + Contract(Account company, String name, String description, LocalDate start, LocalDate end) { + if (start.isAfter(end)) { + throw new IllegalArgumentException("Start cannot be after end of contract."); + } - this.name = name; - this.startDate = start; - this.endDate = end; - this.company = company; - this.description = description; - CreateContractCommand.contractCreated(company.getId(), name, description, start, end); - } - - /** - * Creates a schedule for this contract. For each period in the schedule, a transaction will be - * created automatically. - * - * @param schedule The schedule to create. - * @param source The account to use as source for the schedule. - * @param amount The amount to use for the schedule. - */ - @BusinessMethod - public void createSchedule(Schedule schedule, Account source, double amount) { - CreateScheduleForContractCommand.scheduleCreated(name, schedule, this, source, amount); - } - - @BusinessMethod - public void change(String name, String description, LocalDate start, LocalDate end) { - if (start.isAfter(end)) { - throw new IllegalArgumentException("Start cannot be after end of contract."); + this.name = name; + this.startDate = start; + this.endDate = end; + this.company = company; + this.description = description; + CreateContractCommand.contractCreated(company.getId(), name, description, start, end); } - this.name = name; - this.startDate = start; - this.endDate = end; - this.description = description; - - ChangeContractCommand.contractChanged(id, name, description, start, end); - } - - /** - * Activates the warning before the ending for this contract. If the contract is not yet - * persisted, or has already expired, an exception will be thrown. - */ - @BusinessMethod - public void warnBeforeExpires() { - if (id == null) { - throw new IllegalStateException( - "Cannot activate contract warning if contract is not yet persisted."); + /** + * Creates a schedule for this contract. For each period in the schedule, a transaction will be + * created automatically. + * + * @param schedule The schedule to create. + * @param source The account to use as source for the schedule. + * @param amount The amount to use for the schedule. + */ + @BusinessMethod + public void createSchedule(Schedule schedule, Account source, double amount) { + CreateScheduleForContractCommand.scheduleCreated(name, schedule, this, source, amount); } - if (endDate.isBefore(LocalDate.now())) { - throw new IllegalStateException("Cannot activate contract warning if contract has expired."); - } + @BusinessMethod + public void change(String name, String description, LocalDate start, LocalDate end) { + if (start.isAfter(end)) { + throw new IllegalArgumentException("Start cannot be after end of contract."); + } + + this.name = name; + this.startDate = start; + this.endDate = end; + this.description = description; - if (!notifyBeforeEnd) { - this.notifyBeforeEnd = true; - WarnBeforeExpiryCommand.warnBeforeExpiry(id, endDate); + ChangeContractCommand.contractChanged(id, name, description, start, end); } - } - @BusinessMethod - public void registerUpload(String storageToken) { - if (uploaded) { - throw new IllegalStateException("Contract still contains upload."); + /** + * Activates the warning before the ending for this contract. If the contract is not yet + * persisted, or has already expired, an exception will be thrown. + */ + @BusinessMethod + public void warnBeforeExpires() { + if (id == null) { + throw new IllegalStateException( + "Cannot activate contract warning if contract is not yet persisted."); + } + + if (endDate.isBefore(LocalDate.now())) { + throw StatusException.badRequest( + "Cannot activate contract warning if contract has expired.", + "contract.warn.not.possible.expired"); + } + + if (!notifyBeforeEnd) { + this.notifyBeforeEnd = true; + WarnBeforeExpiryCommand.warnBeforeExpiry(id, endDate); + } } - this.uploaded = true; - AttachFileToContractCommand.attachFileToContract(id, storageToken); - } + @BusinessMethod + public void registerUpload(String storageToken) { + if (uploaded) { + throw new IllegalStateException("Contract still contains upload."); + } - @BusinessMethod - public void terminate() { - if (terminated) { - throw new IllegalStateException("Contract is already terminated."); + this.uploaded = true; + AttachFileToContractCommand.attachFileToContract(id, storageToken); } - if (endDate.isAfter(LocalDate.now())) { - throw new IllegalStateException("Contract has not yet expired."); - } + @BusinessMethod + public void terminate() { + if (terminated) { + throw StatusException.badRequest( + "Contract is already terminated.", "contract.already.terminated"); + } - this.terminated = true; - TerminateContractCommand.contractTerminated(id); - } + if (endDate.isAfter(LocalDate.now())) { + throw StatusException.badRequest( + "Contract has not yet expired.", "contract.not.expired"); + } - @Override - public String toString() { - return this.getName(); - } + this.terminated = true; + TerminateContractCommand.contractTerminated(id); + } + + @Override + public String toString() { + return this.getName(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/account/SavingGoal.java b/domain/src/main/java/com/jongsoft/finance/domain/account/SavingGoal.java index 0cfe6df4..6c50662d 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/account/SavingGoal.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/account/SavingGoal.java @@ -9,156 +9,159 @@ import com.jongsoft.finance.schedule.Schedulable; import com.jongsoft.finance.schedule.Schedule; import com.jongsoft.lang.Control; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.LocalDate; -import java.util.Objects; + import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Objects; + @Getter @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) public class SavingGoal implements AggregateBase { - private Long id; - private String name; - private String description; - private BigDecimal allocated; - private BigDecimal goal; - private LocalDate targetDate; - private Account account; - private Schedule schedule; - - SavingGoal(Account account, String name, BigDecimal goal, LocalDate targetDate) { - if (!account.isManaged()) { - throw StatusException.badRequest( - "Cannot create a savings goal if the account is not owned by the user."); + private Long id; + private String name; + private String description; + private BigDecimal allocated; + private BigDecimal goal; + private LocalDate targetDate; + private Account account; + private Schedule schedule; + + SavingGoal(Account account, String name, BigDecimal goal, LocalDate targetDate) { + if (!account.isManaged()) { + throw StatusException.badRequest( + "Cannot create a savings goal if the account is not owned by the user."); + } + + this.account = Objects.requireNonNull(account, "Account cannot be empty."); + this.goal = goal; + this.targetDate = targetDate; + this.name = name; + + CreateSavingGoalCommand.savingGoalCreated(account.getId(), name, goal, targetDate); } - this.account = Objects.requireNonNull(account, "Account cannot be empty."); - this.goal = goal; - this.targetDate = targetDate; - this.name = name; - - CreateSavingGoalCommand.savingGoalCreated(account.getId(), name, goal, targetDate); - } - - /** - * Call this operation to calculate the amount of money that should be allocated to this saving - * goal every schedule step to reach the goal. - * - *

Note: since this is a calculation the value can vary, based upon the already allocated - * amount of money in the account. - * - * @return the amount the user should set apart when the next saving date comes along - */ - public BigDecimal computeAllocation() { - var remainingToGoal = - goal.subtract(Control.Option(allocated).getOrSupply(() -> BigDecimal.ZERO)); - - if (remainingToGoal.compareTo(BigDecimal.ONE) < 0) { - return BigDecimal.ZERO; + /** + * Call this operation to calculate the amount of money that should be allocated to this saving + * goal every schedule step to reach the goal. + * + *

Note: since this is a calculation the value can vary, based upon the already allocated + * amount of money in the account. + * + * @return the amount the user should set apart when the next saving date comes along + */ + public BigDecimal computeAllocation() { + var remainingToGoal = + goal.subtract(Control.Option(allocated).getOrSupply(() -> BigDecimal.ZERO)); + + if (remainingToGoal.compareTo(BigDecimal.ONE) < 0) { + return BigDecimal.ZERO; + } + + var times = 0; + var now = LocalDate.now(); + var allocationTime = schedule.previous(targetDate); + while (now.isBefore(allocationTime)) { + times++; + allocationTime = schedule.previous(allocationTime); + } + + return remainingToGoal.divide(BigDecimal.valueOf(times), RoundingMode.HALF_UP); } - var times = 0; - var now = LocalDate.now(); - var allocationTime = schedule.previous(targetDate); - while (now.isBefore(allocationTime)) { - times++; - allocationTime = schedule.previous(allocationTime); + /** + * Change either the targeted amount of money that should be reserved or the date at which is + * should be available. + * + * @param goal the target amount of money + * @param targetDate the date at which the goal should be met + */ + @BusinessMethod + public void adjustGoal(BigDecimal goal, LocalDate targetDate) { + if (LocalDate.now().isAfter(targetDate)) { + throw StatusException.badRequest( + "Target date for a saving goal cannot be in the past."); + } else if (BigDecimal.ZERO.compareTo(goal) >= 0) { + throw StatusException.badRequest("The goal cannot be 0 or less."); + } + + this.goal = goal; + this.targetDate = targetDate; + AdjustSavingGoalCommand.savingGoalAdjusted(id, goal, targetDate); } - return remainingToGoal.divide(BigDecimal.valueOf(times), RoundingMode.HALF_UP); - } - - /** - * Change either the targeted amount of money that should be reserved or the date at which is - * should be available. - * - * @param goal the target amount of money - * @param targetDate the date at which the goal should be met - */ - @BusinessMethod - public void adjustGoal(BigDecimal goal, LocalDate targetDate) { - if (LocalDate.now().isAfter(targetDate)) { - throw StatusException.badRequest("Target date for a saving goal cannot be in the past."); - } else if (BigDecimal.ZERO.compareTo(goal) >= 0) { - throw StatusException.badRequest("The goal cannot be 0 or less."); + /** + * Set the interval at which one wishes to add money into the saving goal. + * + * @param periodicity the periodicity + * @param interval the interval to recur on + */ + @BusinessMethod + public void schedule(Periodicity periodicity, int interval) { + var firstSaving = LocalDate.now().plus(interval, periodicity.toChronoUnit()); + if (firstSaving.isAfter(targetDate)) { + throw StatusException.badRequest( + "Cannot set schedule when first saving would be after the target date of this" + + " saving goal."); + } + + this.schedule = new ScheduleValue(periodicity, interval); + AdjustScheduleCommand.scheduleAdjusted( + id, Schedulable.basicSchedule(this.id, this.targetDate, this.schedule)); } - this.goal = goal; - this.targetDate = targetDate; - AdjustSavingGoalCommand.savingGoalAdjusted(id, goal, targetDate); - } - - /** - * Set the interval at which one wishes to add money into the saving goal. - * - * @param periodicity the periodicity - * @param interval the interval to recur on - */ - @BusinessMethod - public void schedule(Periodicity periodicity, int interval) { - var firstSaving = LocalDate.now().plus(interval, periodicity.toChronoUnit()); - if (firstSaving.isAfter(targetDate)) { - throw StatusException.badRequest( - "Cannot set schedule when first saving would be after the target date of this" - + " saving goal."); + /** + * Calling this method will create the next installment towards the end goal. The installment is + * calculated using the {@link #computeAllocation()} method. + * + * @throws StatusException in case no schedule was yet activated on this saving goal + */ + @BusinessMethod + public void reserveNextPayment() { + if (schedule == null) { + throw StatusException.badRequest( + "Cannot automatically reserve an installment for saving goal " + + id + + ". No schedule was setup."); + } + + var installment = computeAllocation(); + if (installment.compareTo(BigDecimal.ZERO) > 0) { + this.allocated = this.allocated.add(installment); + RegisterSavingInstallmentCommand.savingInstallmentRegistered(id, installment); + } } - this.schedule = new ScheduleValue(periodicity, interval); - AdjustScheduleCommand.scheduleAdjusted( - id, Schedulable.basicSchedule(this.id, this.targetDate, this.schedule)); - } - - /** - * Calling this method will create the next installment towards the end goal. The installment is - * calculated using the {@link #computeAllocation()} method. - * - * @throws StatusException in case no schedule was yet activated on this saving goal - */ - @BusinessMethod - public void reserveNextPayment() { - if (schedule == null) { - throw StatusException.badRequest( - "Cannot automatically reserve an installment for saving goal " - + id - + ". No schedule was setup."); + /** + * Add additional money towards the savings goal. This does not require any scheduling (for + * automated savings). + * + * @param amount the amount to add + * @throws StatusException in case the saved amount exceeds the targeted goal amount + */ + @BusinessMethod + public void registerPayment(BigDecimal amount) { + if (allocated.add(amount).compareTo(goal) > 0) { + throw StatusException.badRequest( + "Cannot increase allocation, the increment would add more then the desired goal" + + " of " + + goal); + } + + this.allocated = this.allocated.add(amount); + RegisterSavingInstallmentCommand.savingInstallmentRegistered(id, amount); } - var installment = computeAllocation(); - if (installment.compareTo(BigDecimal.ZERO) > 0) { - this.allocated = this.allocated.add(installment); - RegisterSavingInstallmentCommand.savingInstallmentRegistered(id, installment); + /** Signal that the savings goal has been used for its intended purpose. */ + @BusinessMethod + public void completed() { + CompleteSavingGoalCommand.savingGoalCompleted(id); } - } - - /** - * Add additional money towards the savings goal. This does not require any scheduling (for - * automated savings). - * - * @param amount the amount to add - * @throws StatusException in case the saved amount exceeds the targeted goal amount - */ - @BusinessMethod - public void registerPayment(BigDecimal amount) { - if (allocated.add(amount).compareTo(goal) > 0) { - throw StatusException.badRequest( - "Cannot increase allocation, the increment would add more then the desired goal" - + " of " - + goal); - } - - this.allocated = this.allocated.add(amount); - RegisterSavingInstallmentCommand.savingInstallmentRegistered(id, amount); - } - - /** Signal that the savings goal has been used for its intended purpose. */ - @BusinessMethod - public void completed() { - CompleteSavingGoalCommand.savingGoalCompleted(id); - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/core/Currency.java b/domain/src/main/java/com/jongsoft/finance/domain/core/Currency.java index d4469aff..49058e51 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/core/Currency.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/core/Currency.java @@ -8,6 +8,7 @@ import com.jongsoft.finance.messaging.commands.currency.CurrencyCommandType; import com.jongsoft.finance.messaging.commands.currency.RenameCurrencyCommand; import com.jongsoft.lang.Control; + import lombok.*; @Getter @@ -17,62 +18,62 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Currency implements AggregateBase { - private Long id; - private String name; - private String code; - private char symbol; - private int decimalPlaces; - private boolean enabled; + private Long id; + private String name; + private String code; + private char symbol; + private int decimalPlaces; + private boolean enabled; - public Currency(String name, String code, char symbol) { - this.name = name; - this.code = code; - this.symbol = symbol; - this.decimalPlaces = 2; - this.enabled = true; + public Currency(String name, String code, char symbol) { + this.name = name; + this.code = code; + this.symbol = symbol; + this.decimalPlaces = 2; + this.enabled = true; - CreateCurrencyCommand.currencyCreated(name, symbol, code); - } + CreateCurrencyCommand.currencyCreated(name, symbol, code); + } - @BusinessMethod - public void rename(String name, String code, char symbol) { - var changed = Control.Equal(this.name, name) - .append(this.code, code) - .append(this.symbol, symbol) - .isNotEqual(); + @BusinessMethod + public void rename(String name, String code, char symbol) { + var changed = Control.Equal(this.name, name) + .append(this.code, code) + .append(this.symbol, symbol) + .isNotEqual(); - if (changed) { - this.name = name; - this.code = code; - this.symbol = symbol; - RenameCurrencyCommand.currencyRenamed(id, name, symbol, code); + if (changed) { + this.name = name; + this.code = code; + this.symbol = symbol; + RenameCurrencyCommand.currencyRenamed(id, name, symbol, code); + } } - } - @BusinessMethod - public void disable() { - if (enabled) { - enabled = false; - ChangeCurrencyPropertyCommand.currencyPropertyChanged( - code, false, CurrencyCommandType.ENABLED); + @BusinessMethod + public void disable() { + if (enabled) { + enabled = false; + ChangeCurrencyPropertyCommand.currencyPropertyChanged( + code, false, CurrencyCommandType.ENABLED); + } } - } - @BusinessMethod - public void enable() { - if (!enabled) { - enabled = true; - ChangeCurrencyPropertyCommand.currencyPropertyChanged( - code, true, CurrencyCommandType.ENABLED); + @BusinessMethod + public void enable() { + if (!enabled) { + enabled = true; + ChangeCurrencyPropertyCommand.currencyPropertyChanged( + code, true, CurrencyCommandType.ENABLED); + } } - } - @BusinessMethod - public void accuracy(int decimalPlaces) { - if (decimalPlaces != this.decimalPlaces) { - this.decimalPlaces = decimalPlaces; - ChangeCurrencyPropertyCommand.currencyPropertyChanged( - code, decimalPlaces, CurrencyCommandType.DECIMAL_PLACES); + @BusinessMethod + public void accuracy(int decimalPlaces) { + if (decimalPlaces != this.decimalPlaces) { + this.decimalPlaces = decimalPlaces; + ChangeCurrencyPropertyCommand.currencyPropertyChanged( + code, decimalPlaces, CurrencyCommandType.DECIMAL_PLACES); + } } - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/core/EntityRef.java b/domain/src/main/java/com/jongsoft/finance/domain/core/EntityRef.java index 5447c9d1..b30f97a4 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/core/EntityRef.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/core/EntityRef.java @@ -1,7 +1,9 @@ package com.jongsoft.finance.domain.core; import com.jongsoft.finance.core.AggregateBase; + import io.micronaut.serde.annotation.Serdeable; + import lombok.EqualsAndHashCode; import lombok.Getter; @@ -9,22 +11,22 @@ @EqualsAndHashCode(of = {"id"}) public class EntityRef implements AggregateBase { - private final Long id; + private final Long id; - public EntityRef(Long id) { - this.id = id; - } - - @Serdeable - public record NamedEntity(long id, String name) implements AggregateBase { - @Override - public Long getId() { - return id; + public EntityRef(Long id) { + this.id = id; } - @Override - public String toString() { - return name; + @Serdeable + public record NamedEntity(long id, String name) implements AggregateBase { + @Override + public Long getId() { + return id; + } + + @Override + public String toString() { + return name; + } } - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/core/Setting.java b/domain/src/main/java/com/jongsoft/finance/domain/core/Setting.java index 546dd87e..9c2fd541 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/core/Setting.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/core/Setting.java @@ -2,37 +2,39 @@ import com.jongsoft.finance.core.SettingType; import com.jongsoft.finance.domain.core.events.SettingUpdatedEvent; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.Objects; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Objects; + @Getter @Builder @AllArgsConstructor public class Setting { - private final String name; - private final SettingType type; - private String value; + private final String name; + private final SettingType type; + private String value; - public void update(String value) { - if (!Objects.equals(this.value, value)) { - switch (type) { - case NUMBER -> new BigDecimal(value); - case FLAG -> { - if (!value.equalsIgnoreCase("true") && !value.equalsIgnoreCase("false")) { - throw new IllegalArgumentException( - "Value is not a valid setting for a boolean " + value); - } - } - case DATE -> LocalDate.parse(value); - } + public void update(String value) { + if (!Objects.equals(this.value, value)) { + switch (type) { + case NUMBER -> new BigDecimal(value); + case FLAG -> { + if (!value.equalsIgnoreCase("true") && !value.equalsIgnoreCase("false")) { + throw new IllegalArgumentException( + "Value is not a valid setting for a boolean " + value); + } + } + case DATE -> LocalDate.parse(value); + } - this.value = value; - SettingUpdatedEvent.settingUpdated(name, value); + this.value = value; + SettingUpdatedEvent.settingUpdated(name, value); + } } - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/core/events/SettingUpdatedEvent.java b/domain/src/main/java/com/jongsoft/finance/domain/core/events/SettingUpdatedEvent.java index a107401a..23f2b788 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/core/events/SettingUpdatedEvent.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/core/events/SettingUpdatedEvent.java @@ -4,7 +4,7 @@ public record SettingUpdatedEvent(String setting, String value) implements ApplicationEvent { - public static void settingUpdated(String setting, String value) { - new SettingUpdatedEvent(setting, value).publish(); - } + public static void settingUpdated(String setting, String value) { + new SettingUpdatedEvent(setting, value).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImport.java b/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImport.java index 12957dd4..715d5f26 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImport.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImport.java @@ -8,13 +8,15 @@ import com.jongsoft.finance.messaging.commands.importer.CompleteImportJobCommand; import com.jongsoft.finance.messaging.commands.importer.CreateImportJobCommand; import com.jongsoft.finance.messaging.commands.importer.DeleteImportJobCommand; -import java.util.Date; -import java.util.UUID; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import java.util.Date; +import java.util.UUID; + @Getter @Builder @Aggregate @@ -22,44 +24,47 @@ @EqualsAndHashCode(of = {"id"}) public class BatchImport implements AggregateBase { - private Long id; - private Date created; - private Date finished; - - private String slug; - private String fileCode; + private Long id; + private Date created; + private Date finished; - private transient BatchImportConfig config; - private transient UserAccount user; + private String slug; + private String fileCode; - private double totalIncome; - private double totalExpense; + private transient BatchImportConfig config; + private transient UserAccount user; - @BusinessMethod - public BatchImport(BatchImportConfig config, UserAccount user, String fileCode) { - this.user = user; - this.slug = UUID.randomUUID().toString(); - this.config = config; - this.fileCode = fileCode; + private double totalIncome; + private double totalExpense; - CreateImportJobCommand.importJobCreated(config.getId(), slug, fileCode); - } + @BusinessMethod + public BatchImport(BatchImportConfig config, UserAccount user, String fileCode) { + this.user = user; + this.slug = UUID.randomUUID().toString(); + this.config = config; + this.fileCode = fileCode; + this.created = new Date(); - public void archive() { - if (this.finished != null) { - throw StatusException.badRequest("Cannot archive an import job that has finished running."); + CreateImportJobCommand.importJobCreated(config.getId(), slug, fileCode); } - DeleteImportJobCommand.importJobDeleted(id); - } + public void archive() { + if (this.finished != null) { + throw StatusException.badRequest( + "Cannot archive an import job that has finished running."); + } - @BusinessMethod - public void finish(Date date) { - if (this.finished != null) { - throw StatusException.badRequest("Cannot finish an import which has already completed."); + DeleteImportJobCommand.importJobDeleted(id); } - this.finished = date; - CompleteImportJobCommand.importJobCompleted(id); - } + @BusinessMethod + public void finish(Date date) { + if (this.finished != null) { + throw StatusException.badRequest( + "Cannot finish an import which has already completed."); + } + + this.finished = date; + CompleteImportJobCommand.importJobCompleted(id); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImportConfig.java b/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImportConfig.java index 53844022..495fddc4 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImportConfig.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/importer/BatchImportConfig.java @@ -5,36 +5,38 @@ import com.jongsoft.finance.core.AggregateBase; import com.jongsoft.finance.domain.user.UserAccount; import com.jongsoft.finance.messaging.commands.importer.CreateConfigurationCommand; -import java.io.Serializable; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.io.Serializable; + @Getter @Builder @Aggregate @AllArgsConstructor public class BatchImportConfig implements AggregateBase, Serializable { - private Long id; + private Long id; - private String name; - private String fileCode; - private String type; + private String name; + private String fileCode; + private String type; - private transient UserAccount user; + private transient UserAccount user; - @BusinessMethod - public BatchImportConfig(UserAccount user, String type, String name, String fileCode) { - this.user = user; - this.name = name; - this.fileCode = fileCode; - this.type = type; + @BusinessMethod + public BatchImportConfig(UserAccount user, String type, String name, String fileCode) { + this.user = user; + this.name = name; + this.fileCode = fileCode; + this.type = type; - CreateConfigurationCommand.configurationCreated(type, name, fileCode); - } + CreateConfigurationCommand.configurationCreated(type, name, fileCode); + } - public BatchImport createImport(String fileCode) { - return new BatchImport(this, this.user, fileCode); - } + public BatchImport createImport(String fileCode) { + return new BatchImport(this, this.user, fileCode); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/AnalyzeJob.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/AnalyzeJob.java index 7fbce53f..0de73e2b 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/AnalyzeJob.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/AnalyzeJob.java @@ -5,46 +5,50 @@ import com.jongsoft.finance.messaging.commands.insight.CompleteAnalyzeJob; import com.jongsoft.finance.messaging.commands.insight.CreateAnalyzeJob; import com.jongsoft.finance.messaging.commands.insight.FailAnalyzeJob; -import java.time.YearMonth; -import java.util.UUID; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.time.YearMonth; +import java.util.UUID; + @Getter @Builder @AllArgsConstructor(access = lombok.AccessLevel.PRIVATE) public class AnalyzeJob { - private final String jobId; - private final YearMonth month; - private final UserIdentifier user; - private boolean completed; - - public AnalyzeJob(UserIdentifier user, YearMonth month) { - this.jobId = UUID.randomUUID().toString(); - this.month = month; - this.user = user; + private final String jobId; + private final YearMonth month; + private final UserIdentifier user; + private boolean completed; - CreateAnalyzeJob.createAnalyzeJob(user, month); - } + public AnalyzeJob(UserIdentifier user, YearMonth month) { + this.jobId = UUID.randomUUID().toString(); + this.month = month; + this.user = user; - public void complete() { - if (completed) { - throw StatusException.badRequest( - "Cannot complete an analyze job that has already completed.", "AnalyzeJob.completed"); + CreateAnalyzeJob.createAnalyzeJob(user, month); } - completed = true; - CompleteAnalyzeJob.completeAnalyzeJob(user, month); - } + public void complete() { + if (completed) { + throw StatusException.badRequest( + "Cannot complete an analyze job that has already completed.", + "AnalyzeJob.completed"); + } - public void fail() { - if (completed) { - throw StatusException.badRequest( - "Cannot fail an analyze job that has already completed.", "AnalyzeJob.completed"); + completed = true; + CompleteAnalyzeJob.completeAnalyzeJob(user, month); } - FailAnalyzeJob.failAnalyzeJob(user, month); - } + public void fail() { + if (completed) { + throw StatusException.badRequest( + "Cannot fail an analyze job that has already completed.", + "AnalyzeJob.completed"); + } + + FailAnalyzeJob.failAnalyzeJob(user, month); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/Insight.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/Insight.java index 311c54f7..ee5e6577 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/Insight.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/Insight.java @@ -2,5 +2,5 @@ public interface Insight { - void signal(); + void signal(); } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/InsightType.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/InsightType.java index 7be3b73b..ac73721f 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/InsightType.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/InsightType.java @@ -2,12 +2,12 @@ /** Types of spending insights that can be detected. */ public enum InsightType { - UNUSUAL_AMOUNT, // Transaction amount is unusually high or low for this category - UNUSUAL_FREQUENCY, // Transaction frequency is unusual for this category - UNUSUAL_MERCHANT, // Transaction with an unusual merchant for this category - UNUSUAL_TIMING, // Transaction at an unusual time (day/hour) for this category - POTENTIAL_DUPLICATE, // Transaction might be a duplicate of another transaction - BUDGET_EXCEEDED, // Transaction causes a budget to be exceeded - SPENDING_SPIKE, // Sudden spike in spending in a category - UNUSUAL_LOCATION // Transaction in an unusual location + UNUSUAL_AMOUNT, // Transaction amount is unusually high or low for this category + UNUSUAL_FREQUENCY, // Transaction frequency is unusual for this category + UNUSUAL_MERCHANT, // Transaction with an unusual merchant for this category + UNUSUAL_TIMING, // Transaction at an unusual time (day/hour) for this category + POTENTIAL_DUPLICATE, // Transaction might be a duplicate of another transaction + BUDGET_EXCEEDED, // Transaction causes a budget to be exceeded + SPENDING_SPIKE, // Sudden spike in spending in a category + UNUSUAL_LOCATION // Transaction in an unusual location } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/PatternType.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/PatternType.java index 10d79f4c..0d6a77f5 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/PatternType.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/PatternType.java @@ -2,9 +2,9 @@ /** Types of spending patterns that can be detected. */ public enum PatternType { - RECURRING_MONTHLY, // Regular monthly payments (e.g., rent, subscriptions) - RECURRING_WEEKLY, // Regular weekly payments (e.g., groceries) - SEASONAL, // Seasonal spending (e.g., holiday shopping, summer vacations) - INCREASING_TREND, // Gradually increasing spending in a category - DECREASING_TREND // Gradually decreasing spending in a category + RECURRING_MONTHLY, // Regular monthly payments (e.g., rent, subscriptions) + RECURRING_WEEKLY, // Regular weekly payments (e.g., groceries) + SEASONAL, // Seasonal spending (e.g., holiday shopping, summer vacations) + INCREASING_TREND, // Gradually increasing spending in a category + DECREASING_TREND // Gradually decreasing spending in a category } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/Severity.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/Severity.java index 57edc406..a68313fa 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/Severity.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/Severity.java @@ -2,7 +2,7 @@ /** Severity levels for insights. */ public enum Severity { - INFO, // Informational insight - WARNING, // Warning insight - ALERT // Alert insight requiring attention + INFO, // Informational insight + WARNING, // Warning insight + ALERT // Alert insight requiring attention } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingInsight.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingInsight.java index 1a922cb5..8e7f023a 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingInsight.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingInsight.java @@ -1,69 +1,71 @@ package com.jongsoft.finance.domain.insight; import com.jongsoft.finance.messaging.commands.insight.CreateSpendingInsight; + +import lombok.Builder; +import lombok.Getter; + import java.time.LocalDate; import java.util.Map; import java.util.Objects; -import lombok.Builder; -import lombok.Getter; @Getter public class SpendingInsight implements Insight { - private final InsightType type; - private final String category; - private final Severity severity; - private final double score; - private final LocalDate detectedDate; - private final String message; - private final Long transactionId; - private final Map metadata; + private final InsightType type; + private final String category; + private final Severity severity; + private final double score; + private final LocalDate detectedDate; + private final String message; + private final Long transactionId; + private final Map metadata; - @Builder - private SpendingInsight( - InsightType type, - String category, - Severity severity, - double score, - Long transactionId, - LocalDate detectedDate, - String message, - Map metadata) { - this.type = type; - this.category = category; - this.severity = severity; - this.score = score; - this.detectedDate = detectedDate; - this.message = message; - this.transactionId = transactionId; - this.metadata = metadata; - } - - @Override - public void signal() { - CreateSpendingInsight.createSpendingInsight(this); - } + @Builder + private SpendingInsight( + InsightType type, + String category, + Severity severity, + double score, + Long transactionId, + LocalDate detectedDate, + String message, + Map metadata) { + this.type = type; + this.category = category; + this.severity = severity; + this.score = score; + this.detectedDate = detectedDate; + this.message = message; + this.transactionId = transactionId; + this.metadata = metadata; + } - @Override - public boolean equals(Object obj) { - if (obj instanceof SpendingInsight other) { - return this.type == other.type - && Objects.equals(this.detectedDate, other.detectedDate) - && this.category.equalsIgnoreCase(other.category) - && Objects.equals(this.transactionId, other.transactionId); + @Override + public void signal() { + CreateSpendingInsight.createSpendingInsight(this); } - return false; - } + @Override + public boolean equals(Object obj) { + if (obj instanceof SpendingInsight other) { + return this.type == other.type + && Objects.equals(this.detectedDate, other.detectedDate) + && this.category.equalsIgnoreCase(other.category) + && Objects.equals(this.transactionId, other.transactionId); + } + + return false; + } - @Override - public int hashCode() { - return Objects.hash(type, detectedDate, category, transactionId); - } + @Override + public int hashCode() { + return Objects.hash(type, detectedDate, category, transactionId); + } - @Override - public String toString() { - return "[%s] %s (severity:%s, detectedDate:%s): %s" - .formatted(type, category, severity, detectedDate, transactionId); - } + @Override + public String toString() { + return "[%s] %s (severity:%s, detectedDate:%s): %s" + .formatted(type, category, severity, detectedDate, transactionId); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingPattern.java b/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingPattern.java index ca408de2..673094bc 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingPattern.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/insight/SpendingPattern.java @@ -1,59 +1,61 @@ package com.jongsoft.finance.domain.insight; import com.jongsoft.finance.messaging.commands.insight.CreateSpendingPattern; + +import lombok.Builder; +import lombok.Getter; + import java.time.LocalDate; import java.util.Map; import java.util.Objects; -import lombok.Builder; -import lombok.Getter; @Getter public class SpendingPattern implements Insight { - private final PatternType type; - private final String category; - private final double confidence; - private final LocalDate detectedDate; - private final Map metadata; - - @Builder - private SpendingPattern( - PatternType type, - String category, - double confidence, - LocalDate detectedDate, - Map metadata) { - this.type = type; - this.category = category; - this.confidence = confidence; - this.detectedDate = detectedDate; - this.metadata = metadata; - } - - @Override - public void signal() { - CreateSpendingPattern.createSpendingPattern(this); - } - - @Override - public boolean equals(Object obj) { - if (obj instanceof SpendingPattern other) { - return this.type == other.type - && Objects.equals(this.detectedDate, other.detectedDate) - && this.category.equalsIgnoreCase(other.category); + private final PatternType type; + private final String category; + private final double confidence; + private final LocalDate detectedDate; + private final Map metadata; + + @Builder + private SpendingPattern( + PatternType type, + String category, + double confidence, + LocalDate detectedDate, + Map metadata) { + this.type = type; + this.category = category; + this.confidence = confidence; + this.detectedDate = detectedDate; + this.metadata = metadata; } - return false; - } + @Override + public void signal() { + CreateSpendingPattern.createSpendingPattern(this); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof SpendingPattern other) { + return this.type == other.type + && Objects.equals(this.detectedDate, other.detectedDate) + && this.category.equalsIgnoreCase(other.category); + } + + return false; + } - @Override - public int hashCode() { - return Objects.hash(type, category, detectedDate); - } + @Override + public int hashCode() { + return Objects.hash(type, category, detectedDate); + } - @Override - public String toString() { - return "[%s] %s (confidence:%.2f%%, detectedDate:%s)" - .formatted(type, category, confidence * 100, detectedDate); - } + @Override + public String toString() { + return "[%s] %s (confidence:%.2f%%, detectedDate:%s)" + .formatted(type, category, confidence * 100, detectedDate); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/transaction/ScheduledTransaction.java b/domain/src/main/java/com/jongsoft/finance/domain/transaction/ScheduledTransaction.java index fdb83b43..2235e8ae 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/transaction/ScheduledTransaction.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/transaction/ScheduledTransaction.java @@ -13,106 +13,111 @@ import com.jongsoft.finance.schedule.Schedulable; import com.jongsoft.finance.schedule.Schedule; import com.jongsoft.lang.Control; -import java.time.LocalDate; + import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; + @Getter @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) public class ScheduledTransaction implements AggregateBase, Schedulable { - private Long id; - - private String name; - private String description; - private double amount; + private Long id; - private Account source; - private Account destination; + private String name; + private String description; + private double amount; - private Contract contract; + private Account source; + private Account destination; - private Schedule schedule; - private LocalDate start; - private LocalDate end; + private Contract contract; - public ScheduledTransaction( - String name, Schedule schedule, Account source, Account destination, double amount) { - this.name = name; - this.source = source; - this.destination = destination; - this.amount = amount; - this.schedule = schedule; + private Schedule schedule; + private LocalDate start; + private LocalDate end; + private boolean deleted; - CreateScheduleCommand.scheduleCreated(name, schedule, source, destination, amount); - } + public ScheduledTransaction( + String name, Schedule schedule, Account source, Account destination, double amount) { + this.name = name; + this.source = source; + this.destination = destination; + this.amount = amount; + this.schedule = schedule; - @BusinessMethod - public void limit(LocalDate start, LocalDate end) { - if (start.isAfter(end)) { - throw StatusException.badRequest( - "Start of scheduled transaction cannot be after end date.", - "validation.transaction.schedule.end.before.start"); + CreateScheduleCommand.scheduleCreated(name, schedule, source, destination, amount); } - var hasChanged = Control.Equal(this.start, start).append(this.end, end).isNotEqual(); - - if (hasChanged) { - this.start = start; - this.end = end; - LimitScheduleCommand.scheduleCreated(id, this, start, end); + @BusinessMethod + public void limit(LocalDate start, LocalDate end) { + if (start.isAfter(end)) { + throw StatusException.badRequest( + "Start of scheduled transaction cannot be after end date.", + "validation.transaction.schedule.end.before.start"); + } + + var hasChanged = Control.Equal(this.start, start).append(this.end, end).isNotEqual(); + + if (hasChanged) { + this.start = start; + this.end = end; + LimitScheduleCommand.scheduleCreated(id, this, start, end); + } } - } - @BusinessMethod - public void limitForContract() { - if (contract == null) { - throw StatusException.badRequest("Cannot limit based on a contract when no contract is set."); + @BusinessMethod + public void limitForContract() { + if (contract == null) { + throw StatusException.badRequest( + "Cannot limit based on a contract when no contract is set."); + } + + var expectedEnd = contract.getEndDate().plusYears(20); + if (end == null || !end.isEqual(expectedEnd)) { + this.start = Control.Option(this.start).getOrSupply(contract::getStartDate); + this.end = expectedEnd; + LimitScheduleCommand.scheduleCreated(id, this, start, end); + } } - var expectedEnd = contract.getEndDate().plusYears(20); - if (end == null || !end.isEqual(expectedEnd)) { - this.start = Control.Option(this.start).getOrSupply(contract::getStartDate); - this.end = expectedEnd; - LimitScheduleCommand.scheduleCreated(id, this, start, end); - } - } + @BusinessMethod + public void terminate() { + if (this.start == null) { + this.start = LocalDate.now().minusDays(1); + } - @BusinessMethod - public void terminate() { - if (this.start == null) { - this.start = LocalDate.now().minusDays(1); + this.end = LocalDate.now(); + LimitScheduleCommand.scheduleCreated(id, this, start, end); } - this.end = LocalDate.now(); - LimitScheduleCommand.scheduleCreated(id, this, start, end); - } - - @BusinessMethod - public void adjustSchedule(Periodicity periodicity, int interval) { - var hasChanged = this.schedule == null - || Control.Equal(this.schedule.interval(), interval) - .append(this.schedule.periodicity(), periodicity) - .isNotEqual(); - - if (hasChanged) { - this.schedule = new ScheduleValue(periodicity, interval); - RescheduleCommand.scheduleRescheduled(id, this, schedule); + @BusinessMethod + public void adjustSchedule(Periodicity periodicity, int interval) { + var hasChanged = this.schedule == null + || Control.Equal(this.schedule.interval(), interval) + .append(this.schedule.periodicity(), periodicity) + .isNotEqual(); + + if (hasChanged) { + this.schedule = new ScheduleValue(periodicity, interval); + RescheduleCommand.scheduleRescheduled(id, this, schedule); + } } - } - - @BusinessMethod - public void describe(String name, String description) { - var hasChanged = - Control.Equal(this.name, name).append(this.description, description).isNotEqual(); - if (hasChanged) { - this.name = name; - this.description = description; - DescribeScheduleCommand.scheduleDescribed(id, name, description); + @BusinessMethod + public void describe(String name, String description) { + var hasChanged = Control.Equal(this.name, name) + .append(this.description, description) + .isNotEqual(); + + if (hasChanged) { + this.name = name; + this.description = description; + DescribeScheduleCommand.scheduleDescribed(id, name, description); + } } - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/transaction/Tag.java b/domain/src/main/java/com/jongsoft/finance/domain/transaction/Tag.java index 9cfbfaad..ebeaddda 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/transaction/Tag.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/transaction/Tag.java @@ -2,41 +2,42 @@ import com.jongsoft.finance.annotation.BusinessMethod; import com.jongsoft.finance.messaging.commands.transaction.DeleteTagCommand; + import java.util.Objects; public class Tag { - private final String name; - - public Tag(String name) { - this.name = name; - } + private final String name; - public String name() { - return this.name; - } + public Tag(String name) { + this.name = name; + } - @BusinessMethod - public void archive() { - DeleteTagCommand.tagDeleted(name); - } + public String name() { + return this.name; + } - @Override - public boolean equals(Object o) { - if (o instanceof Tag tag) { - return name.equals(tag.name); + @BusinessMethod + public void archive() { + DeleteTagCommand.tagDeleted(name); } - return false; - } + @Override + public boolean equals(Object o) { + if (o instanceof Tag tag) { + return name.equals(tag.name); + } - @Override - public int hashCode() { - return Objects.hash(name); - } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(name); + } - @Override - public String toString() { - return name; - } + @Override + public String toString() { + return name; + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/transaction/Transaction.java b/domain/src/main/java/com/jongsoft/finance/domain/transaction/Transaction.java index f0a95efb..0fd2733b 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/transaction/Transaction.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/transaction/Transaction.java @@ -10,15 +10,17 @@ import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.List; import com.jongsoft.lang.collection.Sequence; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Date; import java.util.Objects; import java.util.function.Predicate; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; @Getter @Aggregate @@ -26,290 +28,296 @@ @Builder(toBuilder = true) public class Transaction implements AggregateBase, Serializable { - private static final Predicate FROM_PREDICATE = t -> t.amount < 0D; - private static final Predicate TO_PREDICATE = t -> t.amount > 0D; + private static final Predicate FROM_PREDICATE = t -> t.amount < 0D; + private static final Predicate TO_PREDICATE = t -> t.amount > 0D; - @Getter - public enum Type { - CREDIT("long-arrow-alt-left"), - DEBIT("long-arrow-alt-right"), - TRANSFER("exchange-alt"); + @Getter + public enum Type { + CREDIT("long-arrow-alt-left"), + DEBIT("long-arrow-alt-right"), + TRANSFER("exchange-alt"); - private final String style; + private final String style; - Type(String style) { - this.style = style; + Type(String style) { + this.style = style; + } } - } - @Getter - @Builder - @AllArgsConstructor - public static class Part implements AggregateBase, Serializable { + @Getter + @Builder + @AllArgsConstructor + public static class Part implements AggregateBase, Serializable { + + private Long id; + private String description; + private double amount; + private Account account; + + public Part(Account account, double amount) { + this.account = account; + this.amount = amount; + } + } private Long id; - private String description; - private double amount; - private Account account; - public Part(Account account, double amount) { - this.account = account; - this.amount = amount; + private LocalDate date; + private LocalDate interestDate; + private LocalDate bookDate; + + private String description; + private String currency; + private String category; + private String budget; + private String contract; + private String importSlug; + private Sequence tags; + + private Sequence transactions; + private FailureCode failureCode; + + private Date created; + private Date updated; + private boolean deleted; + + public Transaction(Account from, Account to, double amount) { + var toAmount = Math.abs(amount); + var fromAmount = 0 - Math.abs(amount); + + this.transactions = Collections.List( + Part.builder().account(from).amount(fromAmount).build(), + Part.builder().account(to).amount(toAmount).build()); } - } - - private Long id; - - private LocalDate date; - private LocalDate interestDate; - private LocalDate bookDate; - - private String description; - private String currency; - private String category; - private String budget; - private String contract; - private String importSlug; - private Sequence tags; - - private Sequence transactions; - private FailureCode failureCode; - - private Date created; - private Date updated; - - public Transaction(Account from, Account to, double amount) { - var toAmount = Math.abs(amount); - var fromAmount = 0 - Math.abs(amount); - - this.transactions = Collections.List( - Part.builder().account(from).amount(fromAmount).build(), - Part.builder().account(to).amount(toAmount).build()); - } - - @BusinessMethod - public void book(LocalDate date, LocalDate bookDate, LocalDate interestDate) { - var hasChanged = Control.Equal(date, this.date) - .append(bookDate, this.bookDate) - .append(interestDate, this.interestDate) - .isNotEqual(); - - if (hasChanged) { - this.date = date; - this.bookDate = bookDate; - this.interestDate = interestDate; - ChangeTransactionDatesCommand.transactionDatesChanged(id, date, bookDate, interestDate); + + @BusinessMethod + public void book(LocalDate date, LocalDate bookDate, LocalDate interestDate) { + var hasChanged = Control.Equal(date, this.date) + .append(bookDate, this.bookDate) + .append(interestDate, this.interestDate) + .isNotEqual(); + + if (hasChanged) { + this.date = date; + this.bookDate = bookDate; + this.interestDate = interestDate; + ChangeTransactionDatesCommand.transactionDatesChanged(id, date, bookDate, interestDate); + } } - } - @BusinessMethod - public void describe(String description) { - if (Control.Equal(this.description, description).isNotEqual()) { - this.description = description; - DescribeTransactionCommand.transactionDescribed(id, description); + @BusinessMethod + public void describe(String description) { + if (Control.Equal(this.description, description).isNotEqual()) { + this.description = description; + DescribeTransactionCommand.transactionDescribed(id, description); + } } - } - - @BusinessMethod - public void changeAmount(double amount, String currency) { - var hasChanged = Control.Equal(Math.abs(this.computeAmount(this.computeTo())), amount) - .append(this.currency, currency) - .isNotEqual(); - - if (hasChanged) { - if (transactions.size() != 2) { - throw new IllegalStateException( - "Transaction amount cannot be changed for split transactions"); - } - - this.currency = currency; - this.transactions.forEach( - t -> t.amount = t.amount < 0 ? 0 - Math.abs(amount) : Math.abs(amount)); - ChangeTransactionAmountCommand.amountChanged(id, BigDecimal.valueOf(amount), currency); + + @BusinessMethod + public void changeAmount(double amount, String currency) { + var hasChanged = Control.Equal(Math.abs(this.computeAmount(this.computeTo())), amount) + .append(this.currency, currency) + .isNotEqual(); + + if (hasChanged) { + if (transactions.size() != 2) { + throw new IllegalStateException( + "Transaction amount cannot be changed for split transactions"); + } + + this.currency = currency; + this.transactions.forEach( + t -> t.amount = t.amount < 0 ? 0 - Math.abs(amount) : Math.abs(amount)); + ChangeTransactionAmountCommand.amountChanged(id, BigDecimal.valueOf(amount), currency); + } } - } - @BusinessMethod - public void split(List split) { - if (computeFrom().isManaged() && computeTo().isManaged()) { - throw new IllegalStateException( - "Transaction cannot be split when both accounts are your own"); + @BusinessMethod + public void split(List split) { + if (computeFrom().isManaged() && computeTo().isManaged()) { + throw new IllegalStateException( + "Transaction cannot be split when both accounts are your own"); + } + + var notOwn = computeTo().isManaged() ? computeFrom() : computeTo(); + var totalAmount = split.foldLeft(0D, (total, record) -> total + record.amount()); + + var isCredit = computeType() == Type.CREDIT; + if (Math.abs(totalAmount) != Math.abs(computeAmount(notOwn))) { + var multiplier = isCredit ? -1 : 1; + var ownPart = this.transactions + .filter(t -> !t.getAccount().equals(notOwn)) + .head(); + ownPart.amount = multiplier * split.map(SplitRecord::amount).reduce(Double::sum); + } + + var splitParts = split.map(record -> Part.builder() + .account(notOwn) + .description(record.description()) + .amount(isCredit ? Math.abs(record.amount()) : -Math.abs(record.amount())) + .build()); + + this.transactions = + this.transactions.reject(t -> t.getAccount().equals(notOwn)).union(splitParts); + + SplitTransactionCommand.transactionSplit(id, transactions); } - var notOwn = computeTo().isManaged() ? computeFrom() : computeTo(); - var totalAmount = split.foldLeft(0D, (total, record) -> total + record.amount()); + @BusinessMethod + public void changeAccount(boolean isFromAccount, Account account) { + var original = isFromAccount ? computeFrom() : computeTo(); + if (!Objects.equals(original, account)) { + + var other = isFromAccount ? computeTo() : computeFrom(); + if (other.equals(account)) { + failureCode = FailureCode.FROM_TO_SAME; + } + + transactions.filter(isFromAccount ? FROM_PREDICATE : TO_PREDICATE).forEach(t -> { + t.account = account; + ChangeTransactionPartAccount.transactionPartAccountChanged( + t.getId(), account.getId()); + }); + } + } - var isCredit = computeType() == Type.CREDIT; - if (Math.abs(totalAmount) != Math.abs(computeAmount(notOwn))) { - var multiplier = isCredit ? -1 : 1; - var ownPart = - this.transactions.filter(t -> !t.getAccount().equals(notOwn)).head(); - ownPart.amount = multiplier * split.map(SplitRecord::amount).reduce(Double::sum); + @BusinessMethod + public void linkToCategory(String label) { + if (!Objects.equals(this.category, label)) { + this.category = label; + LinkTransactionCommand.linkCreated( + id, LinkTransactionCommand.LinkType.CATEGORY, category); + } } - var splitParts = split.map(record -> Part.builder() - .account(notOwn) - .description(record.description()) - .amount(isCredit ? Math.abs(record.amount()) : -Math.abs(record.amount())) - .build()); + @BusinessMethod + public void linkToBudget(String budget) { + if (budget != null && !Objects.equals(this.budget, budget)) { + this.budget = budget; + LinkTransactionCommand.linkCreated(id, LinkTransactionCommand.LinkType.EXPENSE, budget); + } + } - this.transactions = - this.transactions.reject(t -> t.getAccount().equals(notOwn)).union(splitParts); + @BusinessMethod + public void linkToContract(String contract) { + if (!Objects.equals(this.contract, contract)) { + this.contract = contract; + LinkTransactionCommand.linkCreated( + id, LinkTransactionCommand.LinkType.CONTRACT, contract); + } + } - SplitTransactionCommand.transactionSplit(id, transactions); - } + @BusinessMethod + public void tag(Sequence tags) { + if (!Objects.equals(this.tags, tags)) { + this.tags = tags; + TagTransactionCommand.tagCreated(id, tags); + } + } - @BusinessMethod - public void changeAccount(boolean isFromAccount, Account account) { - var original = isFromAccount ? computeFrom() : computeTo(); - if (!Objects.equals(original, account)) { + @BusinessMethod + public void registerFailure(FailureCode failureCode) { + this.failureCode = failureCode; + RegisterFailureCommand.registerFailure(id, failureCode); + } - var other = isFromAccount ? computeTo() : computeFrom(); - if (other.equals(account)) { - failureCode = FailureCode.FROM_TO_SAME; - } + @BusinessMethod + public void linkToImport(String slug) { + if (this.importSlug != null) { + throw new IllegalStateException( + "Cannot link transaction to an import, it's already linked."); + } - transactions.filter(isFromAccount ? FROM_PREDICATE : TO_PREDICATE).forEach(t -> { - t.account = account; - ChangeTransactionPartAccount.transactionPartAccountChanged(t.getId(), account.getId()); - }); + this.importSlug = slug; } - } - @BusinessMethod - public void linkToCategory(String label) { - if (!Objects.equals(this.category, label)) { - this.category = label; - LinkTransactionCommand.linkCreated(id, LinkTransactionCommand.LinkType.CATEGORY, category); - } - } + @BusinessMethod + public void register() { + if (this.created != null) { + throw new IllegalStateException( + "Cannot register transaction it already exists in the system."); + } + + if (computeFrom().equals(computeTo())) { + failureCode = FailureCode.FROM_TO_SAME; + } else if (transactions.stream().mapToDouble(Part::getAmount).sum() != 0) { + failureCode = FailureCode.AMOUNT_NOT_NULL; + } - @BusinessMethod - public void linkToBudget(String budget) { - if (budget != null && !Objects.equals(this.budget, budget)) { - this.budget = budget; - LinkTransactionCommand.linkCreated(id, LinkTransactionCommand.LinkType.EXPENSE, budget); + CreateTransactionCommand.transactionCreated(this); } - } - @BusinessMethod - public void linkToContract(String contract) { - if (!Objects.equals(this.contract, contract)) { - this.contract = contract; - LinkTransactionCommand.linkCreated(id, LinkTransactionCommand.LinkType.CONTRACT, contract); + @BusinessMethod + public void delete() { + if (id == null) { + throw new IllegalStateException("Cannot delete a transaction not yet persisted."); + } + + DeleteTransactionCommand.transactionDeleted(id); } - } - @BusinessMethod - public void tag(Sequence tags) { - if (!Objects.equals(this.tags, tags)) { - this.tags = tags; - TagTransactionCommand.tagCreated(id, tags); + /** + * Calculate the amount being transferred to the specified account in this transaction. + * + * @param account the account to calculate the transfer for + * @return the calculated amount + */ + public double computeAmount(Account account) { + return transactions + .filter(t -> Objects.equals(t.getAccount(), account)) + .map(Part::getAmount) + .reduce(Double::sum); } - } - - @BusinessMethod - public void registerFailure(FailureCode failureCode) { - this.failureCode = failureCode; - RegisterFailureCommand.registerFailure(id, failureCode); - } - - @BusinessMethod - public void linkToImport(String slug) { - if (this.importSlug != null) { - throw new IllegalStateException("Cannot link transaction to an import, it's already linked."); + + public Account computeFrom() { + return transactions + .first(FROM_PREDICATE) + .map(Part::getAccount) + .getOrThrow(() -> new IllegalStateException("Transaction has no from account.")); } - this.importSlug = slug; - } + public Account computeTo() { + return transactions + .first(TO_PREDICATE) + .map(Part::getAccount) + .getOrThrow(() -> new IllegalStateException("Transaction has no to account.")); + } - @BusinessMethod - public void register() { - if (this.created != null) { - throw new IllegalStateException( - "Cannot register transaction it already exists in the system."); + public Account computeCounter(Account account) { + return transactions + .first(t -> !Objects.equals(t.getAccount(), account)) + .map(Part::getAccount) + .get(); } - if (computeFrom().equals(computeTo())) { - failureCode = FailureCode.FROM_TO_SAME; - } else if (transactions.stream().mapToDouble(Part::getAmount).sum() != 0) { - failureCode = FailureCode.AMOUNT_NOT_NULL; + public Transaction.Type computeType() { + var fromManaged = computeFrom().isManaged(); + var toManaged = computeTo().isManaged(); + + if (fromManaged && toManaged) { + return Type.TRANSFER; + } else if (fromManaged) { + return Type.CREDIT; + } else { + return Type.DEBIT; + } } - CreateTransactionCommand.transactionCreated(this); - } + public boolean isSplit() { + return transactions.size() > 2; + } - @BusinessMethod - public void delete() { - if (id == null) { - throw new IllegalStateException("Cannot delete a transaction not yet persisted."); + public boolean isDebit(Account account) { + return computeAmount(account) > 0; } - DeleteTransactionCommand.transactionDeleted(id); - } - - /** - * Calculate the amount being transferred to the specified account in this transaction. - * - * @param account the account to calculate the transfer for - * @return the calculated amount - */ - public double computeAmount(Account account) { - return transactions - .filter(t -> Objects.equals(t.getAccount(), account)) - .map(Part::getAmount) - .reduce(Double::sum); - } - - public Account computeFrom() { - return transactions - .first(FROM_PREDICATE) - .map(Part::getAccount) - .getOrThrow(() -> new IllegalStateException("Transaction has no from account.")); - } - - public Account computeTo() { - return transactions - .first(TO_PREDICATE) - .map(Part::getAccount) - .getOrThrow(() -> new IllegalStateException("Transaction has no to account.")); - } - - public Account computeCounter(Account account) { - return transactions - .first(t -> !Objects.equals(t.getAccount(), account)) - .map(Part::getAccount) - .get(); - } - - public Transaction.Type computeType() { - var fromManaged = computeFrom().isManaged(); - var toManaged = computeTo().isManaged(); - - if (fromManaged && toManaged) { - return Type.TRANSFER; - } else if (fromManaged) { - return Type.CREDIT; - } else { - return Type.DEBIT; + @Override + public String toString() { + return "Transaction from " + + computeFrom() + + " to " + + computeTo() + + " with amount " + + computeAmount(computeFrom()); } - } - - public boolean isSplit() { - return transactions.size() > 2; - } - - public boolean isDebit(Account account) { - return computeAmount(account) > 0; - } - - @Override - public String toString() { - return "Transaction from " - + computeFrom() - + " to " - + computeTo() - + " with amount " - + computeAmount(computeFrom()); - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRule.java b/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRule.java index 16717bfb..63f91094 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRule.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRule.java @@ -10,185 +10,192 @@ import com.jongsoft.finance.messaging.commands.rule.ChangeConditionCommand; import com.jongsoft.finance.messaging.commands.rule.ChangeRuleCommand; import com.jongsoft.finance.messaging.commands.rule.ReorderRuleCommand; +import com.jongsoft.finance.messaging.commands.rule.RuleRemovedCommand; import com.jongsoft.lang.Collections; import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.List; -import java.io.Serializable; -import java.util.Objects; + import lombok.Builder; import lombok.Generated; import lombok.Getter; import lombok.ToString; +import java.io.Serializable; +import java.util.Objects; + @Getter @Builder @Aggregate @ToString(of = {"id", "name"}) public class TransactionRule implements AggregateBase { - @Getter - public class Condition implements Serializable, Removable { - private Long id; - private RuleColumn field; - private RuleOperation operation; - private String condition; - - public Condition(Long id, RuleColumn field, RuleOperation operation, String condition) { - this.id = id; - this.field = field; - this.operation = operation; - this.condition = condition; - - conditions = conditions.append(this); - } - - public void update(RuleColumn field, RuleOperation operation, String condition) { - var hasChanged = Control.Equal(this.field, field) - .append(this.operation, operation) - .append(this.condition, condition) - .isNotEqual(); - - if (hasChanged) { - this.field = field; - this.operation = operation; - this.condition = condition; - - ChangeConditionCommand.changeConditionUpdated(id, field, operation, condition); - } + @Getter + public class Condition implements Serializable, Removable { + private Long id; + private RuleColumn field; + private RuleOperation operation; + private String condition; + + public Condition(Long id, RuleColumn field, RuleOperation operation, String condition) { + this.id = id; + this.field = field; + this.operation = operation; + this.condition = condition; + + conditions = conditions.append(this); + } + + public void update(RuleColumn field, RuleOperation operation, String condition) { + var hasChanged = Control.Equal(this.field, field) + .append(this.operation, operation) + .append(this.condition, condition) + .isNotEqual(); + + if (hasChanged) { + this.field = field; + this.operation = operation; + this.condition = condition; + + ChangeConditionCommand.changeConditionUpdated(id, field, operation, condition); + } + } + + @BusinessMethod + public void delete() { + conditions = conditions.reject(c -> Objects.equals(c.getId(), id)); + } } - @BusinessMethod - public void delete() { - conditions = conditions.reject(c -> Objects.equals(c.getId(), id)); + @Getter + public class Change implements Serializable, Removable { + private Long id; + private RuleColumn field; + private String change; + + public Change(Long id, RuleColumn field, String change) { + this.id = id; + this.field = field; + this.change = change; + + changes = changes.append(this); + } + + public void update(RuleColumn ruleColumn, String change) { + var hasChanged = Control.Equal(ruleColumn, this.field) + .append(change, this.change) + .isNotEqual(); + + if (hasChanged) { + this.field = ruleColumn; + this.change = change; + + ChangeRuleCommand.changeRuleUpdated(id, ruleColumn, change); + } + } + + @BusinessMethod + public void delete() { + changes = changes.reject(r -> Objects.equals(r.getId(), id)); + } } - } - @Getter - public class Change implements Serializable, Removable { private Long id; - private RuleColumn field; - private String change; - - public Change(Long id, RuleColumn field, String change) { - this.id = id; - this.field = field; - this.change = change; - - changes = changes.append(this); + private String name; + private String description; + private boolean restrictive; + private boolean active; + private boolean deleted; + private String group; + private int sort; + + private List conditions; + private List changes; + + private transient UserAccount user; + + public TransactionRule(UserAccount user, String name, boolean restrictive) { + this.user = user; + this.name = name; + this.restrictive = restrictive; + this.conditions = Collections.List(); + this.changes = Collections.List(); } - public void update(RuleColumn ruleColumn, String change) { - var hasChanged = - Control.Equal(ruleColumn, this.field).append(change, this.change).isNotEqual(); - - if (hasChanged) { - this.field = ruleColumn; - this.change = change; + @Generated + public TransactionRule( + Long id, + String name, + String description, + boolean restrictive, + boolean active, + boolean deleted, + String group, + int sort, + List conditions, + List changes, + UserAccount user) { + this.id = id; + this.name = name; + this.description = description; + this.restrictive = restrictive; + this.active = active; + this.deleted = deleted; + this.group = group; + this.sort = sort; + this.conditions = Control.Option(conditions).getOrSupply(Collections::List); + this.changes = Control.Option(changes).getOrSupply(Collections::List); + this.user = user; + } - ChangeRuleCommand.changeRuleUpdated(id, ruleColumn, change); - } + public void change(String name, String description, boolean restrictive, boolean active) { + this.name = name; + this.restrictive = restrictive; + this.active = active; + this.description = description; } - @BusinessMethod - public void delete() { - changes = changes.reject(r -> Objects.equals(r.getId(), id)); + public void changeOrder(int sort) { + if (this.sort != sort) { + this.sort = sort; + if (id != null) { + ReorderRuleCommand.reorderRuleUpdated(id, sort); + } + } } - } - - private Long id; - private String name; - private String description; - private boolean restrictive; - private boolean active; - private boolean deleted; - private String group; - private int sort; - - private List conditions; - private List changes; - - private transient UserAccount user; - - public TransactionRule(UserAccount user, String name, boolean restrictive) { - this.user = user; - this.name = name; - this.restrictive = restrictive; - this.conditions = Collections.List(); - this.changes = Collections.List(); - } - - @Generated - public TransactionRule( - Long id, - String name, - String description, - boolean restrictive, - boolean active, - boolean deleted, - String group, - int sort, - List conditions, - List changes, - UserAccount user) { - this.id = id; - this.name = name; - this.description = description; - this.restrictive = restrictive; - this.active = active; - this.deleted = deleted; - this.group = group; - this.sort = sort; - this.conditions = Control.Option(conditions).getOrSupply(Collections::List); - this.changes = Control.Option(changes).getOrSupply(Collections::List); - this.user = user; - } - - public void change(String name, String description, boolean restrictive, boolean active) { - this.name = name; - this.restrictive = restrictive; - this.active = active; - this.description = description; - } - - public void changeOrder(int sort) { - if (this.sort != sort) { - this.sort = sort; - if (id != null) { - ReorderRuleCommand.reorderRuleUpdated(id, sort); - } + + public void assign(String ruleGroup) { + this.group = ruleGroup; } - } - public void assign(String ruleGroup) { - this.group = ruleGroup; - } + public void remove() { + deleted = true; + RuleRemovedCommand.ruleRemoved(id); + } - public void remove() { - deleted = true; - } + public void registerCondition(RuleColumn field, RuleOperation operation, String condition) { + if (conditions == null) { + conditions = Collections.List(); + } - public void registerCondition(RuleColumn field, RuleOperation operation, String condition) { - if (conditions == null) { - conditions = Collections.List(); + new Condition(null, field, operation, condition); } - new Condition(null, field, operation, condition); - } + public void registerChange(RuleColumn field, String value) { + if (changes == null) { + changes = Collections.List(); + } - public void registerChange(RuleColumn field, String value) { - if (changes == null) { - changes = Collections.List(); + changes = changes.reject(c -> Objects.equals(c.getField(), field)); + new Change(null, field, value); } - changes = changes.reject(c -> Objects.equals(c.getField(), field)); - new Change(null, field, value); - } - - public Condition findCondition(long conditionId) { - return this.conditions.first(c -> Objects.equals(c.getId(), conditionId)).get(); - } + public Condition findCondition(long conditionId) { + return this.conditions + .first(c -> Objects.equals(c.getId(), conditionId)) + .get(); + } - public Change findChange(long changeId) { - return this.changes.first(c -> Objects.equals(c.getId(), changeId)).get(); - } + public Change findChange(long changeId) { + return this.changes.first(c -> Objects.equals(c.getId(), changeId)).get(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRuleGroup.java b/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRuleGroup.java index ea568b1e..f5156cd7 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRuleGroup.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/transaction/TransactionRuleGroup.java @@ -6,6 +6,7 @@ import com.jongsoft.finance.messaging.commands.rule.RenameRuleGroupCommand; import com.jongsoft.finance.messaging.commands.rule.ReorderRuleGroupCommand; import com.jongsoft.finance.messaging.commands.rule.RuleGroupDeleteCommand; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -16,29 +17,29 @@ @AllArgsConstructor public class TransactionRuleGroup implements AggregateBase { - private Long id; - private String name; - private int sort; - private boolean archived; + private Long id; + private String name; + private int sort; + private boolean archived; - @BusinessMethod - public void changeOrder(int sort) { - if (sort != this.sort) { - this.sort = sort; - ReorderRuleGroupCommand.reorderRuleGroupUpdated(id, sort); + @BusinessMethod + public void changeOrder(int sort) { + if (sort != this.sort) { + this.sort = sort; + ReorderRuleGroupCommand.reorderRuleGroupUpdated(id, sort); + } } - } - @BusinessMethod - public void rename(String name) { - if (!this.name.equalsIgnoreCase(name)) { - this.name = name; - RenameRuleGroupCommand.ruleGroupRenamed(id, name); + @BusinessMethod + public void rename(String name) { + if (!this.name.equalsIgnoreCase(name)) { + this.name = name; + RenameRuleGroupCommand.ruleGroupRenamed(id, name); + } } - } - @BusinessMethod - public void delete() { - RuleGroupDeleteCommand.ruleGroupDeleted(id); - } + @BusinessMethod + public void delete() { + RuleGroupDeleteCommand.ruleGroupDeleted(id); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/user/Budget.java b/domain/src/main/java/com/jongsoft/finance/domain/user/Budget.java index 08f356f3..0f1279df 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/user/Budget.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/user/Budget.java @@ -10,12 +10,14 @@ import com.jongsoft.finance.messaging.commands.budget.UpdateExpenseCommand; import com.jongsoft.lang.Collections; import com.jongsoft.lang.collection.Sequence; + +import lombok.*; + import java.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; import java.util.Objects; -import lombok.*; @Getter @Builder @@ -23,154 +25,154 @@ @AllArgsConstructor public class Budget implements AggregateBase { - @Getter - @ToString(of = "name") - @EqualsAndHashCode(of = "id") - public class Expense implements AggregateBase { - private Long id; - private final String name; - private double lowerBound; - private double upperBound; - - Expense(String name, double lowerBound, double upperBound) { - if (lowerBound >= upperBound) { - throw new IllegalStateException( - "Lower bound of expense cannot be higher then upper bound."); - } - - this.name = name; - this.lowerBound = lowerBound; - this.upperBound = upperBound; - } - - /** - * Create an expense and bind it to its parent budget. This will not register the expense in - * the system yet. - */ - public Expense(long id, String name, double amount) { - this.id = id; - this.name = name; - this.upperBound = amount; - this.lowerBound = amount - 0.01; - expenses = expenses.append(this); - } - - @BusinessMethod - public void updateExpense(double expectedExpense) { - if ((computeExpenses() - computeBudget() + expectedExpense) > expectedIncome) { - throw StatusException.badRequest( - "Expected expenses exceeds the expected income.", - "validation.budget.expense.exceeds.income"); - } - - lowerBound = expectedExpense - .01; - upperBound = expectedExpense; - - UpdateExpenseCommand.expenseUpdated(id, BigDecimal.valueOf(expectedExpense)); + @Getter + @ToString(of = "name") + @EqualsAndHashCode(of = "id") + public class Expense implements AggregateBase { + private Long id; + private final String name; + private double lowerBound; + private double upperBound; + + Expense(String name, double lowerBound, double upperBound) { + if (lowerBound >= upperBound) { + throw new IllegalStateException( + "Lower bound of expense cannot be higher then upper bound."); + } + + this.name = name; + this.lowerBound = lowerBound; + this.upperBound = upperBound; + } + + /** + * Create an expense and bind it to its parent budget. This will not register the expense in + * the system yet. + */ + public Expense(long id, String name, double amount) { + this.id = id; + this.name = name; + this.upperBound = amount; + this.lowerBound = amount - 0.01; + expenses = expenses.append(this); + } + + @BusinessMethod + public void updateExpense(double expectedExpense) { + if ((computeExpenses() - computeBudget() + expectedExpense) > expectedIncome) { + throw StatusException.badRequest( + "Expected expenses exceeds the expected income.", + "validation.budget.expense.exceeds.income"); + } + + lowerBound = expectedExpense - .01; + upperBound = expectedExpense; + + UpdateExpenseCommand.expenseUpdated(id, BigDecimal.valueOf(expectedExpense)); + } + + public double computeBudget() { + return BigDecimal.valueOf(lowerBound) + .add(BigDecimal.valueOf(upperBound)) + .divide(BigDecimal.valueOf(2), new MathContext(6, RoundingMode.HALF_UP)) + .setScale(2, RoundingMode.HALF_UP) + .doubleValue(); + } } - public double computeBudget() { - return BigDecimal.valueOf(lowerBound) - .add(BigDecimal.valueOf(upperBound)) - .divide(BigDecimal.valueOf(2), new MathContext(6, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP) - .doubleValue(); - } - } + private Long id; + private LocalDate start; + private LocalDate end; - private Long id; - private LocalDate start; - private LocalDate end; + @Builder.Default + private Sequence expenses = Collections.List(); - @Builder.Default - private Sequence expenses = Collections.List(); + private double expectedIncome; - private double expectedIncome; + private transient boolean active; - private transient boolean active; + Budget(LocalDate start, double expectedIncome) { + if (expectedIncome < 1) { + throw StatusException.badRequest( + "Expected income cannot be less than 1.", "validation.budget.income.too.low"); + } - Budget(LocalDate start, double expectedIncome) { - if (expectedIncome < 1) { - throw StatusException.badRequest( - "Expected income cannot be less than 1.", "validation.budget.income.too.low"); + this.start = start; + this.expectedIncome = expectedIncome; + this.expenses = Collections.List(); } - this.start = start; - this.expectedIncome = expectedIncome; - this.expenses = Collections.List(); - } - - @BusinessMethod - public Budget indexBudget(LocalDate perDate, double expectedIncome) { - if (!Objects.equals(this.start, perDate)) { - this.close(perDate); - - var deviation = BigDecimal.ONE.add(BigDecimal.valueOf(expectedIncome) - .subtract(BigDecimal.valueOf(this.expectedIncome)) - .divide(BigDecimal.valueOf(this.expectedIncome), 20, RoundingMode.HALF_UP)); - - var newBudget = new Budget(perDate, expectedIncome); - for (var expense : expenses) { - newBudget - .new Expense( - expense.id, - expense.name, - BigDecimal.valueOf(expense.computeBudget()) - .multiply(deviation) - .setScale(0, RoundingMode.CEILING) - .doubleValue()); - } - newBudget.activate(); - - return newBudget; + @BusinessMethod + public Budget indexBudget(LocalDate perDate, double expectedIncome) { + if (!Objects.equals(this.start, perDate)) { + this.close(perDate); + + var deviation = BigDecimal.ONE.add(BigDecimal.valueOf(expectedIncome) + .subtract(BigDecimal.valueOf(this.expectedIncome)) + .divide(BigDecimal.valueOf(this.expectedIncome), 20, RoundingMode.HALF_UP)); + + var newBudget = new Budget(perDate, expectedIncome); + for (var expense : expenses) { + newBudget + .new Expense( + expense.id, + expense.name, + BigDecimal.valueOf(expense.computeBudget()) + .multiply(deviation) + .setScale(0, RoundingMode.CEILING) + .doubleValue()); + } + newBudget.activate(); + + return newBudget; + } + + return this; } - return this; - } - - @BusinessMethod - public void createExpense(String name, double lowerBound, double upperBound) { - if (end != null) { - throw StatusException.badRequest( - "Cannot add expense to an already closed budget period.", - "validation.budget.expense.add.budget.closed"); + @BusinessMethod + public void createExpense(String name, double lowerBound, double upperBound) { + if (end != null) { + throw StatusException.badRequest( + "Cannot add expense to an already closed budget period.", + "validation.budget.expense.add.budget.closed"); + } + + if (computeExpenses() + upperBound > expectedIncome) { + throw StatusException.badRequest( + "Expected expenses exceeds the expected income.", + "validation.budget.expense.exceeds.income"); + } + + expenses = expenses.append(new Expense(name, lowerBound, upperBound)); + CreateExpenseCommand.expenseCreated(name, start, BigDecimal.valueOf(upperBound)); } - if (computeExpenses() + upperBound > expectedIncome) { - throw StatusException.badRequest( - "Expected expenses exceeds the expected income.", - "validation.budget.expense.exceeds.income"); + void activate() { + if (id == null && !active) { + active = true; + CreateBudgetCommand.budgetCreated(this); + } } - expenses = expenses.append(new Expense(name, lowerBound, upperBound)); - CreateExpenseCommand.expenseCreated(name, start, BigDecimal.valueOf(upperBound)); - } + void close(LocalDate endDate) { + if (this.end != null) { + throw StatusException.badRequest( + "Already closed budget cannot be closed again.", + "validation.budget.already.closed"); + } - void activate() { - if (id == null && !active) { - active = true; - CreateBudgetCommand.budgetCreated(this); + this.end = endDate; + CloseBudgetCommand.budgetClosed(id, endDate); } - } - void close(LocalDate endDate) { - if (this.end != null) { - throw StatusException.badRequest( - "Already closed budget cannot be closed again.", "validation.budget.already.closed"); + public double computeExpenses() { + return expenses.stream().mapToDouble(Expense::computeBudget).sum(); } - this.end = endDate; - CloseBudgetCommand.budgetClosed(id, endDate); - } - - public double computeExpenses() { - return expenses.stream().mapToDouble(Expense::computeBudget).sum(); - } - - public Expense determineExpense(String name) { - return expenses - .filter(e -> e.getName().equalsIgnoreCase(name)) - .first(t -> true) - .getOrSupply(() -> null); - } + public Expense determineExpense(String name) { + return expenses.filter(e -> e.getName().equalsIgnoreCase(name)) + .first(t -> true) + .getOrSupply(() -> null); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/user/Category.java b/domain/src/main/java/com/jongsoft/finance/domain/user/Category.java index 10d01557..90b273c0 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/user/Category.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/user/Category.java @@ -7,12 +7,14 @@ import com.jongsoft.finance.messaging.commands.category.DeleteCategoryCommand; import com.jongsoft.finance.messaging.commands.category.RenameCategoryCommand; import com.jongsoft.lang.Control; -import java.time.LocalDate; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import java.time.LocalDate; + @Getter @Builder @Aggregate @@ -20,42 +22,43 @@ @EqualsAndHashCode(of = "id") public class Category implements AggregateBase { - private Long id; - private String label; - private String description; + private Long id; + private String label; + private String description; - private LocalDate lastActivity; - private transient UserAccount user; + private LocalDate lastActivity; + private transient UserAccount user; - private boolean delete; + private boolean delete; - @BusinessMethod - public Category(UserAccount user, String label) { - this.user = user; - this.label = label; - CreateCategoryCommand.categoryCreated(label, description); - } + @BusinessMethod + public Category(UserAccount user, String label) { + this.user = user; + this.label = label; + CreateCategoryCommand.categoryCreated(label, description); + } - @BusinessMethod - public void rename(String label, String description) { - var hasChanged = - Control.Equal(this.label, label).append(this.description, description).isNotEqual(); + @BusinessMethod + public void rename(String label, String description) { + var hasChanged = Control.Equal(this.label, label) + .append(this.description, description) + .isNotEqual(); + + if (hasChanged) { + this.label = label; + this.description = description; + RenameCategoryCommand.categoryRenamed(id, label, description); + } + } + + @BusinessMethod + public void remove() { + this.delete = true; + DeleteCategoryCommand.categoryDeleted(id); + } - if (hasChanged) { - this.label = label; - this.description = description; - RenameCategoryCommand.categoryRenamed(id, label, description); + @Override + public String toString() { + return getLabel(); } - } - - @BusinessMethod - public void remove() { - this.delete = true; - DeleteCategoryCommand.categoryDeleted(id); - } - - @Override - public String toString() { - return getLabel(); - } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/user/Role.java b/domain/src/main/java/com/jongsoft/finance/domain/user/Role.java index ad499f4a..2bbdf4c9 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/user/Role.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/user/Role.java @@ -1,12 +1,13 @@ package com.jongsoft.finance.domain.user; -import java.io.Serializable; import lombok.AllArgsConstructor; import lombok.Getter; +import java.io.Serializable; + @Getter @AllArgsConstructor public class Role implements Serializable { - private String name; + private String name; } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/user/SessionToken.java b/domain/src/main/java/com/jongsoft/finance/domain/user/SessionToken.java index 6c307e81..b5cc988c 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/user/SessionToken.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/user/SessionToken.java @@ -6,33 +6,36 @@ import com.jongsoft.finance.messaging.commands.user.RevokeTokenCommand; import com.jongsoft.lang.Dates; import com.jongsoft.lang.time.Range; -import java.time.LocalDateTime; + import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @Getter public class SessionToken implements AggregateBase { - private final Long id; - private final String token; - private final String description; - private Range validity; - - @Builder - SessionToken(Long id, String token, String description, Range validity) { - this.id = id; - this.token = token; - this.description = description; - this.validity = validity; - } - - @BusinessMethod - public void revoke() { - if (!this.validity.until().isAfter(LocalDateTime.now())) { - throw StatusException.badRequest("Cannot revoke a session token that is already revoked."); + private final Long id; + private final String token; + private final String description; + private Range validity; + + @Builder + SessionToken(Long id, String token, String description, Range validity) { + this.id = id; + this.token = token; + this.description = description; + this.validity = validity; } - this.validity = Dates.range(this.validity.from(), LocalDateTime.now()); - RevokeTokenCommand.tokenRevoked(token); - } + @BusinessMethod + public void revoke() { + if (!this.validity.until().isAfter(LocalDateTime.now())) { + throw StatusException.badRequest( + "Cannot revoke a session token that is already revoked."); + } + + this.validity = Dates.range(this.validity.from(), LocalDateTime.now()); + RevokeTokenCommand.tokenRevoked(token); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/domain/user/UserAccount.java b/domain/src/main/java/com/jongsoft/finance/domain/user/UserAccount.java index 9d1049c5..8e6db96a 100644 --- a/domain/src/main/java/com/jongsoft/finance/domain/user/UserAccount.java +++ b/domain/src/main/java/com/jongsoft/finance/domain/user/UserAccount.java @@ -13,15 +13,17 @@ import com.jongsoft.lang.Collections; import com.jongsoft.lang.collection.Collectors; import com.jongsoft.lang.collection.List; -import java.io.Serializable; -import java.time.LocalDate; -import java.util.Currency; -import java.util.Objects; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.ToString; +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Currency; +import java.util.Objects; + @Getter @Builder @Aggregate @@ -29,185 +31,190 @@ @ToString(of = "username") public class UserAccount implements AggregateBase, Serializable { - private Long id; - private UserIdentifier username; - private String externalUserId; - private String password; - private List roles; - - private String theme; - private Currency primaryCurrency; - private transient String profilePicture; - private String secret; - private boolean twoFactorEnabled; - - public UserAccount(String username, String password) { - this.username = new UserIdentifier(username); - this.password = password; - this.roles = Collections.List(new Role("accountant")); - CreateUserCommand.userCreated(username, password); - } - - public UserAccount(String username, String externalUserId, List roles) { - this.username = new UserIdentifier(username); - this.externalUserId = externalUserId; - this.roles = roles.stream().map(Role::new).collect(Collectors.toList()); - CreateExternalUserCommand.externalUserCreated(username, externalUserId, roles); - } - - /** - * Change the password of the user to the provided new password. - * - * @param password the new password - */ - @BusinessMethod - public void changePassword(String password) { - this.password = password; - ChangePasswordCommand.passwordChanged(username, password); - } - - /** - * Change the currency of the account to the desired one. This is the default currency used - * throughout the application. - * - * @param currency the new default currency - */ - @BusinessMethod - public void changeCurrency(Currency currency) { - if (!Objects.equals(this.primaryCurrency, currency)) { - this.primaryCurrency = currency; - ChangeUserSettingCommand.userSettingChanged( - username, ChangeUserSettingCommand.Type.CURRENCY, primaryCurrency.getCurrencyCode()); + private Long id; + private UserIdentifier username; + private String externalUserId; + private String password; + private List roles; + + private String theme; + private Currency primaryCurrency; + private transient String profilePicture; + private String secret; + private boolean twoFactorEnabled; + + public UserAccount(String username, String password) { + this.username = new UserIdentifier(username); + this.password = password; + this.roles = Collections.List(new Role("accountant")); + CreateUserCommand.userCreated(username, password); } - } - - /** - * Change the theme selected by the user. - * - * @param theme the newly selected theme - */ - @BusinessMethod - public void changeTheme(String theme) { - if (!Objects.equals(this.theme, theme)) { - this.theme = theme; - ChangeUserSettingCommand.userSettingChanged( - username, ChangeUserSettingCommand.Type.THEME, theme); + + public UserAccount(String username, String externalUserId, List roles) { + this.username = new UserIdentifier(username); + this.externalUserId = externalUserId; + this.roles = roles.stream().map(Role::new).collect(Collectors.toList()); + CreateExternalUserCommand.externalUserCreated(username, externalUserId, roles); } - } - - /** Enable multi factor authentication for the current user. */ - @BusinessMethod - public void enableMultiFactorAuthentication() { - if (!twoFactorEnabled) { - this.twoFactorEnabled = true; - ChangeMultiFactorCommand.multiFactorChanged(username, true); + + /** + * Change the password of the user to the provided new password. + * + * @param password the new password + */ + @BusinessMethod + public void changePassword(String password) { + this.password = password; + ChangePasswordCommand.passwordChanged(username, password); } - } - - /** Disable multi factor authentication for the current user. */ - @BusinessMethod - public void disableMultiFactorAuthentication() { - if (twoFactorEnabled) { - this.twoFactorEnabled = false; - ChangeMultiFactorCommand.multiFactorChanged(username, false); + + /** + * Change the currency of the account to the desired one. This is the default currency used + * throughout the application. + * + * @param currency the new default currency + */ + @BusinessMethod + public void changeCurrency(Currency currency) { + if (!Objects.equals(this.primaryCurrency, currency)) { + this.primaryCurrency = currency; + ChangeUserSettingCommand.userSettingChanged( + username, + ChangeUserSettingCommand.Type.CURRENCY, + primaryCurrency.getCurrencyCode()); + } + } + + /** + * Change the theme selected by the user. + * + * @param theme the newly selected theme + */ + @BusinessMethod + public void changeTheme(String theme) { + if (!Objects.equals(this.theme, theme)) { + this.theme = theme; + ChangeUserSettingCommand.userSettingChanged( + username, ChangeUserSettingCommand.Type.THEME, theme); + } } - } - - /** - * Create a new account for the current user. - * - * @param name the name of the account - * @param currency the currency of the account - * @param type the account type - * @return the newly created account linked to this user - */ - @BusinessMethod - public Account createAccount(String name, String currency, String type) { - if (notFullUser()) { - throw StatusException.notAuthorized("User cannot create accounts, incorrect privileges."); + + /** Enable multi factor authentication for the current user. */ + @BusinessMethod + public void enableMultiFactorAuthentication() { + if (!twoFactorEnabled) { + this.twoFactorEnabled = true; + ChangeMultiFactorCommand.multiFactorChanged(username, true); + } } - return new Account(username, name, currency, type); - } - - /** - * Creates a new category registered to the current user. - * - * @param label the label of the category - * @return the newly created category - */ - @BusinessMethod - public Category createCategory(String label) { - if (notFullUser()) { - throw StatusException.notAuthorized("User cannot create categories, incorrect privileges."); + /** Disable multi factor authentication for the current user. */ + @BusinessMethod + public void disableMultiFactorAuthentication() { + if (twoFactorEnabled) { + this.twoFactorEnabled = false; + ChangeMultiFactorCommand.multiFactorChanged(username, false); + } } - return new Category(this, label); - } + /** + * Create a new account for the current user. + * + * @param name the name of the account + * @param currency the currency of the account + * @param type the account type + * @return the newly created account linked to this user + */ + @BusinessMethod + public Account createAccount(String name, String currency, String type) { + if (notFullUser()) { + throw StatusException.notAuthorized( + "User cannot create accounts, incorrect privileges."); + } + + return new Account(username, name, currency, type); + } - @BusinessMethod - public Tag createTag(String label) { - if (notFullUser()) { - throw StatusException.notAuthorized("User cannot create tags, incorrect privileges."); + /** + * Creates a new category registered to the current user. + * + * @param label the label of the category + * @return the newly created category + */ + @BusinessMethod + public Category createCategory(String label) { + if (notFullUser()) { + throw StatusException.notAuthorized( + "User cannot create categories, incorrect privileges."); + } + + return new Category(this, label); } - CreateTagCommand.tagCreated(label); - return new Tag(label); - } - - /** - * Create a new transaction rule for the user. - * - * @param name the name of the rule - * @param restrictive is the rule restrictive - * @return the newly created transaction rule - */ - @BusinessMethod - public TransactionRule createRule(String name, boolean restrictive) { - if (notFullUser()) { - throw StatusException.notAuthorized("User cannot create rules, incorrect privileges."); + @BusinessMethod + public Tag createTag(String label) { + if (notFullUser()) { + throw StatusException.notAuthorized("User cannot create tags, incorrect privileges."); + } + + CreateTagCommand.tagCreated(label); + return new Tag(label); } - return new TransactionRule(this, name, restrictive); - } - - /** - * Create a new import configuration for the current user. - * - * @param name the name of the configuration - * @return the newly created configuration - */ - @BusinessMethod - public BatchImportConfig createImportConfiguration(String type, String name, String fileCode) { - if (notFullUser()) { - throw StatusException.notAuthorized( - "User cannot create import configuration, incorrect privileges."); + /** + * Create a new transaction rule for the user. + * + * @param name the name of the rule + * @param restrictive is the rule restrictive + * @return the newly created transaction rule + */ + @BusinessMethod + public TransactionRule createRule(String name, boolean restrictive) { + if (notFullUser()) { + throw StatusException.notAuthorized("User cannot create rules, incorrect privileges."); + } + + return new TransactionRule(this, name, restrictive); } - return new BatchImportConfig(this, type, name, fileCode); - } - - /** - * Create a new budget for the user. - * - * @param start the start date of the budget - * @param expectedIncome the expected income - * @return the newly created budget - */ - @BusinessMethod - public Budget createBudget(LocalDate start, double expectedIncome) { - if (notFullUser()) { - throw StatusException.notAuthorized("User cannot create budgets, incorrect privileges."); + /** + * Create a new import configuration for the current user. + * + * @param name the name of the configuration + * @return the newly created configuration + */ + @BusinessMethod + public BatchImportConfig createImportConfiguration(String type, String name, String fileCode) { + if (notFullUser()) { + throw StatusException.notAuthorized( + "User cannot create import configuration, incorrect privileges."); + } + + return new BatchImportConfig(this, type, name, fileCode); } - var budget = new Budget(start, expectedIncome); - budget.activate(); - return budget; - } + /** + * Create a new budget for the user. + * + * @param start the start date of the budget + * @param expectedIncome the expected income + * @return the newly created budget + */ + @BusinessMethod + public Budget createBudget(LocalDate start, double expectedIncome) { + if (notFullUser()) { + throw StatusException.notAuthorized( + "User cannot create budgets, incorrect privileges."); + } + + var budget = new Budget(start, expectedIncome); + budget.activate(); + return budget; + } - public boolean notFullUser() { - return roles.stream() - .noneMatch( - r -> Objects.equals(r.getName(), "accountant") || Objects.equals(r.getName(), "admin")); - } + public boolean notFullUser() { + return roles.stream() + .noneMatch(r -> Objects.equals(r.getName(), "accountant") + || Objects.equals(r.getName(), "admin")); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/factory/FilterFactory.java b/domain/src/main/java/com/jongsoft/finance/factory/FilterFactory.java index 6b59e74a..62c4826c 100644 --- a/domain/src/main/java/com/jongsoft/finance/factory/FilterFactory.java +++ b/domain/src/main/java/com/jongsoft/finance/factory/FilterFactory.java @@ -4,19 +4,19 @@ public interface FilterFactory { - AccountProvider.FilterCommand account(); + AccountProvider.FilterCommand account(); - TagProvider.FilterCommand tag(); + TagProvider.FilterCommand tag(); - TransactionProvider.FilterCommand transaction(); + TransactionProvider.FilterCommand transaction(); - ExpenseProvider.FilterCommand expense(); + ExpenseProvider.FilterCommand expense(); - CategoryProvider.FilterCommand category(); + CategoryProvider.FilterCommand category(); - TransactionScheduleProvider.FilterCommand schedule(); + TransactionScheduleProvider.FilterCommand schedule(); - SpendingInsightProvider.FilterCommand insight(); + SpendingInsightProvider.FilterCommand insight(); - SpendingPatternProvider.FilterCommand pattern(); + SpendingPatternProvider.FilterCommand pattern(); } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/ApplicationEvent.java b/domain/src/main/java/com/jongsoft/finance/messaging/ApplicationEvent.java index 8974cd44..9f61b9c6 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/ApplicationEvent.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/ApplicationEvent.java @@ -4,7 +4,7 @@ public interface ApplicationEvent extends Serializable { - default void publish() { - EventBus.getBus().send(this); - } + default void publish() { + EventBus.getBus().send(this); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/CommandHandler.java b/domain/src/main/java/com/jongsoft/finance/messaging/CommandHandler.java index 66b08e71..a1ae259f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/CommandHandler.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/CommandHandler.java @@ -8,10 +8,10 @@ */ public interface CommandHandler { - /** - * Process the command to apply the changes indicated in the command. - * - * @param command the actual command - */ - void handle(T command); + /** + * Process the command to apply the changes indicated in the command. + * + * @param command the actual command + */ + void handle(T command); } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/EventBus.java b/domain/src/main/java/com/jongsoft/finance/messaging/EventBus.java index 1ac75214..27eedec3 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/EventBus.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/EventBus.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.messaging; import io.micronaut.context.event.ApplicationEventPublisher; + import java.io.Serializable; import java.util.EventObject; @@ -9,29 +10,29 @@ * system of the application. */ public class EventBus { - private static EventBus INSTANCE; - - private final ApplicationEventPublisher eventPublisher; - - public EventBus(ApplicationEventPublisher eventPublisher) { - INSTANCE = this; - this.eventPublisher = eventPublisher; - } - - /** - * Publish an event to the message bus. - * - * @param event the event to be published - */ - public void send(ApplicationEvent event) { - eventPublisher.publishEvent(event); - } - - public void sendSystemEvent(EventObject event) { - eventPublisher.publishEvent(event); - } - - public static EventBus getBus() { - return INSTANCE; - } + private static EventBus INSTANCE; + + private final ApplicationEventPublisher eventPublisher; + + public EventBus(ApplicationEventPublisher eventPublisher) { + INSTANCE = this; + this.eventPublisher = eventPublisher; + } + + /** + * Publish an event to the message bus. + * + * @param event the event to be published + */ + public void send(ApplicationEvent event) { + eventPublisher.publishEvent(event); + } + + public void sendSystemEvent(EventObject event) { + eventPublisher.publishEvent(event); + } + + public static EventBus getBus() { + return INSTANCE; + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/InternalAuthenticationEvent.java b/domain/src/main/java/com/jongsoft/finance/messaging/InternalAuthenticationEvent.java index 6ec9ed81..545c9497 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/InternalAuthenticationEvent.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/InternalAuthenticationEvent.java @@ -4,21 +4,21 @@ public class InternalAuthenticationEvent extends ApplicationEvent { - private final String username; + private final String username; - /** - * Constructs a prototypical Event. - * - * @param source The object on which the Event initially occurred. - * @param username - * @throws IllegalArgumentException if source is null. - */ - public InternalAuthenticationEvent(final Object source, final String username) { - super(source); - this.username = username; - } + /** + * Constructs a prototypical Event. + * + * @param source The object on which the Event initially occurred. + * @param username + * @throws IllegalArgumentException if source is null. + */ + public InternalAuthenticationEvent(final Object source, final String username) { + super(source); + this.username = username; + } - public String getUsername() { - return username; - } + public String getUsername() { + return username; + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/StartProcessCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/StartProcessCommand.java new file mode 100644 index 00000000..8dfd024c --- /dev/null +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/StartProcessCommand.java @@ -0,0 +1,13 @@ +package com.jongsoft.finance.messaging.commands; + +import com.jongsoft.finance.messaging.ApplicationEvent; + +import java.util.Map; + +public record StartProcessCommand(String processDefinition, Map parameters) + implements ApplicationEvent { + + public static void startProcess(String processDefinition, Map parameters) { + new StartProcessCommand(processDefinition, parameters).publish(); + } +} diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeAccountCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeAccountCommand.java index 1bb545dc..e6dbe92f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeAccountCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeAccountCommand.java @@ -3,17 +3,17 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record ChangeAccountCommand(long id, String iban, String bic, String number) - implements ApplicationEvent { + implements ApplicationEvent { - /** - * Method to trigger a change in the account details associated with a given ID. - * - * @param id the ID of the account - * @param iban the new IBAN of the account - * @param bic the new BIC of the account - * @param number the new account number - */ - public static void accountChanged(long id, String iban, String bic, String number) { - new ChangeAccountCommand(id, iban, bic, number).publish(); - } + /** + * Method to trigger a change in the account details associated with a given ID. + * + * @param id the ID of the account + * @param iban the new IBAN of the account + * @param bic the new BIC of the account + * @param number the new account number + */ + public static void accountChanged(long id, String iban, String bic, String number) { + new ChangeAccountCommand(id, iban, bic, number).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeInterestCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeInterestCommand.java index d3765b60..30c60bc4 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeInterestCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/ChangeInterestCommand.java @@ -4,16 +4,16 @@ import com.jongsoft.finance.schedule.Periodicity; public record ChangeInterestCommand(long id, double interest, Periodicity periodicity) - implements ApplicationEvent { + implements ApplicationEvent { - /** - * Notifies the system that the interest for a specific entity has changed. - * - * @param id the identifier of the entity - * @param interest the new interest value - * @param periodicity the periodicity of the interest - */ - public static void interestChanged(long id, double interest, Periodicity periodicity) { - new ChangeInterestCommand(id, interest, periodicity).publish(); - } + /** + * Notifies the system that the interest for a specific entity has changed. + * + * @param id the identifier of the entity + * @param interest the new interest value + * @param periodicity the periodicity of the interest + */ + public static void interestChanged(long id, double interest, Periodicity periodicity) { + new ChangeInterestCommand(id, interest, periodicity).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/CreateAccountCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/CreateAccountCommand.java index b6ed19fb..a995b07f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/CreateAccountCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/CreateAccountCommand.java @@ -3,16 +3,16 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record CreateAccountCommand(String name, String currency, String type) - implements ApplicationEvent { + implements ApplicationEvent { - /** - * Publishes an account creation event with the specified details. - * - * @param name the name of the account owner - * @param currency the currency associated with the account - * @param type the type of the account - */ - public static void accountCreated(String name, String currency, String type) { - new CreateAccountCommand(name, currency, type).publish(); - } + /** + * Publishes an account creation event with the specified details. + * + * @param name the name of the account owner + * @param currency the currency associated with the account + * @param type the type of the account + */ + public static void accountCreated(String name, String currency, String type) { + new CreateAccountCommand(name, currency, type).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterAccountIconCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterAccountIconCommand.java index e6b05510..ebdf9a71 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterAccountIconCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterAccountIconCommand.java @@ -4,16 +4,16 @@ import com.jongsoft.finance.messaging.commands.storage.ReplaceFileCommand; public record RegisterAccountIconCommand(long id, String fileCode, String oldFileCode) - implements ReplaceFileCommand, ApplicationEvent { + implements ReplaceFileCommand, ApplicationEvent { - /** - * Notify that an icon has been changed for a specific item identified by its ID. - * - * @param id the ID of the item for which the icon has changed - * @param fileCode the new file code representing the updated icon - * @param oldFileCode the previous file code representing the old icon - */ - public static void iconChanged(long id, String fileCode, String oldFileCode) { - new RegisterAccountIconCommand(id, fileCode, oldFileCode).publish(); - } + /** + * Notify that an icon has been changed for a specific item identified by its ID. + * + * @param id the ID of the item for which the icon has changed + * @param fileCode the new file code representing the updated icon + * @param oldFileCode the previous file code representing the old icon + */ + public static void iconChanged(long id, String fileCode, String oldFileCode) { + new RegisterAccountIconCommand(id, fileCode, oldFileCode).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterSynonymCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterSynonymCommand.java index 87b2ffbd..3f2501f8 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterSynonymCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RegisterSynonymCommand.java @@ -4,7 +4,7 @@ public record RegisterSynonymCommand(long accountId, String synonym) implements ApplicationEvent { - public static void synonymRegistered(long accountId, String synonym) { - new RegisterSynonymCommand(accountId, synonym).publish(); - } + public static void synonymRegistered(long accountId, String synonym) { + new RegisterSynonymCommand(accountId, synonym).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RenameAccountCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RenameAccountCommand.java index 5127c6dc..75abf640 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RenameAccountCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/RenameAccountCommand.java @@ -3,21 +3,21 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record RenameAccountCommand( - long id, String type, String name, String description, String currency) - implements ApplicationEvent { + long id, String type, String name, String description, String currency) + implements ApplicationEvent { - /** - * Renames an account by creating and publishing a RenameAccountCommand with the specified - * details. - * - * @param id the unique identifier of the account - * @param type the type of the account - * @param name the new name for the account - * @param description the description of the account - * @param currency the currency used by the account - */ - public static void accountRenamed( - long id, String type, String name, String description, String currency) { - new RenameAccountCommand(id, type, name, description, currency).publish(); - } + /** + * Renames an account by creating and publishing a RenameAccountCommand with the specified + * details. + * + * @param id the unique identifier of the account + * @param type the type of the account + * @param name the new name for the account + * @param description the description of the account + * @param currency the currency used by the account + */ + public static void accountRenamed( + long id, String type, String name, String description, String currency) { + new RenameAccountCommand(id, type, name, description, currency).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/TerminateAccountCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/TerminateAccountCommand.java index 977e9744..6145b528 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/TerminateAccountCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/account/TerminateAccountCommand.java @@ -4,12 +4,12 @@ public record TerminateAccountCommand(long id) implements ApplicationEvent { - /** - * Terminates the account identified by the specified ID. - * - * @param id the identifier of the account to be terminated - */ - public static void accountTerminated(long id) { - new TerminateAccountCommand(id).publish(); - } + /** + * Terminates the account identified by the specified ID. + * + * @param id the identifier of the account to be terminated + */ + public static void accountTerminated(long id) { + new TerminateAccountCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CloseBudgetCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CloseBudgetCommand.java index 2bd7c8d0..f8437cab 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CloseBudgetCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CloseBudgetCommand.java @@ -1,11 +1,12 @@ package com.jongsoft.finance.messaging.commands.budget; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; public record CloseBudgetCommand(long id, LocalDate end) implements ApplicationEvent { - public static void budgetClosed(long id, LocalDate end) { - new CloseBudgetCommand(id, end).publish(); - } + public static void budgetClosed(long id, LocalDate end) { + new CloseBudgetCommand(id, end).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateBudgetCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateBudgetCommand.java index c9a9b9cb..5005e411 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateBudgetCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateBudgetCommand.java @@ -5,7 +5,7 @@ public record CreateBudgetCommand(Budget budget) implements ApplicationEvent { - public static void budgetCreated(Budget budget) { - new CreateBudgetCommand(budget).publish(); - } + public static void budgetCreated(Budget budget) { + new CreateBudgetCommand(budget).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateExpenseCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateExpenseCommand.java index 7020da70..c4868dc7 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateExpenseCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/CreateExpenseCommand.java @@ -1,13 +1,14 @@ package com.jongsoft.finance.messaging.commands.budget; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.math.BigDecimal; import java.time.LocalDate; public record CreateExpenseCommand(String name, LocalDate start, BigDecimal budget) - implements ApplicationEvent { + implements ApplicationEvent { - public static void expenseCreated(String name, LocalDate start, BigDecimal budget) { - new CreateExpenseCommand(name, start, budget).publish(); - } + public static void expenseCreated(String name, LocalDate start, BigDecimal budget) { + new CreateExpenseCommand(name, start, budget).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/UpdateExpenseCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/UpdateExpenseCommand.java index f3f2ed59..f46d11a5 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/UpdateExpenseCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/budget/UpdateExpenseCommand.java @@ -1,11 +1,12 @@ package com.jongsoft.finance.messaging.commands.budget; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.math.BigDecimal; public record UpdateExpenseCommand(long id, BigDecimal amount) implements ApplicationEvent { - public static void expenseUpdated(long id, BigDecimal amount) { - new UpdateExpenseCommand(id, amount).publish(); - } + public static void expenseUpdated(long id, BigDecimal amount) { + new UpdateExpenseCommand(id, amount).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/CreateCategoryCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/CreateCategoryCommand.java index 5ba57b02..6ff99dd7 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/CreateCategoryCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/CreateCategoryCommand.java @@ -4,7 +4,7 @@ public record CreateCategoryCommand(String name, String description) implements ApplicationEvent { - public static void categoryCreated(String name, String description) { - new CreateCategoryCommand(name, description).publish(); - } + public static void categoryCreated(String name, String description) { + new CreateCategoryCommand(name, description).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/DeleteCategoryCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/DeleteCategoryCommand.java index 90051a64..d2e81f27 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/DeleteCategoryCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/DeleteCategoryCommand.java @@ -4,7 +4,7 @@ public record DeleteCategoryCommand(long id) implements ApplicationEvent { - public static void categoryDeleted(long id) { - new DeleteCategoryCommand(id).publish(); - } + public static void categoryDeleted(long id) { + new DeleteCategoryCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/RenameCategoryCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/RenameCategoryCommand.java index 31d282e1..9cb8c4db 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/RenameCategoryCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/category/RenameCategoryCommand.java @@ -3,9 +3,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record RenameCategoryCommand(long id, String name, String description) - implements ApplicationEvent { + implements ApplicationEvent { - public static void categoryRenamed(long id, String name, String description) { - new RenameCategoryCommand(id, name, description).publish(); - } + public static void categoryRenamed(long id, String name, String description) { + new RenameCategoryCommand(id, name, description).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/AttachFileToContractCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/AttachFileToContractCommand.java index 51fcce3e..5f4c5b2c 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/AttachFileToContractCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/AttachFileToContractCommand.java @@ -4,13 +4,13 @@ public record AttachFileToContractCommand(long id, String fileCode) implements ApplicationEvent { - /** - * Attaches a file to a contract. - * - * @param id the identifier of the contract to attach the file to - * @param fileCode the code of the file to attach - */ - public static void attachFileToContract(long id, String fileCode) { - new AttachFileToContractCommand(id, fileCode).publish(); - } + /** + * Attaches a file to a contract. + * + * @param id the identifier of the contract to attach the file to + * @param fileCode the code of the file to attach + */ + public static void attachFileToContract(long id, String fileCode) { + new AttachFileToContractCommand(id, fileCode).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/ChangeContractCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/ChangeContractCommand.java index 2ef74ce7..990d51e7 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/ChangeContractCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/ChangeContractCommand.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.messaging.commands.contract; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; /** @@ -10,20 +11,20 @@ * ChangeContractCommand and publish it. */ public record ChangeContractCommand( - long id, String name, String description, LocalDate start, LocalDate end) - implements ApplicationEvent { + long id, String name, String description, LocalDate start, LocalDate end) + implements ApplicationEvent { - /** - * Creates and publishes a change contract command with the given parameters. - * - * @param id The unique identifier of the contract. - * @param name The new name of the contract. - * @param description The new description of the contract. - * @param start The new start date of the contract. - * @param end The new end date of the contract. - */ - public static void contractChanged( - long id, String name, String description, LocalDate start, LocalDate end) { - new ChangeContractCommand(id, name, description, start, end).publish(); - } + /** + * Creates and publishes a change contract command with the given parameters. + * + * @param id The unique identifier of the contract. + * @param name The new name of the contract. + * @param description The new description of the contract. + * @param start The new start date of the contract. + * @param end The new end date of the contract. + */ + public static void contractChanged( + long id, String name, String description, LocalDate start, LocalDate end) { + new ChangeContractCommand(id, name, description, start, end).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/CreateContractCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/CreateContractCommand.java index 65e01234..42db0aa8 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/CreateContractCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/CreateContractCommand.java @@ -1,24 +1,25 @@ package com.jongsoft.finance.messaging.commands.contract; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; public record CreateContractCommand( - long companyId, String name, String description, LocalDate start, LocalDate end) - implements ApplicationEvent { + long companyId, String name, String description, LocalDate start, LocalDate end) + implements ApplicationEvent { - /** - * Creates a new contract with the provided details and publishes it to the event bus for - * further processing. - * - * @param companyId the identifier of the company for which the contract is being created - * @param name the name of the contract - * @param description a description of the contract - * @param start the start date of the contract - * @param end the end date of the contract - */ - public static void contractCreated( - long companyId, String name, String description, LocalDate start, LocalDate end) { - new CreateContractCommand(companyId, name, description, start, end).publish(); - } + /** + * Creates a new contract with the provided details and publishes it to the event bus for + * further processing. + * + * @param companyId the identifier of the company for which the contract is being created + * @param name the name of the contract + * @param description a description of the contract + * @param start the start date of the contract + * @param end the end date of the contract + */ + public static void contractCreated( + long companyId, String name, String description, LocalDate start, LocalDate end) { + new CreateContractCommand(companyId, name, description, start, end).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/TerminateContractCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/TerminateContractCommand.java index 9e5c2395..abb89808 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/TerminateContractCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/TerminateContractCommand.java @@ -4,13 +4,13 @@ public record TerminateContractCommand(long id) implements ApplicationEvent { - /** - * Terminates a contract identified by the given ID by creating and publishing a - * TerminateContractCommand event. - * - * @param id the ID of the contract to be terminated - */ - public static void contractTerminated(long id) { - new TerminateContractCommand(id).publish(); - } + /** + * Terminates a contract identified by the given ID by creating and publishing a + * TerminateContractCommand event. + * + * @param id the ID of the contract to be terminated + */ + public static void contractTerminated(long id) { + new TerminateContractCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/WarnBeforeExpiryCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/WarnBeforeExpiryCommand.java index 8758a65e..00800417 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/WarnBeforeExpiryCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/contract/WarnBeforeExpiryCommand.java @@ -1,18 +1,19 @@ package com.jongsoft.finance.messaging.commands.contract; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; public record WarnBeforeExpiryCommand(long id, LocalDate endDate) implements ApplicationEvent { - /** - * Publishes a warning before the expiry date of a certain item identified by the given ID. The - * warning will be sent as an event through the application's event bus. - * - * @param id the unique identifier of the item for which the warning is being sent - * @param endDate the expiry date of the item to warn about - */ - public static void warnBeforeExpiry(long id, LocalDate endDate) { - new WarnBeforeExpiryCommand(id, endDate).publish(); - } + /** + * Publishes a warning before the expiry date of a certain item identified by the given ID. The + * warning will be sent as an event through the application's event bus. + * + * @param id the unique identifier of the item for which the warning is being sent + * @param endDate the expiry date of the item to warn about + */ + public static void warnBeforeExpiry(long id, LocalDate endDate) { + new WarnBeforeExpiryCommand(id, endDate).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/ChangeCurrencyPropertyCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/ChangeCurrencyPropertyCommand.java index e99e9f69..97f2b886 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/ChangeCurrencyPropertyCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/ChangeCurrencyPropertyCommand.java @@ -1,13 +1,14 @@ package com.jongsoft.finance.messaging.commands.currency; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.io.Serializable; public record ChangeCurrencyPropertyCommand( - String code, T value, CurrencyCommandType type) implements ApplicationEvent { + String code, T value, CurrencyCommandType type) implements ApplicationEvent { - public static void currencyPropertyChanged( - String code, T value, CurrencyCommandType type) { - new ChangeCurrencyPropertyCommand<>(code, value, type).publish(); - } + public static void currencyPropertyChanged( + String code, T value, CurrencyCommandType type) { + new ChangeCurrencyPropertyCommand<>(code, value, type).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CreateCurrencyCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CreateCurrencyCommand.java index f2aa86e0..c8b90983 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CreateCurrencyCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CreateCurrencyCommand.java @@ -3,9 +3,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record CreateCurrencyCommand(String name, char symbol, String isoCode) - implements ApplicationEvent { + implements ApplicationEvent { - public static void currencyCreated(String name, char symbol, String isoCode) { - new CreateCurrencyCommand(name, symbol, isoCode).publish(); - } + public static void currencyCreated(String name, char symbol, String isoCode) { + new CreateCurrencyCommand(name, symbol, isoCode).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CurrencyCommandType.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CurrencyCommandType.java index 5faac0f0..27993f24 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CurrencyCommandType.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/CurrencyCommandType.java @@ -1,6 +1,6 @@ package com.jongsoft.finance.messaging.commands.currency; public enum CurrencyCommandType { - ENABLED, - DECIMAL_PLACES + ENABLED, + DECIMAL_PLACES } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/RenameCurrencyCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/RenameCurrencyCommand.java index 5c7fc4be..9a1010a2 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/RenameCurrencyCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/currency/RenameCurrencyCommand.java @@ -3,9 +3,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record RenameCurrencyCommand(long id, String name, char symbol, String isoCode) - implements ApplicationEvent { + implements ApplicationEvent { - public static void currencyRenamed(long id, String name, char symbol, String isoCode) { - new RenameCurrencyCommand(id, name, symbol, isoCode).publish(); - } + public static void currencyRenamed(long id, String name, char symbol, String isoCode) { + new RenameCurrencyCommand(id, name, symbol, isoCode).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CompleteImportJobCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CompleteImportJobCommand.java index 36a73ad3..e8c62157 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CompleteImportJobCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CompleteImportJobCommand.java @@ -4,7 +4,7 @@ public record CompleteImportJobCommand(long id) implements ApplicationEvent { - public static void importJobCompleted(long id) { - new CompleteImportJobCommand(id).publish(); - } + public static void importJobCompleted(long id) { + new CompleteImportJobCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateConfigurationCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateConfigurationCommand.java index 6823e8ff..440d314d 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateConfigurationCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateConfigurationCommand.java @@ -3,9 +3,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record CreateConfigurationCommand(String type, String name, String fileCode) - implements ApplicationEvent { + implements ApplicationEvent { - public static void configurationCreated(String type, String name, String fileCode) { - new CreateConfigurationCommand(type, name, fileCode).publish(); - } + public static void configurationCreated(String type, String name, String fileCode) { + new CreateConfigurationCommand(type, name, fileCode).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateImportJobCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateImportJobCommand.java index 66a431d7..b1836904 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateImportJobCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/CreateImportJobCommand.java @@ -3,9 +3,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record CreateImportJobCommand(long configId, String slug, String fileCode) - implements ApplicationEvent { + implements ApplicationEvent { - public static void importJobCreated(long configId, String slug, String fileCode) { - new CreateImportJobCommand(configId, slug, fileCode).publish(); - } + public static void importJobCreated(long configId, String slug, String fileCode) { + new CreateImportJobCommand(configId, slug, fileCode).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/DeleteImportJobCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/DeleteImportJobCommand.java index 7013eb4a..3a4ed9d1 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/DeleteImportJobCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/importer/DeleteImportJobCommand.java @@ -4,7 +4,7 @@ public record DeleteImportJobCommand(long id) implements ApplicationEvent { - public static void importJobDeleted(long id) { - new DeleteImportJobCommand(id).publish(); - } + public static void importJobDeleted(long id) { + new DeleteImportJobCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CleanInsightsForMonth.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CleanInsightsForMonth.java index 041ad871..5ecd9964 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CleanInsightsForMonth.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CleanInsightsForMonth.java @@ -1,11 +1,12 @@ package com.jongsoft.finance.messaging.commands.insight; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.YearMonth; public record CleanInsightsForMonth(YearMonth month) implements ApplicationEvent { - public static void cleanInsightsForMonth(YearMonth month) { - new CleanInsightsForMonth(month).publish(); - } + public static void cleanInsightsForMonth(YearMonth month) { + new CleanInsightsForMonth(month).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CompleteAnalyzeJob.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CompleteAnalyzeJob.java index 1e878c5f..c20548ec 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CompleteAnalyzeJob.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CompleteAnalyzeJob.java @@ -2,11 +2,12 @@ import com.jongsoft.finance.domain.user.UserIdentifier; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.YearMonth; public record CompleteAnalyzeJob(UserIdentifier user, YearMonth month) implements ApplicationEvent { - public static void completeAnalyzeJob(UserIdentifier user, YearMonth month) { - new CompleteAnalyzeJob(user, month).publish(); - } + public static void completeAnalyzeJob(UserIdentifier user, YearMonth month) { + new CompleteAnalyzeJob(user, month).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateAnalyzeJob.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateAnalyzeJob.java index 13f31eab..cb9df551 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateAnalyzeJob.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateAnalyzeJob.java @@ -2,11 +2,12 @@ import com.jongsoft.finance.domain.user.UserIdentifier; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.YearMonth; public record CreateAnalyzeJob(UserIdentifier user, YearMonth month) implements ApplicationEvent { - public static void createAnalyzeJob(UserIdentifier user, YearMonth month) { - new CreateAnalyzeJob(user, month).publish(); - } + public static void createAnalyzeJob(UserIdentifier user, YearMonth month) { + new CreateAnalyzeJob(user, month).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingInsight.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingInsight.java index 4bcd5377..89daba70 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingInsight.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingInsight.java @@ -4,30 +4,31 @@ import com.jongsoft.finance.domain.insight.Severity; import com.jongsoft.finance.domain.insight.SpendingInsight; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; import java.util.Map; public record CreateSpendingInsight( - InsightType type, - String category, - Severity severity, - double score, - LocalDate detectedDate, - String message, - Long transactionId, - Map metadata) - implements ApplicationEvent { + InsightType type, + String category, + Severity severity, + double score, + LocalDate detectedDate, + String message, + Long transactionId, + Map metadata) + implements ApplicationEvent { - public static void createSpendingInsight(SpendingInsight spendingInsight) { - new CreateSpendingInsight( - spendingInsight.getType(), - spendingInsight.getCategory(), - spendingInsight.getSeverity(), - spendingInsight.getScore(), - spendingInsight.getDetectedDate(), - spendingInsight.getMessage(), - spendingInsight.getTransactionId(), - spendingInsight.getMetadata()) - .publish(); - } + public static void createSpendingInsight(SpendingInsight spendingInsight) { + new CreateSpendingInsight( + spendingInsight.getType(), + spendingInsight.getCategory(), + spendingInsight.getSeverity(), + spendingInsight.getScore(), + spendingInsight.getDetectedDate(), + spendingInsight.getMessage(), + spendingInsight.getTransactionId(), + spendingInsight.getMetadata()) + .publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingPattern.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingPattern.java index 2999f13e..d06ff880 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingPattern.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/CreateSpendingPattern.java @@ -3,24 +3,25 @@ import com.jongsoft.finance.domain.insight.PatternType; import com.jongsoft.finance.domain.insight.SpendingPattern; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; import java.util.Map; public record CreateSpendingPattern( - PatternType type, - String category, - double confidence, - LocalDate detectedDate, - Map metadata) - implements ApplicationEvent { + PatternType type, + String category, + double confidence, + LocalDate detectedDate, + Map metadata) + implements ApplicationEvent { - public static void createSpendingPattern(SpendingPattern spendingPattern) { - new CreateSpendingPattern( - spendingPattern.getType(), - spendingPattern.getCategory(), - spendingPattern.getConfidence(), - spendingPattern.getDetectedDate(), - spendingPattern.getMetadata()) - .publish(); - } + public static void createSpendingPattern(SpendingPattern spendingPattern) { + new CreateSpendingPattern( + spendingPattern.getType(), + spendingPattern.getCategory(), + spendingPattern.getConfidence(), + spendingPattern.getDetectedDate(), + spendingPattern.getMetadata()) + .publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/FailAnalyzeJob.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/FailAnalyzeJob.java index 6fb1dcd6..7021e983 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/FailAnalyzeJob.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/insight/FailAnalyzeJob.java @@ -2,11 +2,12 @@ import com.jongsoft.finance.domain.user.UserIdentifier; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.YearMonth; public record FailAnalyzeJob(UserIdentifier user, YearMonth month) implements ApplicationEvent { - public static void failAnalyzeJob(UserIdentifier user, YearMonth month) { - new FailAnalyzeJob(user, month).publish(); - } + public static void failAnalyzeJob(UserIdentifier user, YearMonth month) { + new FailAnalyzeJob(user, month).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeConditionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeConditionCommand.java index a0384323..26197258 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeConditionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeConditionCommand.java @@ -5,11 +5,11 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record ChangeConditionCommand( - long id, RuleColumn field, RuleOperation operation, String condition) - implements ApplicationEvent { + long id, RuleColumn field, RuleOperation operation, String condition) + implements ApplicationEvent { - public static void changeConditionUpdated( - long id, RuleColumn field, RuleOperation operation, String condition) { - new ChangeConditionCommand(id, field, operation, condition).publish(); - } + public static void changeConditionUpdated( + long id, RuleColumn field, RuleOperation operation, String condition) { + new ChangeConditionCommand(id, field, operation, condition).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeRuleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeRuleCommand.java index c15c7fbd..7cd85cf8 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeRuleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ChangeRuleCommand.java @@ -4,9 +4,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record ChangeRuleCommand(long id, RuleColumn column, String change) - implements ApplicationEvent { + implements ApplicationEvent { - public static void changeRuleUpdated(long id, RuleColumn column, String change) { - new ChangeRuleCommand(id, column, change).publish(); - } + public static void changeRuleUpdated(long id, RuleColumn column, String change) { + new ChangeRuleCommand(id, column, change).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/CreateRuleGroupCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/CreateRuleGroupCommand.java index 3ae2bf29..124598c0 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/CreateRuleGroupCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/CreateRuleGroupCommand.java @@ -2,4 +2,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; -public record CreateRuleGroupCommand(String name) implements ApplicationEvent {} +public record CreateRuleGroupCommand(String name) implements ApplicationEvent { + + public static void ruleGroupCreated(String name) { + new CreateRuleGroupCommand(name).publish(); + } +} diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RenameRuleGroupCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RenameRuleGroupCommand.java index ad43645f..c1e5f254 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RenameRuleGroupCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RenameRuleGroupCommand.java @@ -4,7 +4,7 @@ public record RenameRuleGroupCommand(long id, String name) implements ApplicationEvent { - public static void ruleGroupRenamed(long id, String name) { - new RenameRuleGroupCommand(id, name).publish(); - } + public static void ruleGroupRenamed(long id, String name) { + new RenameRuleGroupCommand(id, name).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleCommand.java index b3061db1..55a47dde 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleCommand.java @@ -4,7 +4,7 @@ public record ReorderRuleCommand(long id, int sort) implements ApplicationEvent { - public static void reorderRuleUpdated(long id, int sort) { - new ReorderRuleCommand(id, sort).publish(); - } + public static void reorderRuleUpdated(long id, int sort) { + new ReorderRuleCommand(id, sort).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleGroupCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleGroupCommand.java index 66725ea7..2f2ff652 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleGroupCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/ReorderRuleGroupCommand.java @@ -4,7 +4,7 @@ public record ReorderRuleGroupCommand(long id, int sort) implements ApplicationEvent { - public static void reorderRuleGroupUpdated(long id, int sort) { - new ReorderRuleGroupCommand(id, sort).publish(); - } + public static void reorderRuleGroupUpdated(long id, int sort) { + new ReorderRuleGroupCommand(id, sort).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleGroupDeleteCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleGroupDeleteCommand.java index e3712b82..38d6d313 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleGroupDeleteCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleGroupDeleteCommand.java @@ -4,7 +4,7 @@ public record RuleGroupDeleteCommand(long id) implements ApplicationEvent { - public static void ruleGroupDeleted(long id) { - new RuleGroupDeleteCommand(id).publish(); - } + public static void ruleGroupDeleted(long id) { + new RuleGroupDeleteCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleRemovedCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleRemovedCommand.java new file mode 100644 index 00000000..76402ac8 --- /dev/null +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/rule/RuleRemovedCommand.java @@ -0,0 +1,10 @@ +package com.jongsoft.finance.messaging.commands.rule; + +import com.jongsoft.finance.messaging.ApplicationEvent; + +public record RuleRemovedCommand(long ruleId) implements ApplicationEvent { + + public static void ruleRemoved(long ruleId) { + new RuleRemovedCommand(ruleId).publish(); + } +} diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustSavingGoalCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustSavingGoalCommand.java index f8448579..e9e55ae9 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustSavingGoalCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustSavingGoalCommand.java @@ -1,14 +1,15 @@ package com.jongsoft.finance.messaging.commands.savings; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.math.BigDecimal; import java.time.LocalDate; /** Command for adjusting the saving goal. */ public record AdjustSavingGoalCommand(long id, BigDecimal goal, LocalDate targetDate) - implements ApplicationEvent { + implements ApplicationEvent { - public static void savingGoalAdjusted(long id, BigDecimal goal, LocalDate targetDate) { - new AdjustSavingGoalCommand(id, goal, targetDate).publish(); - } + public static void savingGoalAdjusted(long id, BigDecimal goal, LocalDate targetDate) { + new AdjustSavingGoalCommand(id, goal, targetDate).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustScheduleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustScheduleCommand.java index b2914c99..90561395 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustScheduleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/AdjustScheduleCommand.java @@ -2,22 +2,23 @@ import com.jongsoft.finance.messaging.commands.schedule.ScheduleCommand; import com.jongsoft.finance.schedule.Schedulable; + import java.util.Map; /** Change the schedule attached to a saving goal. */ public record AdjustScheduleCommand(long id, Schedulable schedulable) implements ScheduleCommand { - @Override - public String processDefinition() { - return "ScheduledSavingGoal"; - } + @Override + public String processDefinition() { + return "ScheduledSavingGoal"; + } - @Override - public Map variables() { - return Map.of("id", id); - } + @Override + public Map variables() { + return Map.of("id", id); + } - public static void scheduleAdjusted(long id, Schedulable schedulable) { - new AdjustScheduleCommand(id, schedulable).publish(); - } + public static void scheduleAdjusted(long id, Schedulable schedulable) { + new AdjustScheduleCommand(id, schedulable).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CompleteSavingGoalCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CompleteSavingGoalCommand.java index e3630ae1..2f8e3858 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CompleteSavingGoalCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CompleteSavingGoalCommand.java @@ -5,7 +5,7 @@ /** Command to complete the savings goal and close it in the system. */ public record CompleteSavingGoalCommand(long id) implements ApplicationEvent { - public static void savingGoalCompleted(long id) { - new CompleteSavingGoalCommand(id).publish(); - } + public static void savingGoalCompleted(long id) { + new CompleteSavingGoalCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CreateSavingGoalCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CreateSavingGoalCommand.java index 4b297670..490d8f67 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CreateSavingGoalCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/CreateSavingGoalCommand.java @@ -1,16 +1,17 @@ package com.jongsoft.finance.messaging.commands.savings; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.math.BigDecimal; import java.time.LocalDate; /** A command to create a new saving goal in the system for the authenticated user. */ public record CreateSavingGoalCommand( - long accountId, String name, BigDecimal goal, LocalDate targetDate) - implements ApplicationEvent { + long accountId, String name, BigDecimal goal, LocalDate targetDate) + implements ApplicationEvent { - public static void savingGoalCreated( - long accountId, String name, BigDecimal goal, LocalDate targetDate) { - new CreateSavingGoalCommand(accountId, name, goal, targetDate).publish(); - } + public static void savingGoalCreated( + long accountId, String name, BigDecimal goal, LocalDate targetDate) { + new CreateSavingGoalCommand(accountId, name, goal, targetDate).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/RegisterSavingInstallmentCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/RegisterSavingInstallmentCommand.java index 05fbc466..847e2630 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/RegisterSavingInstallmentCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/savings/RegisterSavingInstallmentCommand.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.messaging.commands.savings; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.math.BigDecimal; /** @@ -8,9 +9,9 @@ * would increase the allocated amount of money for the saving goal. */ public record RegisterSavingInstallmentCommand(long id, BigDecimal amount) - implements ApplicationEvent { + implements ApplicationEvent { - public static void savingInstallmentRegistered(long id, BigDecimal amount) { - new RegisterSavingInstallmentCommand(id, amount).publish(); - } + public static void savingInstallmentRegistered(long id, BigDecimal amount) { + new RegisterSavingInstallmentCommand(id, amount).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleCommand.java index 210f43be..3153a6de 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleCommand.java @@ -5,11 +5,11 @@ import com.jongsoft.finance.schedule.Schedule; public record CreateScheduleCommand( - String name, Schedule schedule, Account from, Account destination, double amount) - implements ApplicationEvent { + String name, Schedule schedule, Account from, Account destination, double amount) + implements ApplicationEvent { - public static void scheduleCreated( - String name, Schedule schedule, Account from, Account destination, double amount) { - new CreateScheduleCommand(name, schedule, from, destination, amount).publish(); - } + public static void scheduleCreated( + String name, Schedule schedule, Account from, Account destination, double amount) { + new CreateScheduleCommand(name, schedule, from, destination, amount).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleForContractCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleForContractCommand.java index 08636312..a8db1042 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleForContractCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/CreateScheduleForContractCommand.java @@ -6,20 +6,20 @@ import com.jongsoft.finance.schedule.Schedule; public record CreateScheduleForContractCommand( - String name, Schedule schedule, Contract contract, Account source, double amount) - implements ApplicationEvent { + String name, Schedule schedule, Contract contract, Account source, double amount) + implements ApplicationEvent { - /** - * Creates and publishes a new schedule for a contract using the provided information. - * - * @param name The name of the schedule. - * @param schedule The schedule to be created. - * @param contract The contract for which the schedule is created. - * @param source The account to use as the source for the schedule. - * @param amount The amount to use for the schedule. - */ - public static void scheduleCreated( - String name, Schedule schedule, Contract contract, Account source, double amount) { - new CreateScheduleForContractCommand(name, schedule, contract, source, amount).publish(); - } + /** + * Creates and publishes a new schedule for a contract using the provided information. + * + * @param name The name of the schedule. + * @param schedule The schedule to be created. + * @param contract The contract for which the schedule is created. + * @param source The account to use as the source for the schedule. + * @param amount The amount to use for the schedule. + */ + public static void scheduleCreated( + String name, Schedule schedule, Contract contract, Account source, double amount) { + new CreateScheduleForContractCommand(name, schedule, contract, source, amount).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/DescribeScheduleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/DescribeScheduleCommand.java index 6da444ed..b3c6c825 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/DescribeScheduleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/DescribeScheduleCommand.java @@ -3,9 +3,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record DescribeScheduleCommand(long id, String description, String name) - implements ApplicationEvent { + implements ApplicationEvent { - public static void scheduleDescribed(long id, String description, String name) { - new DescribeScheduleCommand(id, description, name).publish(); - } + public static void scheduleDescribed(long id, String description, String name) { + new DescribeScheduleCommand(id, description, name).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/LimitScheduleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/LimitScheduleCommand.java index 1bca24c1..dfeffd52 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/LimitScheduleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/LimitScheduleCommand.java @@ -1,24 +1,25 @@ package com.jongsoft.finance.messaging.commands.schedule; import com.jongsoft.finance.schedule.Schedulable; + import java.time.LocalDate; import java.util.Map; public record LimitScheduleCommand(long id, Schedulable schedulable, LocalDate start, LocalDate end) - implements ScheduleCommand { + implements ScheduleCommand { - @Override - public String processDefinition() { - return "ScheduledTransaction"; - } + @Override + public String processDefinition() { + return "ScheduledTransaction"; + } - @Override - public Map variables() { - return Map.of("id", id); - } + @Override + public Map variables() { + return Map.of("id", id); + } - public static void scheduleCreated( - long id, Schedulable schedulable, LocalDate start, LocalDate end) { - new LimitScheduleCommand(id, schedulable, start, end).publish(); - } + public static void scheduleCreated( + long id, Schedulable schedulable, LocalDate start, LocalDate end) { + new LimitScheduleCommand(id, schedulable, start, end).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/RescheduleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/RescheduleCommand.java index 767c39dc..918426c8 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/RescheduleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/RescheduleCommand.java @@ -2,22 +2,23 @@ import com.jongsoft.finance.schedule.Schedulable; import com.jongsoft.finance.schedule.Schedule; + import java.util.Map; public record RescheduleCommand(long id, Schedulable schedulable, Schedule schedule) - implements ScheduleCommand { + implements ScheduleCommand { - @Override - public String processDefinition() { - return "ScheduledTransaction"; - } + @Override + public String processDefinition() { + return "ScheduledTransaction"; + } - @Override - public Map variables() { - return Map.of("id", id); - } + @Override + public Map variables() { + return Map.of("id", id); + } - public static void scheduleRescheduled(long id, Schedulable schedulable, Schedule schedule) { - new RescheduleCommand(id, schedulable, schedule).publish(); - } + public static void scheduleRescheduled(long id, Schedulable schedulable, Schedule schedule) { + new RescheduleCommand(id, schedulable, schedule).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/ScheduleCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/ScheduleCommand.java index 6c2eae97..5d876106 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/ScheduleCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/schedule/ScheduleCommand.java @@ -2,19 +2,20 @@ import com.jongsoft.finance.messaging.ApplicationEvent; import com.jongsoft.finance.schedule.Schedulable; + import java.util.Map; public interface ScheduleCommand extends ApplicationEvent { - String processDefinition(); + String processDefinition(); - Schedulable schedulable(); + Schedulable schedulable(); - default String businessKey() { - return "bk_" + processDefinition() + "_" + schedulable().getId(); - } + default String businessKey() { + return "bk_" + processDefinition() + "_" + schedulable().getId(); + } - default Map variables() { - return Map.of(); - } + default Map variables() { + return Map.of(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/storage/ReplaceFileCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/storage/ReplaceFileCommand.java index 81ec7547..acb2caa3 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/storage/ReplaceFileCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/storage/ReplaceFileCommand.java @@ -4,7 +4,7 @@ public interface ReplaceFileCommand extends ApplicationEvent { - String fileCode(); + String fileCode(); - String oldFileCode(); + String oldFileCode(); } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/tag/CreateTagCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/tag/CreateTagCommand.java index 49de1b8e..c83f239b 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/tag/CreateTagCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/tag/CreateTagCommand.java @@ -4,7 +4,7 @@ public record CreateTagCommand(String tag) implements ApplicationEvent { - public static void tagCreated(String tag) { - new CreateTagCommand(tag).publish(); - } + public static void tagCreated(String tag) { + new CreateTagCommand(tag).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionAmountCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionAmountCommand.java index b44fa4f6..5ca74746 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionAmountCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionAmountCommand.java @@ -1,12 +1,13 @@ package com.jongsoft.finance.messaging.commands.transaction; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.math.BigDecimal; public record ChangeTransactionAmountCommand(long id, BigDecimal amount, String currency) - implements ApplicationEvent { + implements ApplicationEvent { - public static void amountChanged(long id, BigDecimal amount, String currency) { - new ChangeTransactionAmountCommand(id, amount, currency).publish(); - } + public static void amountChanged(long id, BigDecimal amount, String currency) { + new ChangeTransactionAmountCommand(id, amount, currency).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionDatesCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionDatesCommand.java index fc6c8ad4..ac31e96f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionDatesCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionDatesCommand.java @@ -1,14 +1,15 @@ package com.jongsoft.finance.messaging.commands.transaction; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDate; public record ChangeTransactionDatesCommand( - long id, LocalDate date, LocalDate bookingDate, LocalDate interestDate) - implements ApplicationEvent { + long id, LocalDate date, LocalDate bookingDate, LocalDate interestDate) + implements ApplicationEvent { - public static void transactionDatesChanged( - long id, LocalDate date, LocalDate bookingDate, LocalDate interestDate) { - new ChangeTransactionDatesCommand(id, date, bookingDate, interestDate).publish(); - } + public static void transactionDatesChanged( + long id, LocalDate date, LocalDate bookingDate, LocalDate interestDate) { + new ChangeTransactionDatesCommand(id, date, bookingDate, interestDate).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionPartAccount.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionPartAccount.java index 22406d60..2902e49f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionPartAccount.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/ChangeTransactionPartAccount.java @@ -4,7 +4,7 @@ public record ChangeTransactionPartAccount(long id, long accountId) implements ApplicationEvent { - public static void transactionPartAccountChanged(long id, long accountId) { - new ChangeTransactionPartAccount(id, accountId).publish(); - } + public static void transactionPartAccountChanged(long id, long accountId) { + new ChangeTransactionPartAccount(id, accountId).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/CreateTransactionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/CreateTransactionCommand.java index aadeff76..e2b31ec9 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/CreateTransactionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/CreateTransactionCommand.java @@ -5,7 +5,7 @@ public record CreateTransactionCommand(Transaction transaction) implements ApplicationEvent { - public static void transactionCreated(Transaction transaction) { - new CreateTransactionCommand(transaction).publish(); - } + public static void transactionCreated(Transaction transaction) { + new CreateTransactionCommand(transaction).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTagCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTagCommand.java index 497ecd6d..98a67f65 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTagCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTagCommand.java @@ -4,7 +4,7 @@ public record DeleteTagCommand(String tag) implements ApplicationEvent { - public static void tagDeleted(String tag) { - new DeleteTagCommand(tag).publish(); - } + public static void tagDeleted(String tag) { + new DeleteTagCommand(tag).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTransactionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTransactionCommand.java index bbe885ba..30bde368 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTransactionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DeleteTransactionCommand.java @@ -4,7 +4,7 @@ public record DeleteTransactionCommand(long id) implements ApplicationEvent { - public static void transactionDeleted(long id) { - new DeleteTransactionCommand(id).publish(); - } + public static void transactionDeleted(long id) { + new DeleteTransactionCommand(id).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DescribeTransactionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DescribeTransactionCommand.java index 31de6d69..0d71aae2 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DescribeTransactionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/DescribeTransactionCommand.java @@ -4,7 +4,7 @@ public record DescribeTransactionCommand(long id, String description) implements ApplicationEvent { - public static void transactionDescribed(long id, String description) { - new DescribeTransactionCommand(id, description).publish(); - } + public static void transactionDescribed(long id, String description) { + new DescribeTransactionCommand(id, description).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/LinkTransactionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/LinkTransactionCommand.java index 83b5b47e..a46d270a 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/LinkTransactionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/LinkTransactionCommand.java @@ -3,15 +3,15 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record LinkTransactionCommand(long id, LinkType type, String relation) - implements ApplicationEvent { - public enum LinkType { - CATEGORY, - EXPENSE, - CONTRACT, - IMPORT - } + implements ApplicationEvent { + public enum LinkType { + CATEGORY, + EXPENSE, + CONTRACT, + IMPORT + } - public static void linkCreated(long id, LinkType type, String relation) { - new LinkTransactionCommand(id, type, relation).publish(); - } + public static void linkCreated(long id, LinkType type, String relation) { + new LinkTransactionCommand(id, type, relation).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/RegisterFailureCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/RegisterFailureCommand.java index d98f60ce..62dcbc2d 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/RegisterFailureCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/RegisterFailureCommand.java @@ -5,7 +5,7 @@ public record RegisterFailureCommand(long id, FailureCode code) implements ApplicationEvent { - public static void registerFailure(long id, FailureCode code) { - new RegisterFailureCommand(id, code).publish(); - } + public static void registerFailure(long id, FailureCode code) { + new RegisterFailureCommand(id, code).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/SplitTransactionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/SplitTransactionCommand.java index f1fba4f3..0ea9ce28 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/SplitTransactionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/SplitTransactionCommand.java @@ -5,9 +5,9 @@ import com.jongsoft.lang.collection.Sequence; public record SplitTransactionCommand(long id, Sequence split) - implements ApplicationEvent { + implements ApplicationEvent { - public static void transactionSplit(long id, Sequence split) { - new SplitTransactionCommand(id, split).publish(); - } + public static void transactionSplit(long id, Sequence split) { + new SplitTransactionCommand(id, split).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/TagTransactionCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/TagTransactionCommand.java index 939f5b9a..1c3c1206 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/TagTransactionCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/transaction/TagTransactionCommand.java @@ -5,7 +5,7 @@ public record TagTransactionCommand(long id, Sequence tags) implements ApplicationEvent { - public static void tagCreated(long id, Sequence tags) { - new TagTransactionCommand(id, tags).publish(); - } + public static void tagCreated(long id, Sequence tags) { + new TagTransactionCommand(id, tags).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeMultiFactorCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeMultiFactorCommand.java index 7893cada..6ec8ff7c 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeMultiFactorCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeMultiFactorCommand.java @@ -4,9 +4,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record ChangeMultiFactorCommand(UserIdentifier username, boolean enabled) - implements ApplicationEvent { + implements ApplicationEvent { - public static void multiFactorChanged(UserIdentifier username, boolean enabled) { - new ChangeMultiFactorCommand(username, enabled).publish(); - } + public static void multiFactorChanged(UserIdentifier username, boolean enabled) { + new ChangeMultiFactorCommand(username, enabled).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangePasswordCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangePasswordCommand.java index 3064222e..f3e7daa8 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangePasswordCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangePasswordCommand.java @@ -4,9 +4,9 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record ChangePasswordCommand(UserIdentifier username, String password) - implements ApplicationEvent { + implements ApplicationEvent { - public static void passwordChanged(UserIdentifier username, String password) { - new ChangePasswordCommand(username, password).publish(); - } + public static void passwordChanged(UserIdentifier username, String password) { + new ChangePasswordCommand(username, password).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeUserSettingCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeUserSettingCommand.java index b3160090..7e411cf8 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeUserSettingCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/ChangeUserSettingCommand.java @@ -4,13 +4,13 @@ import com.jongsoft.finance.messaging.ApplicationEvent; public record ChangeUserSettingCommand(UserIdentifier username, Type type, String value) - implements ApplicationEvent { - public enum Type { - THEME, - CURRENCY - } + implements ApplicationEvent { + public enum Type { + THEME, + CURRENCY + } - public static void userSettingChanged(UserIdentifier username, Type type, String value) { - new ChangeUserSettingCommand(username, type, value).publish(); - } + public static void userSettingChanged(UserIdentifier username, Type type, String value) { + new ChangeUserSettingCommand(username, type, value).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateExternalUserCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateExternalUserCommand.java index d768b3e8..3f8d2bf1 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateExternalUserCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateExternalUserCommand.java @@ -4,9 +4,9 @@ import com.jongsoft.lang.collection.List; public record CreateExternalUserCommand(String username, String oauthToken, List roles) - implements ApplicationEvent { + implements ApplicationEvent { - public static void externalUserCreated(String username, String oauthToken, List roles) { - new CreateExternalUserCommand(username, oauthToken, roles).publish(); - } + public static void externalUserCreated(String username, String oauthToken, List roles) { + new CreateExternalUserCommand(username, oauthToken, roles).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateUserCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateUserCommand.java index 93016ea8..29731184 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateUserCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/CreateUserCommand.java @@ -4,7 +4,7 @@ public record CreateUserCommand(String username, String password) implements ApplicationEvent { - public static void userCreated(String username, String password) { - new CreateUserCommand(username, password).publish(); - } + public static void userCreated(String username, String password) { + new CreateUserCommand(username, password).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RegisterTokenCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RegisterTokenCommand.java index a9739d36..7e7edb16 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RegisterTokenCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RegisterTokenCommand.java @@ -1,13 +1,14 @@ package com.jongsoft.finance.messaging.commands.user; import com.jongsoft.finance.messaging.ApplicationEvent; + import java.time.LocalDateTime; public record RegisterTokenCommand(String username, String refreshToken, LocalDateTime expireDate) - implements ApplicationEvent { + implements ApplicationEvent { - public static void tokenRegistered( - String username, String refreshToken, LocalDateTime expireDate) { - new RegisterTokenCommand(username, refreshToken, expireDate).publish(); - } + public static void tokenRegistered( + String username, String refreshToken, LocalDateTime expireDate) { + new RegisterTokenCommand(username, refreshToken, expireDate).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RevokeTokenCommand.java b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RevokeTokenCommand.java index 4c57f853..642c397f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RevokeTokenCommand.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/commands/user/RevokeTokenCommand.java @@ -4,7 +4,7 @@ public record RevokeTokenCommand(String token) implements ApplicationEvent { - public static void tokenRevoked(String token) { - new RevokeTokenCommand(token).publish(); - } + public static void tokenRevoked(String token) { + new RevokeTokenCommand(token).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/handlers/TransactionCreationHandler.java b/domain/src/main/java/com/jongsoft/finance/messaging/handlers/TransactionCreationHandler.java index 61073e0c..7f75185f 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/handlers/TransactionCreationHandler.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/handlers/TransactionCreationHandler.java @@ -4,11 +4,11 @@ public interface TransactionCreationHandler { - /** - * Handle the creation of a transaction - * - * @param command the command - * @return the id of the created transaction - */ - long handleCreatedEvent(CreateTransactionCommand command); + /** + * Handle the creation of a transaction + * + * @param command the command + * @return the id of the created transaction + */ + long handleCreatedEvent(CreateTransactionCommand command); } diff --git a/domain/src/main/java/com/jongsoft/finance/messaging/notifications/TransactionCreated.java b/domain/src/main/java/com/jongsoft/finance/messaging/notifications/TransactionCreated.java index 6aaf6919..26f21e02 100644 --- a/domain/src/main/java/com/jongsoft/finance/messaging/notifications/TransactionCreated.java +++ b/domain/src/main/java/com/jongsoft/finance/messaging/notifications/TransactionCreated.java @@ -4,7 +4,7 @@ public record TransactionCreated(long transactionId) implements ApplicationEvent { - public static void transactionCreated(long transactionId) { - new TransactionCreated(transactionId).publish(); - } + public static void transactionCreated(long transactionId) { + new TransactionCreated(transactionId).publish(); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/AccountProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/AccountProvider.java index 9cb6b6d4..40607185 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/AccountProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/AccountProvider.java @@ -7,42 +7,43 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; import com.jongsoft.lang.time.Range; + import java.time.LocalDate; public interface AccountProvider extends DataProvider, Exportable { - interface FilterCommand { - FilterCommand name(String value, boolean fullMatch); + interface FilterCommand { + FilterCommand name(String value, boolean fullMatch); - FilterCommand iban(String value, boolean fullMatch); + FilterCommand iban(String value, boolean fullMatch); - FilterCommand number(String value, boolean fullMatch); + FilterCommand number(String value, boolean fullMatch); - FilterCommand types(Sequence types); + FilterCommand types(Sequence types); - FilterCommand page(int page, int pageSize); - } + FilterCommand page(int page, int pageSize); + } - interface AccountSpending { - Account account(); + interface AccountSpending { + Account account(); - double total(); + double total(); - double average(); - } + double average(); + } - Optional synonymOf(String synonym); + Optional synonymOf(String synonym); - Optional lookup(String name); + Optional lookup(String name); - Optional lookup(SystemAccountTypes accountType); + Optional lookup(SystemAccountTypes accountType); - ResultPage lookup(FilterCommand filter); + ResultPage lookup(FilterCommand filter); - Sequence top(FilterCommand filter, Range range, boolean asc); + Sequence top(FilterCommand filter, Range range, boolean asc); - @Override - default boolean supports(Class supportingClass) { - return Account.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return Account.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/AccountTypeProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/AccountTypeProvider.java index 25706f23..afa4128e 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/AccountTypeProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/AccountTypeProvider.java @@ -4,5 +4,5 @@ public interface AccountTypeProvider { - Sequence lookup(boolean hidden); + Sequence lookup(boolean hidden); } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/AnalyzeJobProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/AnalyzeJobProvider.java index 61b7d6f4..0f53c501 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/AnalyzeJobProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/AnalyzeJobProvider.java @@ -1,9 +1,10 @@ package com.jongsoft.finance.providers; import com.jongsoft.finance.domain.insight.AnalyzeJob; + import java.util.Optional; public interface AnalyzeJobProvider { - Optional first(); + Optional first(); } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/BudgetProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/BudgetProvider.java index f79dd159..be303fd5 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/BudgetProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/BudgetProvider.java @@ -7,15 +7,15 @@ public interface BudgetProvider extends Exportable { - @Override - Sequence lookup(); + @Override + Sequence lookup(); - Optional lookup(int year, int month); + Optional lookup(int year, int month); - Optional first(); + Optional first(); - @Override - default boolean supports(Class supportingClass) { - return Budget.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return Budget.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/CategoryProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/CategoryProvider.java index d01ce856..cdbcc4ba 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/CategoryProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/CategoryProvider.java @@ -7,18 +7,18 @@ public interface CategoryProvider extends DataProvider, Exportable { - interface FilterCommand { - FilterCommand label(String label, boolean exact); + interface FilterCommand { + FilterCommand label(String label, boolean exact); - FilterCommand page(int page, int pageSize); - } + FilterCommand page(int page, int pageSize); + } - Optional lookup(String label); + Optional lookup(String label); - ResultPage lookup(FilterCommand filterCommand); + ResultPage lookup(FilterCommand filterCommand); - @Override - default boolean supports(Class supportingClass) { - return Category.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return Category.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/ContractProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/ContractProvider.java index 69fe6fe8..b4b8b5ec 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/ContractProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/ContractProvider.java @@ -7,11 +7,11 @@ public interface ContractProvider extends DataProvider, Exportable { - Optional lookup(String name); + Optional lookup(String name); - Sequence search(String partialName); + Sequence search(String partialName); - default boolean supports(Class supportingClass) { - return Contract.class.equals(supportingClass); - } + default boolean supports(Class supportingClass) { + return Contract.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/CurrencyProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/CurrencyProvider.java index d6cf8788..2e596be9 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/CurrencyProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/CurrencyProvider.java @@ -6,10 +6,10 @@ public interface CurrencyProvider extends DataProvider, Exportable { - Optional lookup(String code); + Optional lookup(String code); - @Override - default boolean supports(Class supportingClass) { - return Currency.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return Currency.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/DataProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/DataProvider.java index 0a4f5a03..2b67afc0 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/DataProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/DataProvider.java @@ -6,5 +6,5 @@ public interface DataProvider extends SupportIndicating, JavaBean { - Optional lookup(long id); + Optional lookup(long id); } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/ExpenseProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/ExpenseProvider.java index 198e7b3e..b6e5faa0 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/ExpenseProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/ExpenseProvider.java @@ -5,13 +5,13 @@ public interface ExpenseProvider extends DataProvider { - interface FilterCommand { - FilterCommand name(String value, boolean exact); - } + interface FilterCommand { + FilterCommand name(String value, boolean exact); + } - ResultPage lookup(FilterCommand filter); + ResultPage lookup(FilterCommand filter); - default boolean supports(Class supportingClass) { - return EntityRef.NamedEntity.class.equals(supportingClass); - } + default boolean supports(Class supportingClass) { + return EntityRef.NamedEntity.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/ImportConfigurationProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/ImportConfigurationProvider.java index fa2151a0..9952ebb4 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/ImportConfigurationProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/ImportConfigurationProvider.java @@ -6,7 +6,7 @@ public interface ImportConfigurationProvider { - Optional lookup(String name); + Optional lookup(String name); - Sequence lookup(); + Sequence lookup(); } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/ImportProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/ImportProvider.java index 6a828b10..d6a9f841 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/ImportProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/ImportProvider.java @@ -6,21 +6,21 @@ public interface ImportProvider { - interface FilterCommand { - default int page() { - return 0; - } + interface FilterCommand { + default int page() { + return 0; + } - default int pageSize() { - return Integer.MAX_VALUE; - } + default int pageSize() { + return Integer.MAX_VALUE; + } - static FilterCommand unpaged() { - return new FilterCommand() {}; + static FilterCommand unpaged() { + return new FilterCommand() {}; + } } - } - Optional lookup(String slug); + Optional lookup(String slug); - ResultPage lookup(FilterCommand filter); + ResultPage lookup(FilterCommand filter); } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/SettingProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/SettingProvider.java index 49ae8079..bd195db7 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/SettingProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/SettingProvider.java @@ -4,40 +4,41 @@ import com.jongsoft.finance.domain.core.Setting; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import java.util.function.Function; public interface SettingProvider { - Sequence lookup(); + Sequence lookup(); - Optional lookup(String name); + Optional lookup(String name); - default int getBudgetAnalysisMonths() { - return getSetting("AnalysisBudgetMonths", 3, Integer::valueOf, SettingType.NUMBER); - } + default int getBudgetAnalysisMonths() { + return getSetting("AnalysisBudgetMonths", 3, Integer::valueOf, SettingType.NUMBER); + } - default int getPageSize() { - return getSetting("RecordSetPageSize", 20, Integer::valueOf, SettingType.NUMBER); - } + default int getPageSize() { + return getSetting("RecordSetPageSize", 20, Integer::valueOf, SettingType.NUMBER); + } - default int getAutocompleteLimit() { - return getSetting("AutocompleteLimit", 10, Integer::valueOf, SettingType.NUMBER); - } + default int getAutocompleteLimit() { + return getSetting("AutocompleteLimit", 10, Integer::valueOf, SettingType.NUMBER); + } - default double getMaximumBudgetDeviation() { - return getSetting("AnalysisBudgetDeviation", 0.25, Double::valueOf, SettingType.NUMBER); - } + default double getMaximumBudgetDeviation() { + return getSetting("AnalysisBudgetDeviation", 0.25, Double::valueOf, SettingType.NUMBER); + } - default boolean registrationClosed() { - return !getSetting("RegistrationOpen", true, Boolean::valueOf, SettingType.FLAG); - } + default boolean registrationClosed() { + return !getSetting("RegistrationOpen", true, Boolean::valueOf, SettingType.FLAG); + } - default X getSetting( - String setting, X defaultValue, Function conversion, SettingType filter) { - return lookup(setting) - .filter(s -> s.getType() == filter) - .map(Setting::getValue) - .map(conversion) - .getOrSupply(() -> defaultValue); - } + default X getSetting( + String setting, X defaultValue, Function conversion, SettingType filter) { + return lookup(setting) + .filter(s -> s.getType() == filter) + .map(Setting::getValue) + .map(conversion) + .getOrSupply(() -> defaultValue); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/SpendingInsightProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/SpendingInsightProvider.java index be629a04..4b9a10ea 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/SpendingInsightProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/SpendingInsightProvider.java @@ -5,51 +5,52 @@ import com.jongsoft.finance.domain.insight.SpendingInsight; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import java.time.YearMonth; public interface SpendingInsightProvider extends Exportable { - interface FilterCommand { - FilterCommand category(String value, boolean exact); - - FilterCommand yearMonth(YearMonth yearMonth); - - FilterCommand page(int page, int pageSize); - } - - /** - * Gets all spending insights for the current user. - * - * @return a sequence of all spending insights - */ - Sequence lookup(); - - /** - * Gets spending insights for a specific category. - * - * @param category the category to filter by - * @return an optional spending insight - */ - Optional lookup(String category); - - /** - * Gets spending insights for a specific year and month. - * - * @param yearMonth the year and month to filter by - * @return a sequence of spending insights - */ - Sequence lookup(YearMonth yearMonth); - - /** - * Gets spending insights based on a filter. - * - * @param filter the filter to apply - * @return a page of spending insights - */ - ResultPage lookup(FilterCommand filter); - - @Override - default boolean supports(Class supportingClass) { - return SpendingInsight.class.equals(supportingClass); - } + interface FilterCommand { + FilterCommand category(String value, boolean exact); + + FilterCommand yearMonth(YearMonth yearMonth); + + FilterCommand page(int page, int pageSize); + } + + /** + * Gets all spending insights for the current user. + * + * @return a sequence of all spending insights + */ + Sequence lookup(); + + /** + * Gets spending insights for a specific category. + * + * @param category the category to filter by + * @return an optional spending insight + */ + Optional lookup(String category); + + /** + * Gets spending insights for a specific year and month. + * + * @param yearMonth the year and month to filter by + * @return a sequence of spending insights + */ + Sequence lookup(YearMonth yearMonth); + + /** + * Gets spending insights based on a filter. + * + * @param filter the filter to apply + * @return a page of spending insights + */ + ResultPage lookup(FilterCommand filter); + + @Override + default boolean supports(Class supportingClass) { + return SpendingInsight.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/SpendingPatternProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/SpendingPatternProvider.java index 5f969887..bab4e63e 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/SpendingPatternProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/SpendingPatternProvider.java @@ -5,51 +5,52 @@ import com.jongsoft.finance.domain.insight.SpendingPattern; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import java.time.YearMonth; public interface SpendingPatternProvider extends Exportable { - interface FilterCommand { - FilterCommand category(String value, boolean exact); - - FilterCommand yearMonth(YearMonth yearMonth); - - FilterCommand page(int page, int pageSize); - } - - /** - * Gets all spending patterns for the current user. - * - * @return a sequence of all spending patterns - */ - Sequence lookup(); - - /** - * Gets spending patterns for a specific category. - * - * @param category the category to filter by - * @return an optional spending pattern - */ - Optional lookup(String category); - - /** - * Gets spending patterns for a specific year and month. - * - * @param yearMonth the year and month to filter by - * @return a sequence of spending patterns - */ - Sequence lookup(YearMonth yearMonth); - - /** - * Gets spending patterns based on a filter. - * - * @param filter the filter to apply - * @return a page of spending patterns - */ - ResultPage lookup(FilterCommand filter); - - @Override - default boolean supports(Class supportingClass) { - return SpendingPattern.class.equals(supportingClass); - } + interface FilterCommand { + FilterCommand category(String value, boolean exact); + + FilterCommand yearMonth(YearMonth yearMonth); + + FilterCommand page(int page, int pageSize); + } + + /** + * Gets all spending patterns for the current user. + * + * @return a sequence of all spending patterns + */ + Sequence lookup(); + + /** + * Gets spending patterns for a specific category. + * + * @param category the category to filter by + * @return an optional spending pattern + */ + Optional lookup(String category); + + /** + * Gets spending patterns for a specific year and month. + * + * @param yearMonth the year and month to filter by + * @return a sequence of spending patterns + */ + Sequence lookup(YearMonth yearMonth); + + /** + * Gets spending patterns based on a filter. + * + * @param filter the filter to apply + * @return a page of spending patterns + */ + ResultPage lookup(FilterCommand filter); + + @Override + default boolean supports(Class supportingClass) { + return SpendingPattern.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/TagProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/TagProvider.java index 0e5e229f..53b121f4 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/TagProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/TagProvider.java @@ -7,18 +7,18 @@ public interface TagProvider extends Exportable { - interface FilterCommand { - FilterCommand name(String value, boolean exact); + interface FilterCommand { + FilterCommand name(String value, boolean exact); - FilterCommand page(int page, int pageSize); - } + FilterCommand page(int page, int pageSize); + } - Optional lookup(String name); + Optional lookup(String name); - ResultPage lookup(FilterCommand filter); + ResultPage lookup(FilterCommand filter); - @Override - default boolean supports(Class supportingClass) { - return Tag.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return Tag.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/TransactionProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/TransactionProvider.java index 006820cc..aad4183a 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/TransactionProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/TransactionProvider.java @@ -6,167 +6,168 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; import com.jongsoft.lang.time.Range; + import java.math.BigDecimal; import java.time.LocalDate; public interface TransactionProvider extends DataProvider { - /** - * This command class helps to search in the database for relevant transactions. Use the methods - * provided to add filters to the query. Calling a method twice will override the previous - * value. - */ - interface FilterCommand { /** - * A list of {@link EntityRef} with unique identifiers of accounts that should be filtered - * for. - * - * @param value the list of identifiers - * @return this instance + * This command class helps to search in the database for relevant transactions. Use the methods + * provided to add filters to the query. Calling a method twice will override the previous + * value. */ - FilterCommand accounts(Sequence value); + interface FilterCommand { + /** + * A list of {@link EntityRef} with unique identifiers of accounts that should be filtered + * for. + * + * @param value the list of identifiers + * @return this instance + */ + FilterCommand accounts(Sequence value); + + /** + * A list of {@link EntityRef} with unique identifiers of categories that should be filtered + * for. + * + * @param value the list of identifiers + * @return this instance + */ + FilterCommand categories(Sequence value); + + /** + * A list of {@link EntityRef} with unique identifiers of contracts that should be filtered + * for. + * + * @param value the list of identifiers + * @return this instance + */ + FilterCommand contracts(Sequence value); + + /** + * A list of {@link EntityRef} with unique identifiers of expenses that should be filtered + * for. + * + * @param value the list of identifiers + * @return this instance + */ + FilterCommand expenses(Sequence value); + + /** + * Add a filter on the name of one of the accounts involved in the transaction. Depending on + * the value of the {@code exact} flag this will be an exact match or a partial match. + * + * @param value the name + * @param exact should the name match exactly or partially + * @return this instance + */ + FilterCommand name(String value, boolean exact); + + /** + * Add a filter for the description in the transaction. You can choose if the match should + * be exact or partial using the {@code exact} parameter. + * + * @param value the description + * @param exact should the name match exactly or partially + * @return this instance + */ + FilterCommand description(String value, boolean exact); + + /** + * Add a date range to the filter for the transactions. + * + * @param range the active date range + * @return this instance + */ + FilterCommand range(Range range); + + FilterCommand importSlug(String value); + + FilterCommand currency(String currency); + + /** + * Indicates if the result should include only income or expenses. + * + * @param onlyIncome true for income, false for expenses + * @return this instance + */ + FilterCommand onlyIncome(boolean onlyIncome); + + /** + * Filter to only include transactions to all of your own accounts. This will exclude some + * transactions from the search. This operation is mutually exclusive with {@link + * #transfers()} ()} and {@link #accounts(Sequence)}. + * + * @return this instance + */ + FilterCommand ownAccounts(); + + /** + * Only include transactions between accounts owned by the user. This operation is mutually + * exclusive with {@link #ownAccounts()} and {@link #accounts(Sequence)}. + * + * @return this instance + */ + FilterCommand transfers(); + + /** + * Set the page to retrieve, if a page is set greater then available the result will be a + * blank {@link ResultPage}. + * + * @param value the page + * @return this instance + */ + FilterCommand page(int value, int pageSize); + } /** - * A list of {@link EntityRef} with unique identifiers of categories that should be filtered - * for. - * - * @param value the list of identifiers - * @return this instance + * The daily summary is a statistical data container. It will allow the system to provide the + * aggregation of all transactions that occurred on one single day. */ - FilterCommand categories(Sequence value); + interface DailySummary { + /** + * The date for which this summary is valid. + * + * @return the day + */ + LocalDate day(); + + /** + * The actual aggregated value for the given date. + * + * @return the summary + */ + double summary(); + } /** - * A list of {@link EntityRef} with unique identifiers of contracts that should be filtered - * for. + * Locate the first ever transaction made that meets the preset given using the {@link + * FilterCommand}. * - * @param value the list of identifiers - * @return this instance + * @param filter the filter to be applied + * @return the first found transaction, or an empty otherwise */ - FilterCommand contracts(Sequence value); + Optional first(FilterCommand filter); - /** - * A list of {@link EntityRef} with unique identifiers of expenses that should be filtered - * for. - * - * @param value the list of identifiers - * @return this instance - */ - FilterCommand expenses(Sequence value); - - /** - * Add a filter on the name of one of the accounts involved in the transaction. Depending on - * the value of the {@code exact} flag this will be an exact match or a partial match. - * - * @param value the name - * @param exact should the name match exactly or partially - * @return this instance - */ - FilterCommand name(String value, boolean exact); + ResultPage lookup(FilterCommand filter); - /** - * Add a filter for the description in the transaction. You can choose if the match should - * be exact or partial using the {@code exact} parameter. - * - * @param value the description - * @param exact should the name match exactly or partially - * @return this instance - */ - FilterCommand description(String value, boolean exact); + Sequence daily(FilterCommand filter); /** - * Add a date range to the filter for the transactions. + * Retrieve a list of {@link DailySummary} for the given date range. The list will contain all + * days of the month and the aggregated value on the first of every month. * - * @param range the active date range - * @return this instance + * @param filterCommand the filter to be applied + * @return the list of daily summaries */ - FilterCommand range(Range range); + Sequence monthly(FilterCommand filterCommand); - FilterCommand importSlug(String value); + Optional balance(FilterCommand filter); - FilterCommand currency(String currency); + Sequence similar(EntityRef from, EntityRef to, double amount, LocalDate date); - /** - * Indicates if the result should include only income or expenses. - * - * @param onlyIncome true for income, false for expenses - * @return this instance - */ - FilterCommand onlyIncome(boolean onlyIncome); - - /** - * Filter to only include transactions to all of your own accounts. This will exclude some - * transactions from the search. This operation is mutually exclusive with {@link - * #transfers()} ()} and {@link #accounts(Sequence)}. - * - * @return this instance - */ - FilterCommand ownAccounts(); - - /** - * Only include transactions between accounts owned by the user. This operation is mutually - * exclusive with {@link #ownAccounts()} and {@link #accounts(Sequence)}. - * - * @return this instance - */ - FilterCommand transfers(); - - /** - * Set the page to retrieve, if a page is set greater then available the result will be a - * blank {@link ResultPage}. - * - * @param value the page - * @return this instance - */ - FilterCommand page(int value, int pageSize); - } - - /** - * The daily summary is a statistical data container. It will allow the system to provide the - * aggregation of all transactions that occurred on one single day. - */ - interface DailySummary { - /** - * The date for which this summary is valid. - * - * @return the day - */ - LocalDate day(); - - /** - * The actual aggregated value for the given date. - * - * @return the summary - */ - double summary(); - } - - /** - * Locate the first ever transaction made that meets the preset given using the {@link - * FilterCommand}. - * - * @param filter the filter to be applied - * @return the first found transaction, or an empty otherwise - */ - Optional first(FilterCommand filter); - - ResultPage lookup(FilterCommand filter); - - Sequence daily(FilterCommand filter); - - /** - * Retrieve a list of {@link DailySummary} for the given date range. The list will contain all - * days of the month and the aggregated value on the first of every month. - * - * @param filterCommand the filter to be applied - * @return the list of daily summaries - */ - Sequence monthly(FilterCommand filterCommand); - - Optional balance(FilterCommand filter); - - Sequence similar(EntityRef from, EntityRef to, double amount, LocalDate date); - - default boolean supports(Class supportingClass) { - return Transaction.class.equals(supportingClass); - } + default boolean supports(Class supportingClass) { + return Transaction.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleGroupProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleGroupProvider.java index 2d72874b..3c3b14cb 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleGroupProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleGroupProvider.java @@ -6,7 +6,7 @@ public interface TransactionRuleGroupProvider { - Sequence lookup(); + Sequence lookup(); - Optional lookup(String name); + Optional lookup(String name); } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleProvider.java index ae2f2002..afd14123 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/TransactionRuleProvider.java @@ -5,15 +5,15 @@ import com.jongsoft.lang.collection.Sequence; public interface TransactionRuleProvider - extends DataProvider, Exportable { + extends DataProvider, Exportable { - Sequence lookup(String group); + Sequence lookup(String group); - @Deprecated - void save(TransactionRule rule); + @Deprecated + void save(TransactionRule rule); - @Override - default boolean supports(Class supportingClass) { - return TransactionRule.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return TransactionRule.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/TransactionScheduleProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/TransactionScheduleProvider.java index 7aa9c3d9..6105ebd1 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/TransactionScheduleProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/TransactionScheduleProvider.java @@ -7,18 +7,18 @@ public interface TransactionScheduleProvider extends DataProvider { - interface FilterCommand { - FilterCommand contract(Sequence contracts); + interface FilterCommand { + FilterCommand contract(Sequence contracts); - FilterCommand activeOnly(); - } + FilterCommand activeOnly(); + } - Sequence lookup(); + Sequence lookup(); - ResultPage lookup(FilterCommand filterCommand); + ResultPage lookup(FilterCommand filterCommand); - @Override - default boolean supports(Class supportingClass) { - return ScheduledTransaction.class.equals(supportingClass); - } + @Override + default boolean supports(Class supportingClass) { + return ScheduledTransaction.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/providers/UserProvider.java b/domain/src/main/java/com/jongsoft/finance/providers/UserProvider.java index 4563ecfa..ec16c5fd 100644 --- a/domain/src/main/java/com/jongsoft/finance/providers/UserProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/providers/UserProvider.java @@ -8,17 +8,17 @@ public interface UserProvider extends DataProvider { - Sequence lookup(); + Sequence lookup(); - boolean available(UserIdentifier username); + boolean available(UserIdentifier username); - Optional lookup(UserIdentifier username); + Optional lookup(UserIdentifier username); - Optional refreshToken(String refreshToken); + Optional refreshToken(String refreshToken); - Sequence tokens(UserIdentifier username); + Sequence tokens(UserIdentifier username); - default boolean supports(Class supportingClass) { - return UserAccount.class.equals(supportingClass); - } + default boolean supports(Class supportingClass) { + return UserAccount.class.equals(supportingClass); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/security/AuthenticationFacade.java b/domain/src/main/java/com/jongsoft/finance/security/AuthenticationFacade.java index 3dd55cbc..11d40eb1 100644 --- a/domain/src/main/java/com/jongsoft/finance/security/AuthenticationFacade.java +++ b/domain/src/main/java/com/jongsoft/finance/security/AuthenticationFacade.java @@ -1,11 +1,33 @@ package com.jongsoft.finance.security; -public interface AuthenticationFacade { - - /** - * Get the authenticated username. - * - * @return the authenticated username - */ - String authenticated(); +import com.jongsoft.finance.messaging.InternalAuthenticationEvent; + +import io.micronaut.runtime.event.annotation.EventListener; + +import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class AuthenticationFacade { + + private static final Logger log = LoggerFactory.getLogger(AuthenticationFacade.class); + private static final ThreadLocal AUTHENTICATED_USER = new ThreadLocal<>(); + + /** + * Get the authenticated username. + * + * @return the authenticated username + */ + public String authenticated() { + log.trace("[{}] - request authenticated user.", AUTHENTICATED_USER.get()); + return AUTHENTICATED_USER.get(); + } + + @EventListener + public void internalAuthenticated(InternalAuthenticationEvent event) { + log.trace("[{}] - Setting internal authentication on thread", event.getUsername()); + AUTHENTICATED_USER.set(event.getUsername()); + } } diff --git a/domain/src/main/java/com/jongsoft/finance/security/CurrentUserProvider.java b/domain/src/main/java/com/jongsoft/finance/security/CurrentUserProvider.java index 36b42074..95963462 100644 --- a/domain/src/main/java/com/jongsoft/finance/security/CurrentUserProvider.java +++ b/domain/src/main/java/com/jongsoft/finance/security/CurrentUserProvider.java @@ -4,8 +4,8 @@ public interface CurrentUserProvider { - /** - * @return the current user or null if no user is logged in - */ - UserAccount currentUser(); + /** + * @return the current user or null if no user is logged in + */ + UserAccount currentUser(); } diff --git a/domain/src/test/java/com/jongsoft/finance/domain/account/ContractTest.java b/domain/src/test/java/com/jongsoft/finance/domain/account/ContractTest.java index a5bed2e6..30ec5d8f 100644 --- a/domain/src/test/java/com/jongsoft/finance/domain/account/ContractTest.java +++ b/domain/src/test/java/com/jongsoft/finance/domain/account/ContractTest.java @@ -1,5 +1,6 @@ package com.jongsoft.finance.domain.account; +import com.jongsoft.finance.core.exception.StatusException; import com.jongsoft.finance.domain.transaction.ScheduleValue; import com.jongsoft.finance.messaging.EventBus; import com.jongsoft.finance.messaging.commands.contract.AttachFileToContractCommand; @@ -88,7 +89,7 @@ void warnBeforeExpires_expiredContract() { .startDate(start) .build(); - IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + StatusException exception = Assertions.assertThrows(StatusException.class, contract::warnBeforeExpires); Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(WarnBeforeExpiryCommand.class); @@ -189,7 +190,7 @@ void terminate() { @Test void terminate_notExpired() { - IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + StatusException exception = Assertions.assertThrows(StatusException.class, () -> Contract.builder() .id(1L) .endDate(LocalDate.now().plusDays(2)) @@ -204,7 +205,7 @@ void terminate_notExpired() { @Test void terminate_alreadyTerminated() { - IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + StatusException exception = Assertions.assertThrows(StatusException.class, () -> Contract.builder() .id(1L) .endDate(LocalDate.of(2010, 1, 1)) diff --git a/fintrack-api/build.gradle.kts b/fintrack-api/build.gradle.kts deleted file mode 100644 index e0f29622..00000000 --- a/fintrack-api/build.gradle.kts +++ /dev/null @@ -1,104 +0,0 @@ -plugins { - id("io.micronaut.application") -} - -application { - mainClass.set("com.jongsoft.finance.Application") -} - -micronaut { - runtime("jetty") - testRuntime("junit5") -} - -val integration by sourceSets.creating - -configurations[integration.implementationConfigurationName].extendsFrom(configurations.testImplementation.get()) -configurations[integration.runtimeOnlyConfigurationName].extendsFrom(configurations.testRuntimeOnly.get()) - -tasks.register("itTest") { - description = "Runs the integration tests." - group = "verification" - - testClassesDirs = integration.output.classesDirs - classpath = configurations[integration.runtimeClasspathConfigurationName] + integration.output + sourceSets.main.get().output - - shouldRunAfter(tasks.test) -} - -tasks.jacocoTestReport { - executionData(layout.buildDirectory.files("/jacoco/test.exec", "jacoco/itTest.exec")) -} - -tasks.check { - dependsOn("itTest") -} - -tasks.processResources { - filesMatching("**/micronaut-banner.txt") { - filter { line -> - var updated = line.replace("\${application.version}", project.version.toString()) - updated.replace("\${micronaut.version}", properties.get("micronautVersion").toString()) - } - } -} - -dependencies { - annotationProcessor(mn.micronaut.openapi.asProvider()) - annotationProcessor(mn.micronaut.http.validation) - annotationProcessor(mn.micronaut.validation.processor) - annotationProcessor(mn.micronaut.inject.java) - - implementation(libs.camunda) - implementation(mn.swagger.annotations) - - implementation(mn.micronaut.validation) - implementation(mn.micronaut.security.annotations) - implementation(mn.micronaut.security.jwt) - implementation(mn.micronaut.http.server.jetty) - implementation(mn.micronaut.http.validation) - implementation(mn.micronaut.http.client) - implementation(mn.micronaut.email.template) - implementation(mn.micronaut.views.velocity) - - // Email dependencies - implementation(mn.micronaut.email.postmark) - implementation(mn.micronaut.email.javamail) - - implementation(libs.bcrypt) - implementation(libs.bouncy) - implementation(libs.bcpkix) - implementation(libs.otp) - implementation(libs.lang) - - // Contains the health checker - implementation(mn.micronaut.management) - - // Investigate if this can be swapped for micronaut serde - implementation(mn.micronaut.jackson.databind) - implementation(mn.micronaut.serde.jackson) - - implementation(project(":core")) - implementation(project(":domain")) - implementation(project(":jpa-repository")) - implementation(project(":learning:learning-module")) - implementation(project(":learning:learning-module-rules")) - implementation(project(":learning:learning-module-llm")) - implementation(project(":learning:learning-module-spending-patterns")) - implementation(project(":bpmn-process")) - implementation(project(":transaction-importer:transaction-importer-api")) - implementation(project(":transaction-importer:transaction-importer-csv")) - - // needed for application.yml - runtimeOnly(mn.snakeyaml) - runtimeOnly(mn.eclipse.angus) - runtimeOnly(mn.postmark) - - testImplementation(mn.micronaut.test.rest.assured) - testImplementation(mn.micronaut.test.junit5) - testImplementation(libs.bundles.junit) - - configurations["integrationImplementation"](libs.bundles.junit) - configurations["integrationImplementation"](mn.micronaut.test.junit5) - configurations["integrationImplementation"](mn.micronaut.test.rest.assured) -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/MultiFactorLoginTest.java b/fintrack-api/src/integration/java/com/jongsoft/finance/MultiFactorLoginTest.java deleted file mode 100644 index ccdad5df..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/MultiFactorLoginTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.jongsoft.finance; - -import com.jongsoft.finance.extension.IntegrationTest; -import com.jongsoft.finance.extension.TestContext; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.hamcrest.Matchers.equalTo; - -@IntegrationTest(phase = 1) -@DisplayName("User registers and logins with MFA") -public class MultiFactorLoginTest { - - @Test - void registerAndLoginWithMFA(TestContext testContext) { - var profileContext = testContext - .register("mfa-sample@e", "Zomer2020") - .authenticate("mfa-sample@e", "Zomer2020") - .profile(); - - profileContext - .get(response -> response - .body("theme", equalTo("light")) - .body("currency", equalTo("EUR")) - .body("mfa", equalTo(false))) - .qrCode(); - - testContext.enableMFA(); - - testContext - .authenticate("mfa-sample@e", "Zomer2020") - .multiFactor() - .profile() - .get(response -> response - .body("theme", equalTo("light")) - .body("currency", equalTo("EUR")) - .body("mfa", equalTo(true))); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/RegisterAndAccountsTest.java b/fintrack-api/src/integration/java/com/jongsoft/finance/RegisterAndAccountsTest.java deleted file mode 100644 index feec7e78..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/RegisterAndAccountsTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.jongsoft.finance; - -import com.jongsoft.finance.extension.IntegrationTest; -import com.jongsoft.finance.extension.TestContext; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; - -import static org.hamcrest.Matchers.*; - -@IntegrationTest(phase = 1) -@DisplayName("User registers and creates accounts:") -public class RegisterAndAccountsTest { - - @Test - @Order(1) - @DisplayName("Step 1: Create and authenticate.") - void createAccount(TestContext context) { - context - .register("sample@e", "Zomer2020") - .authenticate("sample@e", "Zomer2020"); - } - - @Test - @Order(2) - @DisplayName("Step 2: User creates the accounts") - void setupAccounts(TestContext context) { - context.accounts() - .create("My checking account", "This is my first account", "default") - .debtor("Chicko Cointer", "The employer of the person") - .creditor("Netflix", "Movie subscription service") - .creditor("Guarda", "A nice little shop.") - .creditor("Groceries are us", "A grocery shop."); - } - - @Test - @Order(3) - @DisplayName("Step 3: User adds a budget") - void addBudget(TestContext context) { - var now = LocalDate.now(); - context.budgets() - .create(2021, 1, 1000.00) - .createExpense("Rent", 500.00) - .createExpense("Groceries", 200.00) - .validateBudget(2021, 1, budget -> budget - .body("income", equalTo(1000.00F)) - .body("expenses.size()", equalTo(2)) - .body("expenses.name", hasItems("Rent", "Groceries")) - .body("expenses.expected", hasItems(500.00F, 200F))) - .updateIncome(2200) - .updateExpense("Rent", 600.00) - .updateExpense("Groceries", 250.00) - .createExpense("Car", 300.00) - .validateBudget(now.getYear(), now.getMonthValue(), budget -> budget - .body("income", equalTo(2200.00F)) - .body("expenses.size()", equalTo(3)) - .body("expenses.name", hasItems("Rent", "Groceries", "Car")) - .body("expenses.expected", hasItems(600.00F, 250.00F, 300.00F))) - .validateBudget(2021, 1, budget -> budget - .body("income", equalTo(1000.00F)) - .body("period.until", equalTo(now.withDayOfMonth(1).toString())) - .body("expenses.size()", equalTo(2)) - .body("expenses.name", hasItems("Rent", "Groceries")) - .body("expenses.expected", hasItems(500.00F, 200F))); - } - - @Test - @Order(4) - @DisplayName("Step 4: User loads the account pages") - void validateAccount(TestContext context) { - context.accounts() - .own(response -> response - .body("size()", equalTo(1)) - .body("[0].name", equalTo("My checking account"))) - .creditors(response -> response - .body("info.records", equalTo(3)) - .body("content.name", hasItems("Groceries are us", "Guarda"))) - .debtors(response -> response - .body("info.records", equalTo(1)) - .body("content.name", hasItems("Chicko Cointer"))); - } - - @Test - @Order(5) - @DisplayName("Step 4: Update the shopping account with image") - void editShoppingAccount(TestContext context) { - var uploadId = context.upload(RegisterAndAccountsTest.class.getResourceAsStream("/assets/account1.svg")); - - context.accounts() - .locate("Groceries are us", account -> account - .fetch(response -> response - .body("name", equalTo("Groceries are us")) - .body("iconFileCode", nullValue())) - .icon(uploadId) - .fetch(response -> response - .body("name", equalTo("Groceries are us")) - .body("iconFileCode", equalTo(uploadId)))); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/TransactionTest.java b/fintrack-api/src/integration/java/com/jongsoft/finance/TransactionTest.java deleted file mode 100644 index b0eb18da..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/TransactionTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.jongsoft.finance; - -import com.jongsoft.finance.extension.AccountContext; -import com.jongsoft.finance.extension.IntegrationTest; -import com.jongsoft.finance.extension.TestContext; -import com.jongsoft.finance.extension.TransactionContext; -import org.apache.commons.lang3.mutable.MutableLong; -import org.apache.commons.lang3.mutable.MutableObject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.util.List; - -import static org.hamcrest.Matchers.*; - -@IntegrationTest(phase = 2) -@DisplayName("User creates the initial transactions:") -public class TransactionTest { - - @Test - @DisplayName("Book the first salary income") - void firstIncome(TestContext context) { - context.authenticate("sample@e", "Zomer2020"); - - var checkingAccount = new MutableObject(); - var employer = new MutableObject(); - - context.accounts() - .locate("My checking account", checkingAccount::setValue) - .locate("Chicko Cointer", employer::setValue); - - context.transactions() - .create( - checkingAccount.getValue(), - employer.getValue(), - 1000, - "First income", - LocalDate.parse("2020-01-01")) - .list(LocalDate.parse("2020-01-01"), response -> response - .body("info.records", equalTo(1)) - .body("content.amount", hasItem(1000.0f))); - } - - @Test - @DisplayName("Buy groceries several times") - void spendMoney(TestContext context) { - context.authenticate("sample@e", "Zomer2020"); - - var checkingAccount = new MutableObject(); - var groceryStore = new MutableObject(); - - context.accounts() - .locate("My checking account", checkingAccount::setValue) - .locate("Groceries are us", groceryStore::setValue); - - context.transactions() - .create( - checkingAccount.getValue(), - groceryStore.getValue(), - 22.32, - "Groceries", - LocalDate.parse("2020-01-02")) - .create( - checkingAccount.getValue(), - groceryStore.getValue(), - 15.00, - "Groceries", - LocalDate.parse("2020-01-03")) - .create( - checkingAccount.getValue(), - groceryStore.getValue(), - 10.00, - "Groceries", - LocalDate.parse("2020-02-04")) - .list(LocalDate.parse("2020-01-02"), response -> response - .body("info.records", equalTo(1)) - .body("content.amount", hasItem(22.32f))) - .list(LocalDate.parse("2020-01-03"), response -> response - .body("info.records", equalTo(1)) - .body("content.amount", hasItem(15f))); - } - - @Test - void splitTransaction(TestContext context) { - context.authenticate("sample@e", "Zomer2020"); - - var checkingAccount = new MutableObject(); - var shop = new MutableObject(); - var transactionId = new MutableLong(); - - context.accounts() - .locate("My checking account", checkingAccount::setValue) - .locate("Guarda", shop::setValue); - - context.transactions() - .create( - checkingAccount.getValue(), - shop.getValue(), - 25, - "Total spending habit", - LocalDate.parse("2023-01-01")) - .list(LocalDate.parse("2023-01-01"), response -> - transactionId.setValue(response.extract().jsonPath().getLong("content[0].id"))) - .split(shop.getValue().getId(), transactionId.getValue(), List.of( - new TransactionContext.TransactionSplit("First part spending", 10), - new TransactionContext.TransactionSplit("Second part spending", 15))) - .list(LocalDate.parse("2023-01-01"), response -> response - .body("info.records", equalTo(1)) - .body("content.amount", hasItem(25f)) - .body("content[0].split.amount", hasItems(15f, 10f))); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/AccountContext.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/AccountContext.java deleted file mode 100644 index 64d10238..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/AccountContext.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.jongsoft.finance.extension; - -import io.restassured.response.ValidatableResponse; -import io.restassured.specification.RequestSpecification; -import io.restassured.specification.ResponseSpecification; - -import java.util.List; -import java.util.function.Consumer; - -public class AccountContext { - - private final RequestSpecification requestSpecification; - - public class Account { - private final int id; - - private Account(String name) { - this.id = requestSpecification - .get("/accounts/all") - .then() - .statusCode(200) - .extract() - .jsonPath() - .getList("findAll { it.name == '%s' }.id".formatted(name)) - .getFirst(); - } - - public Account fetch(Consumer validator) { - validator.accept(requestSpecification - .pathParam("id", id) - .get("/accounts/{id}") - .then() - .statusCode(200)); - return this; - } - - public Account icon(String uploadId) { - requestSpecification - .body(""" - { - "fileCode": "%s" - }""".formatted(uploadId)) - .pathParam("id", id) - .post("/accounts/{id}/image") - .then() - .statusCode(200); - return this; - } - - public long getId() { - return id; - } - } - - AccountContext(RequestSpecification restAssured) { - this.requestSpecification = restAssured; - } - - public AccountContext create(String name, String description, String type) { - requestSpecification - .body(""" - { - "name": "%s", - "description": "%s", - "currency": "EUR", - "type": "%s" - } - """.formatted(name, description, type)) - .put("/accounts") - .then() - .statusCode(200); - - return this; - } - - public AccountContext own(Consumer validator) { - var response = requestSpecification.get("/accounts/my-own") - .then() - .statusCode(200); - validator.accept(response); - return this; - } - - public AccountContext debtor(String name, String description) { - return create(name, description, "debtor"); - } - - public AccountContext debtors(Consumer validator) { - validator.accept(list(List.of("debtor"))); - return this; - } - - public AccountContext creditor(String name, String description) { - return create(name, description, "creditor"); - } - - public AccountContext creditors(Consumer validator) { - validator.accept(list(List.of("creditor"))); - return this; - } - - public AccountContext locate(String name, Consumer account) { - account.accept(new Account(name)); - return this; - } - - private ResponseSpecification list(List types) { - return requestSpecification - .body(""" - { - "accountTypes": %s - } - """.formatted(types)) - .then() - .statusCode(200); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/BudgetContext.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/BudgetContext.java deleted file mode 100644 index 6f6422b6..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/BudgetContext.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.jongsoft.finance.extension; - -import io.restassured.response.ValidatableResponse; -import io.restassured.specification.RequestSpecification; - -import java.time.LocalDate; -import java.util.function.Consumer; -import java.util.function.Supplier; - -import static org.hamcrest.Matchers.equalTo; - -public class BudgetContext { - private final Supplier requestSpecification; - - BudgetContext(Supplier requestSpecification) { - this.requestSpecification = requestSpecification; - } - - public BudgetContext create(int year, int month, double income) { - requestSpecification.get() - .body(""" - { - "year": %d, - "month": %d, - "income": %.2f - } - """.formatted(year, month, income)) - .when() - .put("/budgets") - .then() - .statusCode(201); - return this; - } - - public BudgetContext updateIncome(double amount) { - var now = LocalDate.now(); - - requestSpecification.get() - .body(""" - { - "year": %d, - "month": %d, - "income": %.2f - } - """.formatted(now.getYear(), now.getMonthValue(), amount)) - .when() - .patch("/budgets") - .then() - .statusCode(200); - - return this; - } - - public BudgetContext createExpense(String name, double amount) { - requestSpecification.get() - .body(""" - { - "name": "%s", - "amount": %.2f - } - """.formatted(name, amount)) - .when() - .patch("/budgets/expenses") - .then() - .statusCode(200); - return this; - } - - public BudgetContext updateExpense(String name, double amount) { - var now = LocalDate.now(); - var expenseId = requestSpecification - .get() - .pathParam("year", now.getYear()) - .pathParam("month", now.getMonthValue()) - .when() - .get("/budgets/{year}/{month}") - .then() - .statusCode(200) - .extract() - .body() - .jsonPath() - .getLong("expenses.find { it.name == '%s' }.id".formatted(name)); - - requestSpecification.get() - .body(""" - { - "name": "%s", - "amount": %.2f, - "expenseId": %d - } - """.formatted(name, amount, expenseId)) - .when() - .patch("/budgets/expenses") - .then() - .statusCode(200); - return this; - } - - public BudgetContext validateBudget(int year, int month, Consumer validator) { - var response = requestSpecification - .get() - .pathParam("year", year) - .pathParam("month", month) - .when() - .get("/budgets/{year}/{month}") - .then() - .statusCode(200) - .body("period.from", equalTo("%s-%02d-01".formatted(year, month))); - validator.accept(response); - return this; - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTest.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTest.java deleted file mode 100644 index 15fc9f76..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.jongsoft.finance.extension; - -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.TestMethodOrder; -import org.junit.jupiter.api.extension.ExtendWith; - -import java.lang.annotation.*; - -@Inherited -@Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE}) -@ExtendWith({IntegrationTestExtension.class}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public @interface IntegrationTest { - - int phase(); - -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestExtension.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestExtension.java deleted file mode 100644 index 53a1fd02..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestExtension.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.jongsoft.finance.extension; - -import com.jongsoft.lang.Control; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.ApplicationContextLifeCycle; -import io.micronaut.runtime.EmbeddedApplication; -import io.micronaut.runtime.server.EmbeddedServer; -import org.junit.jupiter.api.extension.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class IntegrationTestExtension implements ParameterResolver, BeforeAllCallback, AfterAllCallback { - - private final static Logger log = LoggerFactory.getLogger(IntegrationTestExtension.class); - - private ApplicationContext applicationContext; - private TestContext testContext; - - @Override - public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return parameterContext.getParameter() - .getType() - .isAssignableFrom(TestContext.class); - } - - @Override - public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return testContext; - } - - @Override - public void beforeAll(ExtensionContext context) { - System.setProperty("datasources.default.url", "jdbc:h2:mem:pledger_io;DB_CLOSE_DELAY=50;MODE=MariaDB"); - applicationContext = ApplicationContext.run("h2"); - Control.Option(applicationContext.getBean(EmbeddedApplication.class)) - .ifPresent(ApplicationContextLifeCycle::start); - Control.Option(applicationContext.getBean(EmbeddedServer.class)) - .ifPresent(server -> { - log.info("Server started on {}:{}", server.getHost(), server.getPort()); - testContext = new TestContext(new TestContext.Server( - server.getScheme() + "://" + server.getHost(), - server.getPort() - ), applicationContext); - }); - } - - @Override - public void afterAll(ExtensionContext context) { - applicationContext.close(); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestOrder.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestOrder.java deleted file mode 100644 index edc05619..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestOrder.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.jongsoft.finance.extension; - -import org.junit.jupiter.api.ClassOrderer; -import org.junit.jupiter.api.ClassOrdererContext; - -public class IntegrationTestOrder implements ClassOrderer { - @Override - public void orderClasses(ClassOrdererContext context) { - context.getClassDescriptors() - .sort((a, b) -> { - var aPhase = a.getTestClass().getAnnotation(IntegrationTest.class).phase(); - var bPhase = b.getTestClass().getAnnotation(IntegrationTest.class).phase(); - return Integer.compare(aPhase, bPhase); - }); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/ProfileContext.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/ProfileContext.java deleted file mode 100644 index 05196134..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/ProfileContext.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.jongsoft.finance.extension; - -import io.micronaut.http.MediaType; -import io.restassured.response.ValidatableResponse; -import io.restassured.specification.RequestSpecification; -import org.apache.http.HttpStatus; - -import java.util.function.Consumer; -import java.util.function.Supplier; - -public class ProfileContext { - private final Supplier requestSpecification; - - public ProfileContext(Supplier requestSpecification) { - this.requestSpecification = requestSpecification; - } - - public ProfileContext get(Consumer validator) { - var response = requestSpecification.get() - .when() - .get("/profile") - .then() - .statusCode(HttpStatus.SC_OK); - - validator.accept(response); - - return this; - } - - public ProfileContext qrCode() { - requestSpecification.get() - .when() - .accept(MediaType.IMAGE_PNG) - .get("/profile/multi-factor/qr-code") - .then() - .statusCode(HttpStatus.SC_OK) - .contentType(MediaType.IMAGE_PNG); - - return this; - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/TestContext.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/TestContext.java deleted file mode 100644 index 03b7fcd6..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/TestContext.java +++ /dev/null @@ -1,186 +0,0 @@ -package com.jongsoft.finance.extension; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.jongsoft.finance.domain.user.UserAccount; -import com.jongsoft.finance.domain.user.UserIdentifier; -import com.jongsoft.finance.providers.UserProvider; -import com.jongsoft.lang.Control; -import dev.samstevens.totp.code.DefaultCodeGenerator; -import dev.samstevens.totp.time.SystemTimeProvider; -import io.micronaut.context.ApplicationContext; -import io.restassured.RestAssured; -import io.restassured.builder.RequestSpecBuilder; -import io.restassured.config.ObjectMapperConfig; -import io.restassured.specification.RequestSpecification; -import org.apache.http.HttpStatus; -import org.assertj.core.api.Assertions; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.InputStream; -import java.time.LocalDate; - -public class TestContext { - - private final static Logger log = LoggerFactory.getLogger(TestContext.class); - - public record Server(String baseUri, int port) { - } - - private String authenticatedWith; - private String authenticationToken; - private final ApplicationContext applicationContext; - - public TestContext(Server server, ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - RestAssured.requestSpecification = new RequestSpecBuilder() - .setBaseUri(server.baseUri) - .setPort(server.port) - .setBasePath("/api") - .setContentType("application/json") - .setAccept("application/json") - .setConfig(RestAssured.config() - .objectMapperConfig( - ObjectMapperConfig.objectMapperConfig() - .jackson2ObjectMapperFactory( - (type, s) -> { - var mapper = new ObjectMapper(); - var module = new SimpleModule(); - module.addSerializer(LocalDate.class, new JsonSerializer<>() { - @Override - public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeString(value.toString()); - } - }); - mapper.registerModule(module); - mapper.setVisibility(mapper.getVisibilityChecker() - .withFieldVisibility(JsonAutoDetect.Visibility.ANY)); - return mapper; - } - )) - .logConfig(RestAssured.config().getLogConfig() - .enableLoggingOfRequestAndResponseIfValidationFails())) - .build(); - } - - public TestContext register(String username, String password) { - log.info("Creating account for user: {}", username); - RestAssured.given() - .body(""" - { - "username": "%s", - "password": "%s" - }""".formatted(username, password)) - .put("/security/create-account") - .then() - .statusCode(201); - return this; - } - - public TestContext authenticate(String username, String password) { - log.info("Authenticating user: {}", username); - var jsonPath = RestAssured.given() - .body(""" - { - "username": "%s", - "password": "%s" - }""".formatted(username, password)) - .post("/security/authenticate") - .then() - .statusCode(200) - .extract() - .body() - .jsonPath(); - - authenticationToken = jsonPath.getString("access_token"); - authenticatedWith = username; - return this; - } - - public TestContext multiFactor() { - authenticationToken = authRequest() - .given() - .contentType("application/json") - .body(""" - { - "verificationCode": "%s" - }""".formatted(generateToken())) - .when() - .post("/security/2-factor") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .jsonPath() - .getString("access_token"); - - return this; - } - - public TestContext enableMFA() { - authRequest() - .given() - .contentType("application/json") - .body(""" - { - "verificationCode": "%s" - }""".formatted(generateToken())) - .when() - .post("/profile/multi-factor/enable") - .then() - .statusCode(HttpStatus.SC_NO_CONTENT); - - return this; - } - - public ProfileContext profile() { - return new ProfileContext(this::authRequest); - } - - public AccountContext accounts() { - return new AccountContext(authRequest()); - } - - public TransactionContext transactions() { - return new TransactionContext(this::authRequest); - } - - public BudgetContext budgets() { - return new BudgetContext(this::authRequest); - } - - public String upload(InputStream inputStream) { - return authRequest() - .contentType("multipart/form-data") - .multiPart("upload", "account1.svg", inputStream) - .post("/attachment") - .then() - .statusCode(201) - .extract() - .body() - .jsonPath() - .getString("fileCode"); - } - - public RequestSpecification authRequest() { - return RestAssured.given() - .header("Authorization", "Bearer " + authenticationToken); - } - - private String generateToken() { - var optionalUser = applicationContext.getBean(UserProvider.class) - .lookup(new UserIdentifier(authenticatedWith)); - Assertions.assertThat(optionalUser.isPresent()).isTrue(); - - return optionalUser.map(UserAccount::getSecret) - .map(secret -> Control.Try(() -> new DefaultCodeGenerator() - .generate(secret, Math.floorDiv(new SystemTimeProvider().getTime(), 30))) - .get()) - .get(); - } -} diff --git a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/TransactionContext.java b/fintrack-api/src/integration/java/com/jongsoft/finance/extension/TransactionContext.java deleted file mode 100644 index fd440eb7..00000000 --- a/fintrack-api/src/integration/java/com/jongsoft/finance/extension/TransactionContext.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.jongsoft.finance.extension; - -import io.restassured.response.ValidatableResponse; -import io.restassured.specification.RequestSpecification; -import org.apache.http.HttpStatus; - -import java.time.LocalDate; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -public class TransactionContext { - - public record TransactionSplit(String description, double amount) { - } - - private final Supplier requestSpecification; - - public TransactionContext(Supplier requestSpecification) { - this.requestSpecification = requestSpecification; - } - - public TransactionContext create( - AccountContext.Account from, - AccountContext.Account to, - double amount, - String description, - LocalDate date) { - requestSpecification - .get() - .body(""" - { - "amount": %f, - "description": "%s", - "date": "%s", - "currency": "EUR", - "source": { - "id": %d - }, - "destination": { - "id": %d - } - } - """.formatted(amount, description, date, from.getId(), to.getId())) - .when() - .pathParam("id", from.getId()) - .put("/accounts/{id}/transactions") - .then() - .statusCode(HttpStatus.SC_NO_CONTENT); - return this; - } - - public TransactionContext split( - Long accountId, - Long transactionId, - List splits) { - requestSpecification - .get() - .body(""" - { - "splits": [%s] - }""".formatted( - splits.stream() - .map(split -> """ - { - "description": "%s", - "amount": %.2f - }""".formatted(split.description(), split.amount())) - .collect(Collectors.joining(", ")))) - .when() - .pathParam("id", accountId) - .pathParam("transactionId", transactionId) - .patch("/accounts/{id}/transactions/{transactionId}") - .then() - .statusCode(HttpStatus.SC_OK); - return this; - } - - public TransactionContext list(LocalDate onDate, Consumer validator) { - var response = requestSpecification - .get() - .body(""" - { - "dateRange": { - "start": "%s", - "end": "%s" - }, - "page": 0 - }""".formatted(onDate, onDate.plusDays(1))) - .when() - .post("/transactions") - .then() - .statusCode(HttpStatus.SC_OK); - validator.accept(response); - return this; - } -} diff --git a/fintrack-api/src/integration/resources/application-test.yml b/fintrack-api/src/integration/resources/application-test.yml deleted file mode 100644 index d0ce2586..00000000 --- a/fintrack-api/src/integration/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -micronaut: - application: - storage: - location: ./build/resources/test - server: - log-handled-exceptions: true diff --git a/fintrack-api/src/integration/resources/assets/account1.svg b/fintrack-api/src/integration/resources/assets/account1.svg deleted file mode 100644 index 46b51cdd..00000000 --- a/fintrack-api/src/integration/resources/assets/account1.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/fintrack-api/src/integration/resources/junit-platform.properties b/fintrack-api/src/integration/resources/junit-platform.properties deleted file mode 100644 index b017c9e0..00000000 --- a/fintrack-api/src/integration/resources/junit-platform.properties +++ /dev/null @@ -1 +0,0 @@ -junit.jupiter.testclass.order.default=com.jongsoft.finance.extension.IntegrationTestOrder \ No newline at end of file diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/Application.java b/fintrack-api/src/main/java/com/jongsoft/finance/Application.java deleted file mode 100644 index 94e6261e..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/Application.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.jongsoft.finance; - -import static com.jongsoft.finance.rest.ApiConstants.*; - -import io.micronaut.runtime.Micronaut; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; -import io.swagger.v3.oas.annotations.info.Contact; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.info.License; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.security.SecurityScheme; -import io.swagger.v3.oas.annotations.tags.Tag; - -@OpenAPIDefinition( - info = - @Info( - title = "Pledger", - version = "2.0.0", - description = "Pledger.io is a self-hosted personal finance application that" - + " helps you track your income and expenses.", - license = @License(name = "MIT", url = "https://opensource.org/licenses/MIT"), - contact = - @Contact( - name = "Jong Soft Development", - url = "https://github.com/pledger-io/rest-application")), - security = @SecurityRequirement(name = "bearer"), - tags = { - @Tag( - name = TAG_REACT_APP, - description = "All methods reserved to fetch the React App embedded in the API."), - @Tag( - name = TAG_ACCOUNTS, - description = "API access to fetch accounts of a user in Pledger.io"), - @Tag( - name = TAG_ACCOUNTS_TRANSACTIONS, - description = - "API access to fetch transactions for any accounts of a user in" + " Pledger.io"), - @Tag( - name = TAG_BUDGETS, - description = "API to get access to budget information for the user."), - @Tag( - name = TAG_CATEGORIES, - description = "API to get access to category information for the user."), - @Tag( - name = TAG_CONTRACTS, - description = "API to get access to contract information for the user."), - @Tag( - name = TAG_ATTACHMENTS, - description = "API for managing file attachments and documents related to" - + " transactions and accounts."), - @Tag( - name = TAG_REPORTS, - description = "API for generating and retrieving financial reports and analytics."), - @Tag( - name = TAG_TRANSACTION, - description = - "API for creating, updating, and managing individual financial" + " transactions."), - @Tag( - name = TAG_TRANSACTION_IMPORT, - description = - "API for importing transactions from external sources and file" + " formats."), - @Tag( - name = TAG_TRANSACTION_ANALYTICS, - description = - "API for retrieving analytical data and insights about transaction" + " patterns."), - @Tag( - name = TAG_TRANSACTION_TAGGING, - description = "API for managing tags and labels associated with transactions for" - + " better organization."), - @Tag( - name = TAG_AUTOMATION_RULES, - description = "API for defining and managing rules that automate transaction" - + " processing and categorization."), - @Tag( - name = TAG_AUTOMATION_PROCESSES, - description = "API for managing automated business processes and workflows related to" - + " financial data."), - @Tag( - name = TAG_SETTINGS, - description = "API for configuring general application settings and preferences."), - @Tag( - name = TAG_SETTINGS_CURRENCIES, - description = "API for managing currency settings, exchange rates, and" - + " currency-related configurations."), - @Tag( - name = TAG_SETTINGS_LOCALIZATION, - description = "API for configuring language, region, and format preferences."), - @Tag( - name = TAG_SECURITY, - description = - "API for managing security settings, permissions, and access" + " controls."), - @Tag( - name = TAG_SECURITY_USERS, - description = "API for user management, including creation, updates, and permission" - + " assignments."), - }) -@SecurityScheme( - name = "bearer", - type = SecuritySchemeType.HTTP, - bearerFormat = "JWT", - scheme = "bearer") -public class Application { - - public static void main(String[] args) { - Micronaut.run(Application.class, args); - System.exit(0); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/DiskStorageService.java b/fintrack-api/src/main/java/com/jongsoft/finance/DiskStorageService.java deleted file mode 100644 index 5ad68e6f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/DiskStorageService.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.jongsoft.finance; - -import com.jongsoft.finance.annotation.BusinessEventListener; -import com.jongsoft.finance.configuration.SecuritySettings; -import com.jongsoft.finance.configuration.StorageSettings; -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.messaging.commands.storage.ReplaceFileCommand; -import com.jongsoft.finance.security.CurrentUserProvider; -import com.jongsoft.finance.security.Encryption; -import com.jongsoft.lang.Control; -import com.jongsoft.lang.control.Optional; -import jakarta.inject.Named; -import jakarta.inject.Singleton; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.UUID; - -@Singleton -@Named("storageService") -public class DiskStorageService implements StorageService { - - private final SecuritySettings securitySettings; - private final CurrentUserProvider currentUserProvider; - private final Path uploadRootDirectory; - private final Encryption encryption; - - public DiskStorageService( - SecuritySettings securitySettings, - CurrentUserProvider currentUserProvider, - StorageSettings storageLocation) { - this.securitySettings = securitySettings; - this.currentUserProvider = currentUserProvider; - this.encryption = new Encryption(); - - uploadRootDirectory = Path.of(storageLocation.getLocation(), "upload"); - if (Files.notExists(uploadRootDirectory)) { - Control.Try(() -> Files.createDirectory(uploadRootDirectory)); - } - } - - @Override - public String store(byte[] content) { - var token = UUID.randomUUID().toString(); - - byte[] toStore; - if (securitySettings.isEncrypt()) { - toStore = encryption.encrypt(content, currentUserProvider.currentUser().getSecret()); - } else { - toStore = content; - } - - try { - Files.write( - uploadRootDirectory.resolve(token), - toStore, - StandardOpenOption.WRITE, - StandardOpenOption.CREATE_NEW); - return token; - } catch (IOException e) { - return null; - } - } - - @Override - public Optional read(String token) { - try { - var readResult = Files.readAllBytes(uploadRootDirectory.resolve(token)); - - if (securitySettings.isEncrypt()) { - readResult = - encryption.decrypt(readResult, currentUserProvider.currentUser().getSecret()); - } - - return Control.Option(readResult); - } catch (IOException e) { - throw StatusException.notFound("Cannot locate content for token " + token); - } catch (IllegalStateException e) { - throw StatusException.notAuthorized("Cannot access file with token " + token); - } - } - - @Override - public void remove(String token) { - try { - Files.deleteIfExists(uploadRootDirectory.resolve(token)); - } catch (IOException e) { - throw new IllegalStateException("Cannot locate content for token " + token); - } - } - - @BusinessEventListener - public void onStorageChangeEvent(ReplaceFileCommand event) { - if (event.oldFileCode() != null) { - this.remove(event.oldFileCode()); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/factory/ApplicationFactory.java b/fintrack-api/src/main/java/com/jongsoft/finance/factory/ApplicationFactory.java deleted file mode 100644 index eacf6c84..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/factory/ApplicationFactory.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.jongsoft.finance.factory; - -import com.jongsoft.finance.core.Encoder; -import com.jongsoft.finance.domain.FinTrack; -import io.micronaut.context.annotation.Context; -import io.micronaut.context.annotation.Factory; - -@Factory -public class ApplicationFactory { - - @Context - public FinTrack createApplicationDomain(Encoder hashingAlgorithm) { - return new FinTrack(hashingAlgorithm); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/factory/MailDaemonFactory.java b/fintrack-api/src/main/java/com/jongsoft/finance/factory/MailDaemonFactory.java deleted file mode 100644 index 88116616..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/factory/MailDaemonFactory.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.jongsoft.finance.factory; - -import com.jongsoft.finance.core.MailDaemon; -import io.micronaut.context.annotation.*; -import io.micronaut.email.BodyType; -import io.micronaut.email.Email; -import io.micronaut.email.EmailSender; -import io.micronaut.email.MultipartBody; -import io.micronaut.email.template.TemplateBody; -import io.micronaut.views.ModelAndView; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Factory -public class MailDaemonFactory { - private final Logger log = LoggerFactory.getLogger(MailDaemonFactory.class); - - @Context - @Requirements({ - @Requires(property = "application.mail", notEquals = "mock"), - }) - @Replaces(MailDaemon.class) - public MailDaemon createMailDaemon( - @Value("${application.mail}") String mailImplementation, EmailSender customMailer) { - log.info("Starting a real mail daemon using {}", mailImplementation); - return (recipient, template, mailProperties) -> { - log.debug("Sending email to {}", recipient); - - var email = Email.builder() - .to(recipient) - .subject("Pleger.io: Welcome to the family!") - .body(new MultipartBody( - new TemplateBody<>( - BodyType.HTML, new ModelAndView<>(template + ".html", mailProperties)), - new TemplateBody<>( - BodyType.TEXT, new ModelAndView<>(template + ".text", mailProperties)))); - - customMailer.send(email); - }; - } - - @Context - public MailDaemon createMailDaemon() { - log.info("Starting a mock mail daemon"); - return (recipient, template, mailProperties) -> log.info("Sending email to {}", recipient); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/factory/MessageSourceFactory.java b/fintrack-api/src/main/java/com/jongsoft/finance/factory/MessageSourceFactory.java deleted file mode 100644 index 410f747c..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/factory/MessageSourceFactory.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.jongsoft.finance.factory; - -import io.micronaut.context.MessageSource; -import io.micronaut.context.annotation.Bean; -import io.micronaut.context.annotation.Factory; -import io.micronaut.context.i18n.ResourceBundleMessageSource; -import io.micronaut.runtime.context.CompositeMessageSource; -import java.util.List; - -@Factory -public class MessageSourceFactory { - - @Bean - public MessageSource messageSource() { - var messagesBundle = new ResourceBundleMessageSource("i18n.messages"); - var validationBundle = new ResourceBundleMessageSource("i18n.ValidationMessages"); - - return new CompositeMessageSource(List.of(messagesBundle, validationBundle)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/factory/SmtpMailFactory.java b/fintrack-api/src/main/java/com/jongsoft/finance/factory/SmtpMailFactory.java deleted file mode 100644 index 5d68c59d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/factory/SmtpMailFactory.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.jongsoft.finance.factory; - -import io.micronaut.context.annotation.*; -import io.micronaut.email.TransactionalEmailSender; -import io.micronaut.email.javamail.sender.JavaxEmailComposer; -import io.micronaut.email.javamail.sender.JavaxEmailSender; -import io.micronaut.scheduling.TaskExecutors; -import jakarta.inject.Named; -import java.util.concurrent.ExecutorService; - -@Factory -@Replaces(TransactionalEmailSender.class) -@Requirements(@Requires(env = "smtp")) -public class SmtpMailFactory { - - @Context - @Primary - public TransactionalEmailSender createTransactionSender( - @Named(TaskExecutors.IO) ExecutorService executorService, - JavaxEmailComposer javaxEmailComposer) { - return new JavaxEmailSender(executorService, javaxEmailComposer); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFailureHandler.java b/fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFailureHandler.java deleted file mode 100644 index 2c286410..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFailureHandler.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.jongsoft.finance.filter; - -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.Produces; -import io.micronaut.http.hateoas.JsonError; -import io.micronaut.http.server.exceptions.ExceptionHandler; -import io.micronaut.security.authentication.Authentication; -import io.micronaut.security.authentication.AuthorizationException; -import io.micronaut.security.authentication.DefaultAuthorizationExceptionHandler; -import jakarta.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Produces -@Singleton -@Replaces(DefaultAuthorizationExceptionHandler.class) -public class AuthenticationFailureHandler - implements ExceptionHandler> { - - private final Logger log = LoggerFactory.getLogger(AuthenticationFailureHandler.class); - - @Override - public HttpResponse handle(HttpRequest request, AuthorizationException exception) { - if (exception.isForbidden()) { - log.warn( - "{}: {} - User {} does not have access based upon the roles {}.", - request.getMethod(), - request.getPath(), - exception - .getAuthentication() - .getAttributes() - .getOrDefault("email", exception.getAuthentication().getName()), - exception.getAuthentication().getRoles()); - - return HttpResponse.status(HttpStatus.FORBIDDEN) - .body(new JsonError("User does not have access based upon the roles")); - } - - log.warn( - "{}: {} - User {} is not authenticated.", - request.getMethod(), - request.getPath(), - Control.Option(exception.getAuthentication()) - .map(Authentication::getName) - .getOrSupply(() -> "Unknown")); - - return HttpResponse.unauthorized(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFilter.java b/fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFilter.java deleted file mode 100644 index b6a01ac9..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFilter.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.jongsoft.finance.filter; - -import com.jongsoft.finance.domain.FinTrack; -import com.jongsoft.finance.messaging.InternalAuthenticationEvent; -import com.jongsoft.lang.Control; -import io.micronaut.context.event.ApplicationEventPublisher; -import io.micronaut.core.async.publisher.Publishers; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.MutableHttpResponse; -import io.micronaut.http.annotation.Filter; -import io.micronaut.http.filter.HttpServerFilter; -import io.micronaut.http.filter.ServerFilterChain; -import io.micronaut.http.filter.ServerFilterPhase; -import io.micronaut.security.authentication.ServerAuthentication; -import io.micronaut.serde.ObjectMapper; -import java.security.Principal; -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.UUID; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.MDC; - -@Filter("/**") -public class AuthenticationFilter implements HttpServerFilter { - - private final Logger log = LoggerFactory.getLogger(AuthenticationFilter.class); - - private final ApplicationEventPublisher eventPublisher; - private final ObjectMapper objectMapper; - private final FinTrack finTrack; - - public AuthenticationFilter( - ApplicationEventPublisher eventPublisher, - ObjectMapper objectMapper, - FinTrack finTrack) { - this.eventPublisher = eventPublisher; - this.objectMapper = objectMapper; - this.finTrack = finTrack; - } - - @Override - public int getOrder() { - return ServerFilterPhase.SECURITY.after(); - } - - @Override - public Publisher> doFilter( - final HttpRequest request, final ServerFilterChain chain) { - request.getUserPrincipal().ifPresent(this::handleAuthentication); - - var startTime = Instant.now(); - MDC.put("correlationId", UUID.randomUUID().toString()); - return Publishers.then(chain.proceed(request), response -> { - if (request.getPath().contains("/api/localization/")) { - log.trace("{}: {}", request.getMethod(), request.getPath()); - } else { - if (log.isTraceEnabled() && request.getBody().isPresent()) { - Object body = request.getBody().get(); - log.atTrace() - .addArgument(request::getMethod) - .addArgument(request::getPath) - .addArgument(() -> Duration.between(startTime, Instant.now()).toMillis()) - .addArgument(() -> Control.Try(() -> objectMapper.writeValueAsString(body)) - .recover(Throwable::getMessage) - .get()) - .log("{}: {} in {} ms, with request body {}."); - } else { - log.info( - "{}: {} in {} ms - Status Code {}.", - request.getMethod(), - request.getPath(), - Duration.between(startTime, Instant.now()).toMillis(), - response.status()); - } - } - MDC.remove("correlationId"); - }); - } - - private void handleAuthentication(Principal principal) { - var userName = principal.getName(); - if (principal instanceof ServerAuthentication authentication) { - var hasEmail = authentication.getAttributes().containsKey("email"); - if (hasEmail) { - userName = authentication.getAttributes().get("email").toString(); - finTrack.createOathUser( - userName, principal.getName(), List.copyOf(authentication.getRoles())); - } - } - log.trace("Authenticated user {}", userName); - eventPublisher.publishEvent(new InternalAuthenticationEvent(this, userName)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/filter/CurrencyHeaderFilter.java b/fintrack-api/src/main/java/com/jongsoft/finance/filter/CurrencyHeaderFilter.java deleted file mode 100644 index 3a8eda3a..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/filter/CurrencyHeaderFilter.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.jongsoft.finance.filter; - -import static org.slf4j.LoggerFactory.getLogger; - -import com.jongsoft.finance.providers.CurrencyProvider; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.MutableHttpResponse; -import io.micronaut.http.annotation.Filter; -import io.micronaut.http.filter.HttpServerFilter; -import io.micronaut.http.filter.ServerFilterChain; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; - -@Filter("/**") -public class CurrencyHeaderFilter implements HttpServerFilter { - - private final Logger log = getLogger(CurrencyHeaderFilter.class); - - private final CurrencyProvider currencyProvider; - - public CurrencyHeaderFilter(CurrencyProvider currencyProvider) { - this.currencyProvider = currencyProvider; - } - - @Override - public Publisher> doFilter( - final HttpRequest request, final ServerFilterChain chain) { - var requestedCurrency = request.getHeaders().get("X-Accept-Currency", String.class); - - if (requestedCurrency.isPresent()) { - log.debug("Filtering for currency {}", requestedCurrency.get()); - currencyProvider - .lookup(requestedCurrency.get()) - .ifPresent(curr -> request.setAttribute(RequestAttributes.CURRENCY, curr)); - } else { - request.setAttribute(RequestAttributes.CURRENCY, ""); - } - - return chain.proceed(request); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/filter/LocaleHeaderFilter.java b/fintrack-api/src/main/java/com/jongsoft/finance/filter/LocaleHeaderFilter.java deleted file mode 100644 index 69f12b5b..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/filter/LocaleHeaderFilter.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.jongsoft.finance.filter; - -import io.micronaut.http.HttpRequest; -import io.micronaut.http.MutableHttpResponse; -import io.micronaut.http.annotation.Filter; -import io.micronaut.http.filter.HttpServerFilter; -import io.micronaut.http.filter.ServerFilterChain; -import java.util.Locale; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Filter("/**") -public class LocaleHeaderFilter implements HttpServerFilter { - - private final Logger log = LoggerFactory.getLogger(LocaleHeaderFilter.class); - - @Override - public Publisher> doFilter( - HttpRequest request, ServerFilterChain chain) { - log.debug("Filtering for locale {}", request.getHeaders().get("Accept-Language")); - - request - .getHeaders() - .get("Accept-Language", String.class) - .ifPresent( - s -> request.setAttribute(RequestAttributes.LOCALIZATION, Locale.forLanguageTag(s))); - - return chain.proceed(request); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/filter/RequestAttributes.java b/fintrack-api/src/main/java/com/jongsoft/finance/filter/RequestAttributes.java deleted file mode 100644 index 59b0cacb..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/filter/RequestAttributes.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.jongsoft.finance.filter; - -public class RequestAttributes { - - public static final String LOCALIZATION = "i18n_locale"; - public static final String CURRENCY = "fin_t_curr"; - - private RequestAttributes() { - // purposely left blank - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/filter/StatusExceptionHandler.java b/fintrack-api/src/main/java/com/jongsoft/finance/filter/StatusExceptionHandler.java deleted file mode 100644 index c8eb0ec9..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/filter/StatusExceptionHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.jongsoft.finance.filter; - -import com.jongsoft.finance.core.exception.StatusException; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.Produces; -import io.micronaut.http.hateoas.JsonError; -import io.micronaut.http.hateoas.Link; -import io.micronaut.http.server.exceptions.ExceptionHandler; -import jakarta.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Produces -@Singleton -public class StatusExceptionHandler - implements ExceptionHandler> { - - private final Logger log = LoggerFactory.getLogger(StatusExceptionHandler.class); - - @Override - public HttpResponse handle(HttpRequest request, StatusException exception) { - if (exception.getStatusCode() != 404) { - log.warn( - "{}: {} - Resource requested status {} with message: '{}'", - request.getMethod(), - request.getPath(), - exception.getStatusCode(), - exception.getMessage()); - } else { - log.trace("{}: {} - Resource not found on server.", request.getMethod(), request.getPath()); - } - - var error = new JsonError(exception.getMessage()); - error.link(Link.SELF, Link.of(request.getUri())); - - if (exception.getLocalizationMessage() != null) { - error.link(Link.HELP, exception.getLocalizationMessage()); - } - - return HttpResponse.status(HttpStatus.valueOf(exception.getStatusCode())).body(error); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/jackson/CharSerializer.java b/fintrack-api/src/main/java/com/jongsoft/finance/jackson/CharSerializer.java deleted file mode 100644 index 1476aeda..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/jackson/CharSerializer.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.jongsoft.finance.jackson; - -import io.micronaut.context.annotation.Primary; -import io.micronaut.core.annotation.NonNull; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.type.Argument; -import io.micronaut.serde.Decoder; -import io.micronaut.serde.Encoder; -import io.micronaut.serde.Serde; -import jakarta.inject.Singleton; -import java.io.IOException; - -/** Custom serializer for {@link Character} type. */ -@Primary -@Singleton -public class CharSerializer implements Serde { - @Override - public @Nullable Character deserialize( - @NonNull Decoder decoder, - @NonNull DecoderContext context, - @NonNull Argument type) - throws IOException { - return decoder.decodeString().charAt(0); - } - - @Override - public void serialize( - @NonNull Encoder encoder, - @NonNull EncoderContext context, - @NonNull Argument type, - @NonNull Character value) - throws IOException { - encoder.encodeString(value.toString()); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/jackson/LocalDateSerializer.java b/fintrack-api/src/main/java/com/jongsoft/finance/jackson/LocalDateSerializer.java deleted file mode 100644 index 682bb11d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/jackson/LocalDateSerializer.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.jongsoft.finance.jackson; - -import io.micronaut.context.annotation.Primary; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.type.Argument; -import io.micronaut.serde.Decoder; -import io.micronaut.serde.Encoder; -import io.micronaut.serde.Serde; -import jakarta.inject.Singleton; -import java.io.IOException; -import java.time.LocalDate; - -@Primary -@Singleton -public class LocalDateSerializer implements Serde { - - @Override - public @Nullable LocalDate deserialize( - Decoder decoder, DecoderContext context, Argument type) - throws IOException { - return LocalDate.parse(decoder.decodeString()); - } - - @Override - public void serialize( - Encoder encoder, EncoderContext context, Argument type, LocalDate value) - throws IOException { - encoder.encodeString(value.toString()); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/jackson/LocalDateTimeSerializer.java b/fintrack-api/src/main/java/com/jongsoft/finance/jackson/LocalDateTimeSerializer.java deleted file mode 100644 index 22f567ce..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/jackson/LocalDateTimeSerializer.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.jongsoft.finance.jackson; - -import io.micronaut.context.annotation.Primary; -import io.micronaut.core.annotation.NonNull; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.type.Argument; -import io.micronaut.serde.Decoder; -import io.micronaut.serde.Encoder; -import io.micronaut.serde.Serde; -import jakarta.inject.Singleton; -import java.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -@Primary -@Singleton -public class LocalDateTimeSerializer implements Serde { - - @Override - public @Nullable LocalDateTime deserialize( - @NonNull Decoder decoder, - @NonNull DecoderContext context, - @NonNull Argument type) - throws IOException { - return LocalDateTime.parse(decoder.decodeString()); - } - - @Override - public void serialize( - @NonNull Encoder encoder, - @NonNull EncoderContext context, - @NonNull Argument type, - @NonNull LocalDateTime value) - throws IOException { - encoder.encodeString(value.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/ApiConstants.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/ApiConstants.java deleted file mode 100644 index 7e2329d5..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/ApiConstants.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.jongsoft.finance.rest; - -public class ApiConstants { - public static final String TAG_REACT_APP = "React::App"; - public static final String TAG_ACCOUNTS = "Accounts"; - public static final String TAG_ACCOUNTS_TRANSACTIONS = "Accounts::Transactions"; - public static final String TAG_BUDGETS = "Budgets"; - public static final String TAG_CATEGORIES = "Categories"; - public static final String TAG_CONTRACTS = "Contracts"; - public static final String TAG_ATTACHMENTS = "Attachments"; - - public static final String TAG_TRANSACTION = "Transactions"; - public static final String TAG_TRANSACTION_IMPORT = TAG_TRANSACTION + "::Import"; - public static final String TAG_TRANSACTION_ANALYTICS = TAG_TRANSACTION + "::Analytics"; - public static final String TAG_TRANSACTION_TAGGING = TAG_TRANSACTION + "::Tags"; - - public static final String TAG_REPORTS = "Reports"; - - public static final String TAG_AUTOMATION = "Automation"; - public static final String TAG_AUTOMATION_RULES = TAG_AUTOMATION + "::Rules"; - public static final String TAG_AUTOMATION_PROCESSES = TAG_AUTOMATION + "::Processes"; - - public static final String TAG_SETTINGS = "Settings"; - public static final String TAG_SETTINGS_CURRENCIES = TAG_SETTINGS + "::Currencies"; - public static final String TAG_SETTINGS_LOCALIZATION = TAG_SETTINGS_CURRENCIES + "::Locale"; - - public static final String TAG_SECURITY = "Security"; - public static final String TAG_SECURITY_USERS = TAG_SECURITY + "::Users"; - - private ApiConstants() {} -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/ApiDefaults.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/ApiDefaults.java deleted file mode 100644 index 91727e3e..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/ApiDefaults.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jongsoft.finance.rest; - -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import io.micronaut.http.hateoas.JsonError; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -@Documented -@Retention(RUNTIME) -@Target({ElementType.METHOD, ElementType.TYPE}) -@ApiResponse( - responseCode = "404", - content = @Content(schema = @Schema(implementation = JsonError.class)), - description = "Resource not found") -@ApiResponse( - responseCode = "500", - content = @Content(schema = @Schema(implementation = JsonError.class)), - description = "Internal server error") -@ApiResponse( - responseCode = "401", - content = @Content(schema = @Schema(implementation = JsonError.class)), - description = "The user is not authenticated, login first.") -public @interface ApiDefaults {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/DateFormat.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/DateFormat.java deleted file mode 100644 index 22f29a81..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/DateFormat.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.jongsoft.finance.rest; - -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import io.micronaut.core.convert.format.Format; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; - -@Documented -@Retention(RUNTIME) -@Format("yyyy-MM-dd") -public @interface DateFormat {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/ReactController.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/ReactController.java deleted file mode 100644 index 26d95168..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/ReactController.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.jongsoft.finance.rest; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_REACT_APP; - -import io.micronaut.core.io.ResourceResolver; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.server.types.files.StreamedFile; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -@Controller("/ui") -@Tag(name = TAG_REACT_APP) -@Secured(SecurityRule.IS_ANONYMOUS) -public class ReactController { - - private final ResourceResolver resourceResolver; - - public ReactController(ResourceResolver resourceResolver) { - this.resourceResolver = resourceResolver; - } - - /** - * Serves the React app's index.html file. - * - * @return The index.html file - */ - @Get(produces = MediaType.TEXT_HTML) - public HttpResponse index() { - Optional indexHtml = - resourceResolver.getResourceAsStream("classpath:public/index.html"); - if (indexHtml.isPresent()) { - return HttpResponse.ok(new StreamedFile(indexHtml.get(), MediaType.TEXT_HTML_TYPE)); - } else { - return HttpResponse.notFound("React app not found"); - } - } - - /** - * Catch-all route to serve the React app for any path under /react/. This allows the React - * Router to handle client-side routing properly. - * - * @return The index.html file - */ - @Get(uri = "/{path:.*}", produces = MediaType.TEXT_HTML) - public HttpResponse catchAll(String path) { - return index(); - } - - @Get(uri = "/favicon.ico") - public HttpResponse favicon() { - return loadResource("favicon.ico"); - } - - @Get(uri = "/manifest.json") - public HttpResponse manifest() { - return loadResource("manifest.json"); - } - - @Get(uri = "/logo192.png") - public HttpResponse logo() { - return loadResource("logo192.png"); - } - - @Get(uri = "/logo512.png") - public HttpResponse logo_512() { - return loadResource("logo512.png"); - } - - @Get(uri = "/assets/{path:.*}") - public HttpResponse getAsset(String path) { - return loadResource("assets/" + path); - } - - @Get(uri = "/images/{path:.*}") - public HttpResponse getImage(String path) { - return loadResource("images/" + path); - } - - private HttpResponse loadResource(String path) { - var assetFile = resourceResolver.getResource("classpath:public/" + path); - if (assetFile.isPresent()) { - var streamedFile = new StreamedFile(assetFile.get()); - return HttpResponse.ok(streamedFile.getInputStream()) - .contentType(streamedFile.getMediaType()) - .characterEncoding(StandardCharsets.UTF_8); - } else { - return HttpResponse.notFound("React app not found"); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/StaticResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/StaticResource.java deleted file mode 100644 index be7995c1..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/StaticResource.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.jongsoft.finance.rest; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_REACT_APP; - -import io.micronaut.core.io.ResourceResolver; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.server.types.files.StreamedFile; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Inject; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller -@Tag(name = TAG_REACT_APP) -@Secured(SecurityRule.IS_ANONYMOUS) -public class StaticResource { - private final Logger log = LoggerFactory.getLogger(StaticResource.class); - - @Inject - ResourceResolver resourceResolver; - - @Get - @Operation(hidden = true) - public HttpResponse index() throws URISyntaxException { - return HttpResponse.redirect(new URI("/ui/dashboard")); - } - - @Get("/favicon.ico") - @Operation(hidden = true) - public HttpResponse favicon() { - Optional indexHtml = - resourceResolver.getResourceAsStream("classpath:public/assets/favicon.ico"); - if (indexHtml.isPresent()) { - return HttpResponse.ok(new StreamedFile(indexHtml.get(), MediaType.IMAGE_X_ICON_TYPE)); - } else { - return HttpResponse.notFound("Favicon not found"); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountEditRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountEditRequest.java deleted file mode 100644 index e7ec4421..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountEditRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.finance.schedule.Periodicity; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.*; - -@Serdeable -record AccountEditRequest( - @NotNull @NotBlank @Schema(description = "Account name") String name, - @Schema(description = "Account description") String description, - @NotNull @NotBlank @Schema(description = "Account currency, must exist in the system") - String currency, - @Pattern( - regexp = "^([A-Z]{2}[ \\-]?[0-9]{2})(?=(?:[ \\-]?[A-Z0-9]){9,30}$)((?:[" - + " \\-]?[A-Z0-9]{3,5}){2,7})([ \\-]?[A-Z0-9]{1,3})?$") - @Schema(description = "IBAN number") - String iban, - @Pattern(regexp = "^([a-zA-Z]{4}[a-zA-Z]{2}[a-zA-Z0-9]{2}([a-zA-Z0-9]{3})?)$") - @Schema(description = "The banks BIC number") - String bic, - @Schema(description = "The account number, in case IBAN is not applicable") String number, - @Min(-2) @Max(2) double interest, - Periodicity interestPeriodicity, - @NotNull String type) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountEditResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountEditResource.java deleted file mode 100644 index 27a49f24..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountEditResource.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_ACCOUNTS; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.account.SavingGoal; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.rest.model.AccountResponse; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Positive; -import java.math.BigDecimal; - -@ApiDefaults -@Tag(name = TAG_ACCOUNTS) -@Controller("/api/accounts/{accountId}") -@Secured(SecurityRule.IS_AUTHENTICATED) -public class AccountEditResource { - - private final AccountProvider accountProvider; - - public AccountEditResource(AccountProvider accountProvider) { - this.accountProvider = accountProvider; - } - - @Get - @Operation( - summary = "Get Account", - description = "Attempts to get the account with matching account id. If no account is found" - + " or you are notauthorized an exception will be returned.", - parameters = - @Parameter( - name = "accountId", - description = "The unique account id", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class)), - responses = { - @ApiResponse( - responseCode = "200", - description = "The resulting account", - content = @Content(schema = @Schema(implementation = AccountResponse.class))), - @ApiResponse(responseCode = "401", description = "The account cannot be accessed"), - @ApiResponse(responseCode = "404", description = "No account can be located") - }) - AccountResponse get(@PathVariable long accountId) { - return accountProvider - .lookup(accountId) - .map(AccountResponse::new) - .getOrThrow(() -> StatusException.notFound("Account not found")); - } - - @Post - @Operation( - summary = "Update Account", - description = "Update an existing account with the new details provided in the body. The" - + " updated account will be returned, or if no account is found an" - + " exception.", - parameters = - @Parameter( - name = "accountId", - description = "The unique account id", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class)), - responses = { - @ApiResponse( - responseCode = "200", - description = "The updated account", - content = @Content(schema = @Schema(implementation = AccountResponse.class))), - @ApiResponse(responseCode = "401", description = "The account cannot be accessed"), - @ApiResponse(responseCode = "404", description = "No account can be located") - }) - AccountResponse update( - @PathVariable long accountId, @Valid @Body AccountEditRequest accountEditRequest) { - var account = accountProvider - .lookup(accountId) - .getOrThrow(() -> StatusException.notFound("No account found with id " + accountId)); - - account.rename( - accountEditRequest.name(), - accountEditRequest.description(), - accountEditRequest.currency(), - accountEditRequest.type()); - - account.changeAccount( - accountEditRequest.iban(), accountEditRequest.bic(), accountEditRequest.number()); - - if (accountEditRequest.interestPeriodicity() != null) { - account.interest(accountEditRequest.interest(), accountEditRequest.interestPeriodicity()); - } - - return new AccountResponse(account); - } - - @Post(value = "/image") - @Operation( - summary = "Attach icon", - description = "Attach an icon to the account. If any icon was previously registered it will" - + " be removed from the system.", - parameters = - @Parameter( - name = "accountId", - description = "The unique account id", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - AccountResponse persistImage( - @PathVariable long accountId, @Body @Valid AccountImageRequest imageRequest) { - var accountPromise = accountProvider.lookup(accountId); - - if (accountPromise.isPresent()) { - accountPromise.get().registerIcon(imageRequest.fileCode()); - - return new AccountResponse(accountPromise.get()); - } else { - throw StatusException.notFound("Could not find account"); - } - } - - @Delete - @Operation( - summary = "Delete Account", - parameters = - @Parameter( - name = "accountId", - description = "The unique account id", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class)), - responses = { - @ApiResponse(responseCode = "204", description = "Account successfully deleted"), - @ApiResponse(responseCode = "404", description = "No account can be located") - }) - void delete(@PathVariable long accountId) { - accountProvider - .lookup(accountId) - .ifPresent(Account::terminate) - .elseThrow(() -> StatusException.notFound("No account found for id " + accountId)); - } - - @Post("/savings") - @Operation( - operationId = "addSavingGoal", - summary = "Create saving goal", - description = "Creates a saving goal into the account, only valid for accounts of types" - + " SAVINGS and JOINED_SAVINGS") - AccountResponse createSavingGoal( - @PathVariable long accountId, @Body @Valid AccountSavingGoalCreateRequest request) { - accountProvider - .lookup(accountId) - .ifPresent(account -> - account.createSavingGoal(request.name(), request.goal(), request.targetDate())) - .elseThrow(() -> StatusException.notFound("No account found for id " + accountId)); - - return accountProvider.lookup(accountId).map(AccountResponse::new).get(); - } - - @Post("/savings/{savingId}") - @Operation( - operationId = "updateSavingGoal", - summary = "Adjust Saving Goal", - description = "Adjust a saving goal already attached to the savings account.") - AccountResponse adjustSavingGoal( - @PathVariable long accountId, - @PathVariable long savingId, - @Body @Valid AccountSavingGoalCreateRequest request) { - accountProvider - .lookup(accountId) - .ifPresent(account -> account - .getSavingGoals() - .filter(goal -> goal.getId() == savingId) - .head() - .adjustGoal(request.goal(), request.targetDate())) - .elseThrow(() -> StatusException.notFound("No account found for id " + accountId)); - - return accountProvider.lookup(accountId).map(AccountResponse::new).get(); - } - - @Put("/savings/{savingId}/reserve") - @Operation( - operationId = "reserveForSavingGoal", - summary = "Reserve Saving Goal", - description = "Reserve money from the account towards the saving goal.") - AccountResponse reservationForSavingGoal( - @PathVariable long accountId, - @PathVariable long savingId, - @Valid @QueryValue @Positive double amount) { - accountProvider - .lookup(accountId) - .ifPresent(account -> account - .getSavingGoals() - .filter(goal -> goal.getId() == savingId) - .head() - .registerPayment(BigDecimal.valueOf(amount))) - .elseThrow(() -> StatusException.notFound("No account found for id " + accountId)); - - return accountProvider.lookup(accountId).map(AccountResponse::new).get(); - } - - @Delete("/savings/{savingId}") - @Operation( - operationId = "deleteSavingGoal", - summary = "Delete saving goal", - description = "Removes a saving goal from the account.") - void deleteSavingGoal(@PathVariable long accountId, @PathVariable long savingId) { - accountProvider - .lookup(accountId) - .ifPresent(account -> account - .getSavingGoals() - .filter(goal -> goal.getId() == savingId) - .forEach(SavingGoal::completed)) - .elseThrow(() -> StatusException.notFound("No account found for id " + accountId)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountImageRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountImageRequest.java deleted file mode 100644 index 4107b337..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountImageRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable.Deserializable -record AccountImageRequest( - @Schema(description = "The file code that was returned after the upload") String fileCode) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountResource.java deleted file mode 100644 index cf253245..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountResource.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_ACCOUNTS; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.AccountTypeProvider; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.rest.model.AccountResponse; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import com.jongsoft.lang.Collections; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; - -@ApiDefaults -@Tag(name = TAG_ACCOUNTS) -@Controller("/api/accounts") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class AccountResource { - - private final SettingProvider settingProvider; - private final CurrentUserProvider currentUserProvider; - private final AccountProvider accountProvider; - private final FilterFactory accountFilterFactory; - private final AccountTypeProvider accountTypeProvider; - - public AccountResource( - SettingProvider settingProvider, - CurrentUserProvider currentUserProvider, - AccountProvider accountProvider, - FilterFactory accountFilterFactory, - AccountTypeProvider accountTypeProvider) { - this.settingProvider = settingProvider; - this.currentUserProvider = currentUserProvider; - this.accountProvider = accountProvider; - this.accountFilterFactory = accountFilterFactory; - this.accountTypeProvider = accountTypeProvider; - } - - @Get("/my-own") - @Operation( - summary = "List own accounts", - description = "List all accounts that are creatable in the front-end using one of the" - + " selectable account types", - operationId = "ownAccounts") - List ownAccounts() { - var accounts = accountProvider.lookup( - accountFilterFactory.account().types(accountTypeProvider.lookup(false))); - - return accounts.content().map(AccountResponse::new).toJava(); - } - - @Get("/all") - @Operation( - summary = "List all accounts", - description = "Fetch all accounts registered to the authenticated user", - operationId = "listAllAccounts") - List allAccounts() { - return accountProvider.lookup().map(AccountResponse::new).toJava(); - } - - @Get("/auto-complete{?token,type}") - @Operation( - summary = "Autocomplete accounts", - description = "Performs a search operation based on the partial name (token) of the given" - + " account type", - operationId = "autocomplete", - parameters = { - @Parameter( - name = "token", - description = "A partial search text.", - in = ParameterIn.QUERY, - required = true, - schema = @Schema(implementation = String.class)), - @Parameter( - name = "type", - description = "An account type to limit the search to, see types for available" - + " types.", - example = "credit", - in = ParameterIn.QUERY, - required = true, - schema = @Schema(implementation = String.class)), - }) - List autocomplete(@Nullable String token, @Nullable String type) { - var filter = accountFilterFactory - .account() - .name(token, false) - .page(0, settingProvider.getAutocompleteLimit()); - if (type != null) { - filter.types(Collections.List(type)); - } - - var accounts = accountProvider.lookup(filter); - return accounts.content().map(AccountResponse::new).toJava(); - } - - @Post - @Operation( - summary = "Search accounts", - description = "Search through all accounts using the provided filter set", - operationId = "listAccounts") - ResultPageResponse accounts(@Valid @Body AccountSearchRequest searchRequest) { - var filter = accountFilterFactory - .account() - .page(Math.max(0, searchRequest.page() - 1), settingProvider.getPageSize()) - .types(searchRequest.accountTypes()); - if (searchRequest.name() != null) { - filter.name(searchRequest.name(), false); - } - - var response = accountProvider.lookup(filter).map(AccountResponse::new); - - return new ResultPageResponse<>(response); - } - - @Put - @Operation( - summary = "Create account", - description = "This operation will allow for adding new accounts to the system", - operationId = "createAccount") - AccountResponse create(@Valid @Body AccountEditRequest accountEditRequest) { - var existing = accountProvider.lookup(accountEditRequest.name()); - - if (!existing.isPresent()) { - currentUserProvider - .currentUser() - .createAccount( - accountEditRequest.name(), accountEditRequest.currency(), accountEditRequest.type()); - - return accountProvider - .lookup(accountEditRequest.name()) - .map(inserted -> { - if (accountEditRequest.interestPeriodicity() != null) { - inserted.interest( - accountEditRequest.interest(), accountEditRequest.interestPeriodicity()); - } - - inserted.changeAccount( - accountEditRequest.iban(), accountEditRequest.bic(), accountEditRequest.number()); - - return new AccountResponse(inserted); - }) - .getOrThrow(() -> StatusException.internalError("Failed to create account")); - } - - throw StatusException.badRequest("Account already exists"); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSavingGoalCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSavingGoalCreateRequest.java deleted file mode 100644 index 25c0e9ca..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSavingGoalCreateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import java.math.BigDecimal; -import java.time.LocalDate; - -@Serdeable.Deserializable -public record AccountSavingGoalCreateRequest( - @NotBlank String name, @NotNull @Positive BigDecimal goal, @NotNull LocalDate targetDate) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSearchRequest.java deleted file mode 100644 index ca2927f2..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSearchRequest.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import com.jongsoft.lang.collection.Sequence; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotEmpty; -import java.util.List; - -@Serdeable -public class AccountSearchRequest { - - @NotEmpty - private List accountTypes; - - @Min(0) - private int page; - - private String name; - - public AccountSearchRequest(List accountTypes, int page, String name) { - this.accountTypes = accountTypes; - this.page = page; - this.name = name; - } - - public Sequence accountTypes() { - return Control.Option(accountTypes).map(Collections::List).getOrSupply(Collections::List); - } - - public int page() { - return page; - } - - public String name() { - return name; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSpendingResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSpendingResponse.java deleted file mode 100644 index 337feacf..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountSpendingResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.rest.model.AccountResponse; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class AccountSpendingResponse { - - private final AccountProvider.AccountSpending wrapped; - - public AccountSpendingResponse(AccountProvider.AccountSpending wrapped) { - this.wrapped = wrapped; - } - - public AccountResponse getAccount() { - return new AccountResponse(wrapped.account()); - } - - public double getTotal() { - return wrapped.total(); - } - - public double getAverage() { - return wrapped.average(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTopResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTopResource.java deleted file mode 100644 index faf00447..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTopResource.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_ACCOUNTS; - -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.rest.DateFormat; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Dates; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.LocalDate; -import java.util.List; - -@ApiDefaults -@Tag(name = TAG_ACCOUNTS) -@Controller("/api/accounts/top") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class AccountTopResource { - - private final AccountProvider accountProvider; - private final FilterFactory filterFactory; - private final SettingProvider settingProvider; - - public AccountTopResource( - AccountProvider accountProvider, - FilterFactory filterFactory, - SettingProvider settingProvider) { - this.accountProvider = accountProvider; - this.filterFactory = filterFactory; - this.settingProvider = settingProvider; - } - - @Get("/debit/{start}/{end}") - @Operation( - summary = "Top debit accounts", - description = - "Calculates and returns the accounts where you spent the most for the given date range", - operationId = "listTopDebtors") - List topDebtors( - @PathVariable @DateFormat LocalDate start, @PathVariable @DateFormat LocalDate end) { - var filterCommand = filterFactory - .account() - .types(Collections.List("debtor")) - .page(0, settingProvider.getAutocompleteLimit()); - - return accountProvider - .top(filterCommand, Dates.range(start, end), true) - .map(AccountSpendingResponse::new) - .toJava(); - } - - @Get("/creditor/{start}/{end}") - @Operation( - summary = "Top creditor accounts", - description = - "Calculates and returns the accounts that credited the most money for the given date range", - operationId = "listTopCreditors") - List topCreditor( - @PathVariable @DateFormat LocalDate start, @PathVariable @DateFormat LocalDate end) { - var filterCommand = filterFactory - .account() - .types(Collections.List("creditor")) - .page(0, settingProvider.getAutocompleteLimit()); - - return accountProvider - .top(filterCommand, Dates.range(start, end), false) - .map(AccountSpendingResponse::new) - .toJava(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionCreateRequest.java deleted file mode 100644 index 8470c732..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionCreateRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.time.LocalDate; -import java.util.List; - -@Serdeable.Deserializable -record AccountTransactionCreateRequest( - @NotNull LocalDate date, - LocalDate interestDate, - LocalDate bookDate, - @NotNull @NotBlank String currency, - @NotBlank @Size(max = 1024) String description, - @NotNull double amount, - @NotNull EntityRef source, - @NotNull EntityRef destination, - EntityRef category, - EntityRef budget, - EntityRef contract, - List tags) { - - @Serdeable.Deserializable - record EntityRef(@NotNull Long id, String name) {} -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionResource.java deleted file mode 100644 index 7265b3b1..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionResource.java +++ /dev/null @@ -1,300 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_ACCOUNTS_TRANSACTIONS; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.domain.transaction.SplitRecord; -import com.jongsoft.finance.domain.transaction.Transaction; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.rest.model.TransactionResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import com.jongsoft.lang.Dates; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.function.Consumer; - -@Tag(name = TAG_ACCOUNTS_TRANSACTIONS) -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/accounts/{accountId}/transactions") -public class AccountTransactionResource { - - private final FilterFactory filterFactory; - private final TransactionProvider transactionProvider; - private final AccountProvider accountProvider; - private final SettingProvider settingProvider; - - public AccountTransactionResource( - FilterFactory filterFactory, - TransactionProvider transactionProvider, - AccountProvider accountProvider, - SettingProvider settingProvider) { - this.filterFactory = filterFactory; - this.transactionProvider = transactionProvider; - this.accountProvider = accountProvider; - this.settingProvider = settingProvider; - } - - @Post - @Operation( - summary = "Search transactions", - description = "Search through all transaction in the account using the provided filter", - parameters = - @Parameter( - name = "accountId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - responses = { - @ApiResponse(responseCode = "200", description = "Paged result of transactions"), - @ApiResponse(responseCode = "401", description = "The account cannot be accessed"), - @ApiResponse(responseCode = "404", description = "No account can be located") - }) - ResultPageResponse search( - @PathVariable long accountId, @Valid @Body AccountTransactionSearchRequest request) { - var accountOption = accountProvider.lookup(accountId); - - if (!accountOption.isPresent()) { - throw StatusException.notFound("Account not found with id " + accountId); - } - var command = filterFactory - .transaction() - .accounts(Collections.List(new EntityRef(accountId))) - .range(Dates.range(request.dateRange().start(), request.dateRange().end())) - .page(request.getPage(), Integer.MAX_VALUE); - - if (request.text() != null) { - command.description(request.text(), false); - } - - var results = transactionProvider.lookup(command).map(TransactionResponse::new); - - return new ResultPageResponse<>(results); - } - - @Put - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Create transaction", - description = "Create a new transaction in the provided accounts", - parameters = - @Parameter( - name = "accountId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - responses = { - @ApiResponse( - responseCode = "204", - description = "The transaction", - content = @Content(schema = @Schema(implementation = TransactionResponse.class))), - }) - void create(@Valid @Body AccountTransactionCreateRequest request) { - Account fromAccount = accountProvider.lookup(request.source().id()).get(); - Account toAccount = accountProvider.lookup(request.destination().id()).get(); - - final Consumer builderConsumer = - transactionBuilder -> transactionBuilder - .currency(request.currency()) - .description(request.description()) - .budget(Control.Option(request.budget()) - .map(AccountTransactionCreateRequest.EntityRef::name) - .getOrSupply(() -> null)) - .category(Control.Option(request.category()) - .map(AccountTransactionCreateRequest.EntityRef::name) - .getOrSupply(() -> null)) - .contract(Control.Option(request.contract()) - .map(AccountTransactionCreateRequest.EntityRef::name) - .getOrSupply(() -> null)) - .date(request.date()) - .bookDate(request.bookDate()) - .interestDate(request.interestDate()) - .tags(Control.Option(request.tags()) - .map(Collections::List) - .getOrSupply(Collections::List)); - - final Transaction transaction = fromAccount.createTransaction( - toAccount, request.amount(), determineType(fromAccount, toAccount), builderConsumer); - - transaction.register(); - } - - @Get("/first{?description}") - @Operation( - summary = "Get the first transaction", - description = "Returns the first transaction found for the given account", - parameters = - @Parameter( - name = "accountId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - responses = { - @ApiResponse( - responseCode = "200", - description = "The transaction", - content = @Content(schema = @Schema(implementation = TransactionResponse.class))), - @ApiResponse(responseCode = "404", description = "No transaction found") - }) - TransactionResponse first(@PathVariable Long accountId, @Nullable String description) { - var command = filterFactory.transaction().accounts(Collections.List(new EntityRef(accountId))); - - if (description != null) { - command.description(description, true); - } - - return transactionProvider - .first(command) - .map(TransactionResponse::new) - .getOrThrow(() -> StatusException.notFound("No transactions found")); - } - - @Get("/{transactionId}") - @Operation( - summary = "Get a transaction", - description = "Returns one single transaction identified by the provided id", - parameters = { - @Parameter( - name = "accountId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - @Parameter( - name = "transactionId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "The transaction", - content = @Content(schema = @Schema(implementation = TransactionResponse.class))), - @ApiResponse(responseCode = "401", description = "The transaction cannot be accessed"), - @ApiResponse(responseCode = "404", description = "No account can be located") - }) - TransactionResponse get(@PathVariable long transactionId) { - return transactionProvider - .lookup(transactionId) - .map(TransactionResponse::new) - .getOrThrow(() -> StatusException.notFound("No transaction found for id " + transactionId)); - } - - @Post("/{transactionId}") - @Operation( - summary = "Update a transaction", - description = "Updates a single transaction and returns the updated version", - parameters = { - @Parameter( - name = "accountId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - @Parameter( - name = "transactionId", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH), - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "The transaction", - content = @Content(schema = @Schema(implementation = TransactionResponse.class))), - @ApiResponse(responseCode = "401", description = "The transaction cannot be accessed"), - @ApiResponse(responseCode = "404", description = "Transaction not found") - }) - TransactionResponse update( - @PathVariable long transactionId, @Valid @Body AccountTransactionCreateRequest request) { - var presence = transactionProvider.lookup(transactionId); - if (!presence.isPresent()) { - throw StatusException.notFound("No transaction found for id " + transactionId); - } - var fromAccount = accountProvider.lookup(request.source().id()).get(); - var toAccount = accountProvider.lookup(request.destination().id()).get(); - var transaction = presence.get(); - - transaction.changeAccount(true, fromAccount); - transaction.changeAccount(false, toAccount); - transaction.book(request.date(), request.bookDate(), request.interestDate()); - transaction.describe(request.description()); - - if (!transaction.isSplit()) { - transaction.changeAmount(request.amount(), request.currency()); - } - - // update meta-data - transaction.linkToBudget(Control.Option(request.budget()) - .map(AccountTransactionCreateRequest.EntityRef::name) - .getOrSupply(() -> null)); - transaction.linkToCategory(Control.Option(request.category()) - .map(AccountTransactionCreateRequest.EntityRef::name) - .getOrSupply(() -> null)); - transaction.linkToContract(Control.Option(request.contract()) - .map(AccountTransactionCreateRequest.EntityRef::name) - .getOrSupply(() -> null)); - - Control.Option(request.tags()).map(Collections::List).ifPresent(transaction::tag); - - return new TransactionResponse(transaction); - } - - @Patch("/{transactionId}") - @Operation( - summary = "Split transactions", - description = "Split the transaction into smaller pieces, all belonging to the same actual" - + " transaction.", - parameters = - @Parameter( - name = "transactionId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - TransactionResponse split( - @PathVariable long transactionId, @Valid @Body AccountTransactionSplitRequest request) { - return transactionProvider - .lookup(transactionId) - .map(transaction -> { - var splits = Collections.List(request.getSplits()) - .map(split -> new SplitRecord(split.description(), split.amount())); - transaction.split(splits); - - return new TransactionResponse(transaction); - }) - .getOrThrow(() -> StatusException.notFound("No transaction found for id " + transactionId)); - } - - @Delete("/{transactionId}") - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Delete transaction", - description = "Delete a transaction from the account", - parameters = - @Parameter( - name = "transactionId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - void delete(@PathVariable long transactionId) { - transactionProvider - .lookup(transactionId) - .ifPresent(Transaction::delete) - .elseThrow(() -> StatusException.notFound("No transaction found with id " + transactionId)); - } - - private Transaction.Type determineType(Account fromAccount, Account toAccount) { - if (fromAccount.isManaged() && toAccount.isManaged()) { - return Transaction.Type.TRANSFER; - } - - return Transaction.Type.CREDIT; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionSearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionSearchRequest.java deleted file mode 100644 index 95740fd1..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionSearchRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; - -@Serdeable -record AccountTransactionSearchRequest(String text, @Min(0) int page, @NotNull Range dateRange) { - - @Serdeable - public record Range(LocalDate start, LocalDate end) {} - - public int getPage() { - return Math.max(0, page - 1); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionSplitRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionSplitRequest.java deleted file mode 100644 index 1e2066e6..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionSplitRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.util.List; - -@Serdeable.Deserializable -record AccountTransactionSplitRequest(@NotNull @Size(min = 2) List splits) { - - @Serdeable.Deserializable - public record SplitRecord(String description, double amount) {} - - public List getSplits() { - return splits; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTypeResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTypeResource.java deleted file mode 100644 index 933e5a7f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTypeResource.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_ACCOUNTS; - -import com.jongsoft.finance.providers.AccountTypeProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Singleton; -import java.util.List; - -@ApiDefaults -@Singleton -@Tag(name = TAG_ACCOUNTS) -@Controller("/api/account-types") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class AccountTypeResource { - - private final AccountTypeProvider accountTypeProvider; - - public AccountTypeResource(AccountTypeProvider accountTypeProvider) { - this.accountTypeProvider = accountTypeProvider; - } - - @Get - @Operation( - summary = "List types", - description = "Get a listing of all available account types in the system.", - operationId = "listTypes") - List list() { - return accountTypeProvider.lookup(false).toJava(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetCreateRequest.java deleted file mode 100644 index 5b097014..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetCreateRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.time.LocalDate; - -@Serdeable.Deserializable -record BudgetCreateRequest(@Min(1900) int year, @Max(12) @Min(1) int month, @Min(0) double income) { - - public LocalDate getStart() { - return LocalDate.of(year, month, 1); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetResource.java deleted file mode 100644 index 88188259..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetResource.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_BUDGETS; - -import com.jongsoft.finance.core.DateUtils; -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.domain.user.Budget; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.BudgetProvider; -import com.jongsoft.finance.providers.ExpenseProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.rest.model.BudgetResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import com.jongsoft.lang.Collections; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.http.hateoas.JsonError; -import io.micronaut.security.annotation.Secured; -import io.micronaut.validation.Validated; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.List; -import java.util.Objects; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Tag(name = TAG_BUDGETS) -@Controller("/api/budgets") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class BudgetResource { - - private final Logger log = LoggerFactory.getLogger(BudgetResource.class); - - private final BudgetProvider budgetProvider; - private final ExpenseProvider expenseProvider; - private final FilterFactory filterFactory; - - private final CurrentUserProvider currentUserProvider; - private final TransactionProvider transactionProvider; - - public BudgetResource( - BudgetProvider budgetProvider, - ExpenseProvider expenseProvider, - FilterFactory filterFactory, - CurrentUserProvider currentUserProvider, - TransactionProvider transactionProvider) { - this.budgetProvider = budgetProvider; - this.expenseProvider = expenseProvider; - this.filterFactory = filterFactory; - this.currentUserProvider = currentUserProvider; - this.transactionProvider = transactionProvider; - } - - @Get("/current") - @Operation(summary = "Current month", description = "Get the budget for the current month.") - @ApiDefaults - BudgetResponse currentMonth() { - return budgetProvider - .lookup(LocalDate.now().getYear(), LocalDate.now().getMonthValue()) - .map(BudgetResponse::new) - .getOrThrow(() -> StatusException.notFound("Budget not found for current month.")); - } - - @Get("/{year}/{month}") - @Operation( - summary = "Get any month", - description = "Get the budget for the given year and month combination.") - @ApiDefaults - BudgetResponse givenMonth(@PathVariable int year, @PathVariable int month) { - return budgetProvider - .lookup(year, month) - .map(BudgetResponse::new) - .getOrThrow(() -> StatusException.notFound("Budget not found for month.")); - } - - @Get("/auto-complete{?token}") - @Operation( - summary = "Lookup expense", - description = "Search for expenses that match the provided token", - parameters = - @Parameter( - name = "token", - in = ParameterIn.QUERY, - schema = @Schema(implementation = String.class))) - List autocomplete(@Nullable String token) { - return expenseProvider - .lookup(filterFactory.expense().name(token, false)) - .content() - .toJava(); - } - - @Get - @Operation( - summary = "First budget start", - description = "Computes the date of the start of the first budget registered in FinTrack") - LocalDate firstBudget() { - return budgetProvider - .first() - .map(Budget::getStart) - .getOrThrow(() -> StatusException.notFound("No budget found")); - } - - @Put - @Operation(summary = "Create initial budget", description = "Create a new budget in the system.") - @ApiResponse( - responseCode = "400", - content = @Content(schema = @Schema(implementation = JsonError.class)), - description = "There is already an open budget.") - @Validated - @Status(HttpStatus.CREATED) - void create(@Body BudgetCreateRequest createRequest) { - var startDate = createRequest.getStart(); - var existing = budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue()); - if (existing.isPresent()) { - throw StatusException.badRequest( - "Cannot start a new budget, there is already a budget open."); - } - - currentUserProvider.currentUser().createBudget(startDate, createRequest.income()); - } - - @Patch - @Operation( - summary = "Patch budget.", - description = "Update an existing budget that is not yet closed in the system.") - BudgetResponse patchBudget(@Valid @Body BudgetCreateRequest patchRequest) { - var startDate = patchRequest.getStart(); - - var budget = budgetProvider - .lookup(startDate.getYear(), startDate.getMonthValue()) - .getOrThrow( - () -> StatusException.notFound("No budget is active yet, create a budget first.")); - - budget.indexBudget(startDate, patchRequest.income()); - return budgetProvider - .lookup(startDate.getYear(), startDate.getMonthValue()) - .map(BudgetResponse::new) - .getOrThrow( - () -> StatusException.internalError("Could not get budget after updating the period.")); - } - - @Patch("/expenses") - @Operation( - summary = "Patch Expenses", - description = "Create or update an expense in the currents month budget.") - BudgetResponse patchExpenses(@Valid @Body ExpensePatchRequest patchRequest) { - var currentDate = LocalDate.now().withDayOfMonth(1); - - var budget = budgetProvider - .lookup(currentDate.getYear(), currentDate.getMonthValue()) - .getOrThrow(() -> - StatusException.notFound("Cannot update expenses, no budget available" + " yet.")); - - if (patchRequest.expenseId() != null) { - log.debug("Updating expense {} within active budget.", patchRequest.expenseId()); - - if (budget.getStart().isBefore(currentDate)) { - log.info( - "Starting new budget period as the current period {} is after the existing" - + " start of {}", - currentDate, - budget.getStart()); - budget.indexBudget(currentDate, budget.getExpectedIncome()); - budget = budgetProvider - .lookup(currentDate.getYear(), currentDate.getMonthValue()) - .getOrThrow(() -> StatusException.internalError("Updating of budget failed.")); - } - - var toUpdate = budget - .getExpenses() - .first(expense -> Objects.equals(expense.getId(), patchRequest.expenseId())) - .getOrThrow( - () -> StatusException.badRequest("Attempted to update a non existing expense.")); - - toUpdate.updateExpense(patchRequest.amount()); - } else { - budget.createExpense( - patchRequest.name(), patchRequest.amount() - 0.01, patchRequest.amount()); - } - - return budgetProvider - .lookup(currentDate.getYear(), currentDate.getMonthValue()) - .map(BudgetResponse::new) - .getOrThrow(() -> StatusException.internalError("Error whilst fetching updated budget.")); - } - - @Get("/expenses/{id}/{year}/{month}") - @Operation( - summary = "Compute expense", - description = "Computes the expense for the provided year and month") - List computeExpense( - @PathVariable long id, @PathVariable int year, @PathVariable int month) { - var dateRange = DateUtils.forMonth(year, month); - - return budgetProvider.lookup(year, month).stream() - .flatMap(budget -> budget.getExpenses().stream()) - .filter(expense -> expense.getId() == id) - .map(expense -> { - var filter = filterFactory - .transaction() - .ownAccounts() - .range(dateRange) - .expenses(Collections.List(new EntityRef(expense.getId()))); - - return new ComputedExpenseResponse( - expense.computeBudget(), - transactionProvider - .balance(filter) - .getOrSupply(() -> BigDecimal.ZERO) - .doubleValue(), - dateRange); - }) - .toList(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ComputedExpenseResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ComputedExpenseResponse.java deleted file mode 100644 index 1e6e99f8..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ComputedExpenseResponse.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import com.jongsoft.lang.time.Range; -import io.micronaut.serde.annotation.Serdeable; -import java.math.BigDecimal; -import java.math.MathContext; -import java.math.RoundingMode; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; - -@Serdeable.Serializable -class ComputedExpenseResponse { - - public static class DateRange { - - private Range range; - - public DateRange(Range range) { - this.range = range; - } - - public LocalDate getStart() { - return range.from(); - } - - public LocalDate getEnd() { - return range.until(); - } - } - - private double allowed; - private double spent; - private DateRange dateRange; - - public ComputedExpenseResponse(double allowed, double spent, Range dateRange) { - this.allowed = allowed; - this.spent = spent; - this.dateRange = new DateRange(dateRange); - } - - public double getSpent() { - return spent; - } - - public double getDailySpent() { - var days = (int) ChronoUnit.DAYS.between(dateRange.getStart(), dateRange.getEnd()); - return calculateDaily(spent, days).doubleValue(); - } - - public double getLeft() { - return BigDecimal.valueOf(allowed) - .subtract(BigDecimal.valueOf(Math.abs(spent))) - .doubleValue(); - } - - public double getDailyLeft() { - var days = (int) ChronoUnit.DAYS.between(dateRange.getStart(), dateRange.getEnd()); - return calculateDaily( - BigDecimal.valueOf(allowed) - .subtract(BigDecimal.valueOf(Math.abs(spent))) - .doubleValue(), - days) - .doubleValue(); - } - - private BigDecimal calculateDaily(double spent, int days) { - return BigDecimal.valueOf(spent) - .divide(BigDecimal.valueOf(days), new MathContext(6, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ExpensePatchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ExpensePatchRequest.java deleted file mode 100644 index c9aeb036..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ExpensePatchRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Min; - -@Serdeable -public record ExpensePatchRequest(Long expenseId, String name, @Min(0) double amount) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ExpenseTransactionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ExpenseTransactionResource.java deleted file mode 100644 index 308b4244..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/ExpenseTransactionResource.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_BUDGETS; - -import com.jongsoft.finance.core.DateUtils; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.rest.model.TransactionResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = TAG_BUDGETS) -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/budgets/expenses/{expenseId}/{year}/{month}/transactions") -public class ExpenseTransactionResource { - - private final FilterFactory filterFactory; - private final TransactionProvider transactionService; - private final SettingProvider settingProvider; - - public ExpenseTransactionResource( - FilterFactory filterFactory, - TransactionProvider transactionService, - SettingProvider settingProvider) { - this.filterFactory = filterFactory; - this.transactionService = transactionService; - this.settingProvider = settingProvider; - } - - @Get("{?page}") - @Operation( - summary = "Transaction overview", - description = "Paged listing of all transactions for the provided expense and month.", - parameters = { - @Parameter( - name = "expenseId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class), - required = true), - @Parameter( - name = "year", - in = ParameterIn.PATH, - schema = @Schema(implementation = Integer.class), - required = true), - @Parameter( - name = "month", - in = ParameterIn.PATH, - schema = @Schema(implementation = Integer.class), - required = true), - @Parameter( - name = "page", - in = ParameterIn.QUERY, - schema = @Schema(implementation = Integer.class)) - }) - ResultPageResponse transactions( - @PathVariable long expenseId, - @PathVariable int year, - @PathVariable int month, - @Nullable Integer page) { - var filter = filterFactory - .transaction() - .range(DateUtils.forMonth(year, month)) - .onlyIncome(false) - .ownAccounts() - .expenses(Collections.List(new EntityRef(expenseId))) - .page(Control.Option(page).getOrSupply(() -> 1), settingProvider.getPageSize()); - - return new ResultPageResponse<>( - transactionService.lookup(filter).map(TransactionResponse::new)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategoryCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategoryCreateRequest.java deleted file mode 100644 index a79fc7e0..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategoryCreateRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.jongsoft.finance.rest.category; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -@Serdeable.Deserializable -record CategoryCreateRequest( - @NotNull @NotBlank @Size(max = 255) String name, @Size(max = 1024) String description) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategoryResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategoryResource.java deleted file mode 100644 index 7c658367..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategoryResource.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.jongsoft.finance.rest.category; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_CATEGORIES; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.user.Category; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.CategoryProvider; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.rest.model.CategoryResponse; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; - -@Tag(name = TAG_CATEGORIES) -@Controller("/api/categories") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class CategoryResource { - - private final FilterFactory filterFactory; - private final CategoryProvider categoryService; - private final CurrentUserProvider currentUserProvider; - - private final SettingProvider settingProvider; - - public CategoryResource( - FilterFactory filterFactory, - CategoryProvider categoryService, - CurrentUserProvider currentUserProvider, - SettingProvider settingProvider) { - this.filterFactory = filterFactory; - this.categoryService = categoryService; - this.currentUserProvider = currentUserProvider; - this.settingProvider = settingProvider; - } - - @Get - @Operation( - summary = "List categories", - description = "List all available categories", - operationId = "getAll") - List list() { - return categoryService.lookup().map(CategoryResponse::new).toJava(); - } - - @Post - @Operation( - summary = "Search categories", - description = "Search through the categories with the provided filter set", - operationId = "searchCategories") - ResultPageResponse search(@Valid @Body CategorySearchRequest searchRequest) { - var response = categoryService.lookup( - filterFactory.category().page(searchRequest.getPage(), settingProvider.getPageSize())); - - return new ResultPageResponse<>(response.map(CategoryResponse::new)); - } - - @Get("/auto-complete{?token}") - @Operation( - summary = "Autocomplete", - description = "List all categories matching the provided token", - operationId = "getCategoriesByToken") - List autocomplete(@Nullable String token) { - return categoryService - .lookup(filterFactory - .category() - .label(token, false) - .page(0, settingProvider.getAutocompleteLimit())) - .content() - .map(CategoryResponse::new) - .toJava(); - } - - @Put - @Status(HttpStatus.CREATED) - @Operation( - summary = "Create category", - description = "Adds a new category to the system", - operationId = "createCategory") - CategoryResponse create(@Valid @Body CategoryCreateRequest createRequest) { - currentUserProvider.currentUser().createCategory(createRequest.name()); - - return categoryService - .lookup(createRequest.name()) - .map(category -> { - category.rename(createRequest.name(), createRequest.description()); - return category; - }) - .map(CategoryResponse::new) - .getOrThrow(() -> StatusException.internalError("Could not create category")); - } - - @Get("/{id}") - @Operation( - summary = "Get category", - description = "Get a single category by its Id", - operationId = "getCategory") - CategoryResponse get(@PathVariable long id) { - return categoryService - .lookup(id) - .map(CategoryResponse::new) - .getOrThrow(() -> StatusException.notFound("No category found with id " + id)); - } - - @Post("/{id}") - @Operation( - summary = "Update category", - description = "Update a single category by its Id", - operationId = "updateCategory") - CategoryResponse update(@PathVariable long id, @Valid @Body CategoryCreateRequest updateRequest) { - return categoryService - .lookup(id) - .map(category -> { - category.rename(updateRequest.name(), updateRequest.description()); - return new CategoryResponse(category); - }) - .getOrThrow(() -> StatusException.notFound("No category found with id " + id)); - } - - @Delete("/{id}") - @Operation( - summary = "Delete category", - description = "Delete a single category by its Id", - operationId = "deleteCategory") - @Status(HttpStatus.NO_CONTENT) - void delete(@PathVariable long id) { - categoryService.lookup(id).ifPresent(Category::remove); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategorySearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategorySearchRequest.java deleted file mode 100644 index 3ac3765d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/category/CategorySearchRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.jongsoft.finance.rest.category; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Deserializable -public record CategorySearchRequest(int page) { - - public int getPage() { - return Math.max(page - 1, 0); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractAttachmentRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractAttachmentRequest.java deleted file mode 100644 index 4450b764..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractAttachmentRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable.Deserializable -public record ContractAttachmentRequest( - @Schema(description = "The file code of the attachment.", example = "1234567890") - String fileCode) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractCreateRequest.java deleted file mode 100644 index 523b7e3b..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractCreateRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; - -@Serdeable.Deserializable -record ContractCreateRequest( - @NotNull @NotBlank @Schema(description = "The name of the contract.", example = "Contract 1") - String name, - @Schema(description = "The description of the contract.", example = "Contract 1 description") - String description, - @NotNull @Schema(description = "The company the contract is with.") EntityRef company, - @NotNull @Schema(description = "The start date of the contract.") LocalDate start, - @NotNull @Schema(description = "The end date of the contract.") LocalDate end) { - - @Serdeable.Deserializable - public record EntityRef( - @NotNull @Schema(description = "The id of the company.", example = "1") Long id, - @Schema(description = "The name of the company.", example = "Company 1") String name) {} -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractOverviewResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractOverviewResponse.java deleted file mode 100644 index 63e11914..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractOverviewResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import com.jongsoft.finance.rest.model.ContractResponse; -import io.micronaut.serde.annotation.Serdeable; -import java.util.List; - -@Serdeable.Serializable -record ContractOverviewResponse(List active, List terminated) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractResource.java deleted file mode 100644 index f4fc75ae..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractResource.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_CONTRACTS; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.account.Contract; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.domain.transaction.ScheduledTransaction; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.ContractProvider; -import com.jongsoft.finance.providers.TransactionScheduleProvider; -import com.jongsoft.finance.rest.model.ContractResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.micronaut.validation.Validated; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; - -@Tag(name = TAG_CONTRACTS) -@Controller("/api/contracts") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class ContractResource { - - private static final String NO_CONTRACT_FOUND_MESSAGE = "No contract can be found for "; - - private final AccountProvider accountProvider; - private final ContractProvider contractProvider; - private final TransactionScheduleProvider scheduleProvider; - private final FilterFactory filterFactory; - - public ContractResource( - AccountProvider accountProvider, - ContractProvider contractProvider, - TransactionScheduleProvider scheduleProvider, - FilterFactory filterFactory) { - this.accountProvider = accountProvider; - this.contractProvider = contractProvider; - this.scheduleProvider = scheduleProvider; - this.filterFactory = filterFactory; - } - - @Get - @Operation( - summary = "List contracts", - description = "List all contracts split in both active and inactive ones", - operationId = "getAll") - ContractOverviewResponse list() { - var contracts = contractProvider.lookup(); - - return new ContractOverviewResponse( - contracts.reject(Contract::isTerminated).map(ContractResponse::new).toJava(), - contracts.filter(Contract::isTerminated).map(ContractResponse::new).toJava()); - } - - @Get("/auto-complete") - @Operation( - summary = "Autocomplete contracts", - description = "Performs a search operation based on the partial name (token)", - operationId = "getByToken") - List autocomplete(@QueryValue String token) { - return contractProvider.search(token).map(ContractResponse::new).toJava(); - } - - @Put - @Validated - @Status(HttpStatus.CREATED) - @Operation( - summary = "Create contract", - description = "Adds a new contract to FinTrack for the authenticated user", - operationId = "createContract") - ContractResponse create(@Body ContractCreateRequest createRequest) { - return accountProvider - .lookup(createRequest.company().id()) - .map(account -> account.createContract( - createRequest.name(), - createRequest.description(), - createRequest.start(), - createRequest.end())) - .map(account -> contractProvider - .lookup(createRequest.name()) - .getOrThrow(() -> StatusException.internalError("Error creating contract"))) - .map(ContractResponse::new) - .getOrThrow(() -> StatusException.notFound( - "No account can be found for " + createRequest.company().id())); - } - - @Post("/{contractId}") - @Operation( - summary = "Update contract", - description = "Updates an existing contract for the authenticated user", - parameters = - @Parameter( - name = "contractId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - ContractResponse update( - @PathVariable long contractId, @Body @Valid ContractCreateRequest updateRequest) { - return contractProvider - .lookup(contractId) - .map(contract -> { - contract.change( - updateRequest.name(), - updateRequest.description(), - updateRequest.start(), - updateRequest.end()); - return contract; - }) - .map(ContractResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CONTRACT_FOUND_MESSAGE + contractId)); - } - - @Get("/{contractId}") - @Operation( - summary = "Get contract", - description = "Get a single contract from FinTrack", - parameters = - @Parameter( - name = "contractId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - ContractResponse get(@PathVariable long contractId) { - return contractProvider - .lookup(contractId) - .map(ContractResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CONTRACT_FOUND_MESSAGE + contractId)); - } - - @Put("/{contractId}/schedule") - @Operation( - summary = "Schedule transaction", - description = "Create a new schedule for creating transaction under this contract.", - parameters = - @Parameter( - name = "contractId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - void schedule(@PathVariable long contractId, @Body @Valid CreateScheduleRequest request) { - var account = accountProvider - .lookup(request.source().id()) - .getOrThrow(() -> StatusException.badRequest("No source account found with provided id.")); - - var contract = contractProvider - .lookup(contractId) - .getOrThrow(() -> StatusException.badRequest("No contract found with provided id.")); - - contract.createSchedule(request.getSchedule(), account, request.amount()); - - // update the schedule start / end date - scheduleProvider - .lookup(filterFactory - .schedule() - .activeOnly() - .contract(Collections.List(new EntityRef(contractId)))) - .content() - .forEach(ScheduledTransaction::limitForContract); - } - - @Get("/{contractId}/expire-warning") - @Operation( - summary = "Enable warning", - description = "This call will enable the warning 1 month before contract expires", - parameters = - @Parameter( - name = "contractId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class)), - operationId = "warnBeforeExpireDate") - ContractResponse warnExpiry(@PathVariable long contractId) { - return contractProvider - .lookup(contractId) - .map(contract -> { - contract.warnBeforeExpires(); - return contract; - }) - .map(ContractResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CONTRACT_FOUND_MESSAGE + contractId)); - } - - @Post("/{contractId}/attachment") - @Operation( - summary = "Attach file", - description = "This call will register an attachment to the contract", - parameters = - @Parameter( - name = "contractId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class)), - operationId = "registerAttachment") - ContractResponse attachment( - @PathVariable long contractId, @Body @Valid ContractAttachmentRequest attachmentRequest) { - return contractProvider - .lookup(contractId) - .map(contract -> { - contract.registerUpload(attachmentRequest.fileCode()); - return contract; - }) - .map(ContractResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CONTRACT_FOUND_MESSAGE + contractId)); - } - - @Delete("/{contractId}") - @Operation( - summary = "Delete contract", - description = "Archives an existing contract", - parameters = - @Parameter( - name = "contractId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class))) - void delete(@PathVariable long contractId) { - contractProvider.lookup(contractId).ifPresent(Contract::terminate); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractTransactionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractTransactionResource.java deleted file mode 100644 index 9a55b5ba..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/ContractTransactionResource.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.rest.model.TransactionResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.Optional; - -@Tag(name = "Contract") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/contracts/{contractId}/transactions") -public class ContractTransactionResource { - - private final FilterFactory filterFactory; - private final TransactionProvider transactionService; - private final SettingProvider settingProvider; - - public ContractTransactionResource( - FilterFactory filterFactory, - TransactionProvider transactionService, - SettingProvider settingProvider) { - this.filterFactory = filterFactory; - this.transactionService = transactionService; - this.settingProvider = settingProvider; - } - - @Get("{?page}") - @Operation( - summary = "Transaction overview", - description = "Paged listing of all transactions that belong to a contract") - ResultPageResponse transactions( - @PathVariable long contractId, @Nullable Integer page) { - var filter = filterFactory - .transaction() - .ownAccounts() - .onlyIncome(false) - .contracts(Collections.List(new EntityRef(contractId))) - .page(Optional.ofNullable(page).orElse(0), settingProvider.getPageSize()); - - return new ResultPageResponse<>( - transactionService.lookup(filter).map(TransactionResponse::new)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/CreateScheduleRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/CreateScheduleRequest.java deleted file mode 100644 index d94fcb86..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/contract/CreateScheduleRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import com.jongsoft.finance.domain.transaction.ScheduleValue; -import com.jongsoft.finance.schedule.Periodicity; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; - -@Serdeable -record CreateScheduleRequest( - @NotNull @Schema(description = "The schedule to create transactions on.") - ScheduleValueJson schedule, - @NotNull @Schema(description = "The account to charge for every scheduled transaction.") - EntityRef source, - @Schema( - description = "The amount to charge for every scheduled transaction.", - example = "100.00") - double amount) { - - @Serdeable - public record ScheduleValueJson( - @NotNull @Schema(description = "The periodicity of the schedule.", example = "MONTHS") - Periodicity periodicity, - @Min(1) - @Schema(description = "The interval a transaction should be created on.", example = "2") - int interval) {} - - @Serdeable.Deserializable - record EntityRef( - @NotNull @Schema(description = "The id of the account.", example = "1") Long id, - String name) {} - - public ScheduleValue getSchedule() { - return new ScheduleValue(schedule.periodicity, schedule.interval); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/file/FileResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/file/FileResource.java deleted file mode 100644 index 8ff1d03e..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/file/FileResource.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.jongsoft.finance.rest.file; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_ATTACHMENTS; - -import com.jongsoft.finance.StorageService; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.*; -import io.micronaut.http.multipart.CompletedFileUpload; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.io.IOException; - -@Tag(name = TAG_ATTACHMENTS) -@Controller("/api/attachment") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class FileResource { - - private final StorageService storageService; - - public FileResource(StorageService storageService) { - this.storageService = storageService; - } - - @Post - @Status(HttpStatus.CREATED) - @Consumes(MediaType.MULTIPART_FORM_DATA) - @Operation( - summary = "Upload attachment", - description = "Upload a file so that it can be attached to one of the entities in FinTrack") - UploadResponse upload(@Body CompletedFileUpload upload) throws IOException { - var token = storageService.store(upload.getBytes()); - return new UploadResponse(token); - } - - @Get(value = "/{fileCode}", consumes = MediaType.ALL, produces = MediaType.ALL) - @Operation( - summary = "Download attachment", - description = "Download an existing attachment, if file encryption is enabled this will" - + " throw an exception if the current user did not upload the file.") - byte[] download(@PathVariable String fileCode) { - return storageService.read(fileCode).get(); - } - - @Delete("/{fileCode}") - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Delete attachment", - description = "Delete an existing attachment, if file encryption is enabled this will" - + " throw an exception if the current user did not upload the file.") - void delete(@PathVariable String fileCode) { - storageService.remove(fileCode); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/file/UploadResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/file/UploadResponse.java deleted file mode 100644 index f3158f12..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/file/UploadResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.jongsoft.finance.rest.file; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -record UploadResponse(String fileCode) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/BatchImportResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/BatchImportResource.java deleted file mode 100644 index e1f58f2c..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/BatchImportResource.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_TRANSACTION_IMPORT; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.providers.ImportConfigurationProvider; -import com.jongsoft.finance.providers.ImportProvider; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.rest.model.CSVImporterConfigResponse; -import com.jongsoft.finance.rest.model.ImporterResponse; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; - -@Tag(name = TAG_TRANSACTION_IMPORT) -@Controller("/api/import") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class BatchImportResource { - - private final CurrentUserProvider currentUserProvider; - private final ImportConfigurationProvider csvConfigProvider; - private final ImportProvider importProvider; - private final SettingProvider settingProvider; - - public BatchImportResource( - CurrentUserProvider currentUserProvider, - ImportConfigurationProvider csvConfigProvider, - ImportProvider importProvider, - SettingProvider settingProvider) { - this.currentUserProvider = currentUserProvider; - this.csvConfigProvider = csvConfigProvider; - this.importProvider = importProvider; - this.settingProvider = settingProvider; - } - - @Post - @Operation( - summary = "List jobs", - description = "This operation will list all the run importer jobs for the current user") - ResultPageResponse list(@Valid @Body ImportSearchRequest request) { - var result = importProvider.lookup(new ImportProvider.FilterCommand() { - @Override - public int page() { - return request.getPage(); - } - - @Override - public int pageSize() { - return settingProvider.getPageSize(); - } - }); - - return new ResultPageResponse<>(result.map(ImporterResponse::new)); - } - - @Put - @Operation( - summary = "Create importer", - description = "Creates a new importer job in FinTrack, which can be used to import a CSV of" - + " transactions") - ImporterResponse create(@Valid @Body ImporterCreateRequest request) { - return csvConfigProvider - .lookup(request.configuration()) - .map(config -> config.createImport(request.uploadToken())) - .map(ImporterResponse::new) - .getOrThrow(() -> StatusException.notFound("CSV configuration not found")); - } - - @Get("/{batchSlug}") - @Operation( - summary = "Get Importer Job", - description = "Fetch a single importer job from FinTrack", - parameters = - @Parameter( - name = "batchSlug", - in = ParameterIn.PATH, - description = "The unique identifier")) - ImporterResponse get(@PathVariable String batchSlug) { - return importProvider - .lookup(batchSlug) - .map(ImporterResponse::new) - .getOrThrow(() -> StatusException.notFound("CSV configuration not found")); - } - - @Delete("/{batchSlug}") - @Operation( - summary = "Delete importer job", - description = "Removes an unfinished job from the system. Note that already completed jobs" - + " cannot be removed.", - parameters = - @Parameter( - name = "batchSlug", - in = ParameterIn.PATH, - description = "The unique identifier")) - @Status(value = HttpStatus.NO_CONTENT) - String delete(@PathVariable String batchSlug) { - return importProvider - .lookup(batchSlug) - .map(job -> { - job.archive(); - return job.getSlug(); - }) - .getOrThrow(() -> StatusException.notFound("Cannot delete import with slug " + batchSlug)); - } - - @Get("/config") - @Operation( - summary = "List configurations", - description = "List all available importer configurations in FinTrack") - List config() { - return csvConfigProvider.lookup().map(CSVImporterConfigResponse::new).toJava(); - } - - @Put("/config") - @Operation( - summary = "Create configuration", - description = - "Creates a new importer configuration in FinTrack, using the provided file" + " token") - CSVImporterConfigResponse createConfig(@Valid @Body CSVImporterConfigCreateRequest request) { - var existing = csvConfigProvider.lookup(request.name()); - if (existing.isPresent()) { - throw StatusException.badRequest( - "Configuration with name " + request.name() + " already exists."); - } - - return new CSVImporterConfigResponse(currentUserProvider - .currentUser() - .createImportConfiguration(request.type(), request.name(), request.fileCode())); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/CSVImporterConfigCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/CSVImporterConfigCreateRequest.java deleted file mode 100644 index bbbe1211..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/CSVImporterConfigCreateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; - -@Serdeable.Deserializable -record CSVImporterConfigCreateRequest( - @NotBlank @Schema(description = "The type of importer that is to be used") String type, - @Schema(description = "The name of the configuration") @NotBlank String name, - @Schema(description = "The file code to get the contents of the configuration") @NotBlank - String fileCode) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImportSearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImportSearchRequest.java deleted file mode 100644 index 9562d455..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImportSearchRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Min; - -@Serdeable.Deserializable -record ImportSearchRequest(@Min(0) int page) { - - public int getPage() { - return Math.max(0, page - 1); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImporterCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImporterCreateRequest.java deleted file mode 100644 index 145fb126..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImporterCreateRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@Serdeable.Deserializable -record ImporterCreateRequest( - @NotNull @NotBlank String configuration, @NotNull @NotBlank String uploadToken) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImporterTransactionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImporterTransactionResource.java deleted file mode 100644 index 86fff452..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/ImporterTransactionResource.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.transaction.Transaction; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.rest.model.TransactionResponse; -import com.jongsoft.finance.rule.RuleDataSet; -import com.jongsoft.finance.rule.RuleEngine; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Tag(name = "Importer") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/import/{batchSlug}/transactions") -public class ImporterTransactionResource { - - private final Logger log = LoggerFactory.getLogger(ImporterTransactionResource.class); - - private final SettingProvider settingProvider; - private final FilterFactory filterFactory; - private final TransactionProvider transactionProvider; - - private final RuleEngine ruleEngine; - - public ImporterTransactionResource( - SettingProvider settingProvider, - FilterFactory filterFactory, - TransactionProvider transactionProvider, - RuleEngine ruleEngine) { - this.settingProvider = settingProvider; - this.filterFactory = filterFactory; - this.transactionProvider = transactionProvider; - this.ruleEngine = ruleEngine; - } - - @Post - @Operation( - summary = "Transaction overview", - operationId = "getTransactions", - description = "Search for transactions created by the importer job", - parameters = - @Parameter( - name = "batchSlug", - in = ParameterIn.PATH, - schema = @Schema(implementation = String.class))) - ResultPageResponse search( - @PathVariable String batchSlug, @Valid @Body TransactionSearchRequest request) { - var filter = filterFactory - .transaction() - .importSlug(batchSlug) - .page(request.getPage(), settingProvider.getPageSize()); - - var response = transactionProvider.lookup(filter).map(TransactionResponse::new); - - return new ResultPageResponse<>(response); - } - - @Post("/run-rule-automation") - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Run rule automation", - operationId = "runRuleAutomation", - description = "Run rule automation on transactions created by the importer job", - parameters = - @Parameter( - name = "batchSlug", - in = ParameterIn.PATH, - schema = @Schema(implementation = String.class))) - void runRuleAutomation(@PathVariable String batchSlug) { - var page = 0; - var searchFilter = filterFactory.transaction().importSlug(batchSlug).page(page, 250); - - var result = transactionProvider.lookup(searchFilter); - while (true) { - log.info( - "Processing page {} of {} transactions for applying rules.", page + 1, result.pages()); - result.content().stream().parallel().forEach(this::processTransaction); - if (!result.hasNext()) { - break; - } - result = transactionProvider.lookup(searchFilter.page(++page, 250)); - } - } - - private void processTransaction(Transaction transaction) { - var inputSet = new RuleDataSet(); - inputSet.put(RuleColumn.TO_ACCOUNT, transaction.computeTo().getName()); - inputSet.put(RuleColumn.SOURCE_ACCOUNT, transaction.computeFrom().getName()); - inputSet.put(RuleColumn.AMOUNT, transaction.computeAmount(transaction.computeTo())); - inputSet.put(RuleColumn.DESCRIPTION, transaction.getDescription()); - - var outputSet = ruleEngine.run(inputSet); - - for (Map.Entry entry : outputSet.entrySet()) { - switch (entry.getKey()) { - case CATEGORY -> transaction.linkToCategory((String) entry.getValue()); - case TO_ACCOUNT, CHANGE_TRANSFER_TO -> - transaction.changeAccount(false, (Account) entry.getValue()); - case SOURCE_ACCOUNT, CHANGE_TRANSFER_FROM -> - transaction.changeAccount(true, (Account) entry.getValue()); - case CONTRACT -> transaction.linkToContract((String) entry.getValue()); - case BUDGET -> transaction.linkToBudget((String) entry.getValue()); - default -> - throw new IllegalArgumentException("Unsupported rule column provided " + entry.getKey()); - } - } - } - - @Delete("/{transactionId}") - @Status(HttpStatus.NO_CONTENT) - @Post - @Operation( - summary = "Delete transaction", - operationId = "deleteTransaction", - description = "Search for transactions created by the importer job", - parameters = { - @Parameter( - name = "batchSlug", - in = ParameterIn.PATH, - schema = @Schema(implementation = String.class)), - @Parameter( - name = "transactionId", - in = ParameterIn.PATH, - schema = @Schema(implementation = Long.class)) - }) - void delete(@PathVariable long transactionId) { - transactionProvider.lookup(transactionId).ifPresent(Transaction::delete); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/TransactionSearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/TransactionSearchRequest.java deleted file mode 100644 index fd57f7c6..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/importer/TransactionSearchRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Min; - -@Serdeable.Deserializable -class TransactionSearchRequest { - - @Min(0) - private int page; - - public TransactionSearchRequest(int page) { - this.page = page; - } - - public int getPage() { - return Math.max(0, page - 1); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/localization/LanguageResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/localization/LanguageResource.java deleted file mode 100644 index 1af06e48..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/localization/LanguageResource.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.jongsoft.finance.rest.localization; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SETTINGS_LOCALIZATION; - -import com.jongsoft.finance.core.exception.StatusException; -import io.micronaut.context.MessageSource; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.tags.Tags; -import java.io.IOException; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; - -@Tags(@Tag(name = TAG_SETTINGS_LOCALIZATION)) -@Secured(SecurityRule.IS_ANONYMOUS) -@Controller("/api/localization/lang") -public class LanguageResource { - - private final MessageSource messageSource; - - public LanguageResource(MessageSource messageSource) { - this.messageSource = messageSource; - } - - @Get("/{language}") - @Operation(summary = "Get a localization file", operationId = "getTranslations") - public Map get(@PathVariable String language) throws IOException { - var pathPart = "en".equals(language) ? "" : "_" + language; - - var messages = getClass().getResourceAsStream("/i18n/messages" + pathPart + ".properties"); - var validation = - getClass().getResourceAsStream("/i18n/ValidationMessages" + pathPart + ".properties"); - - var response = new HashMap(); - var textKeys = new Properties(); - textKeys.load(messages); - textKeys.load(validation); - textKeys.forEach((key, value) -> response.put(key.toString(), value.toString())); - return response; - } - - @Get("/{language}/{textKey}") - @Operation(summary = "Get single translation", operationId = "getTranslation") - LanguageResponse getText(@PathVariable String language, @PathVariable String textKey) { - var message = messageSource - .getMessage(textKey, MessageSource.MessageContext.of(Locale.forLanguageTag(language))) - .orElseThrow(() -> StatusException.notFound("No message found for " + textKey)); - - return new LanguageResponse(message); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/localization/LanguageResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/localization/LanguageResponse.java deleted file mode 100644 index 5cb44952..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/localization/LanguageResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.jongsoft.finance.rest.localization; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -record LanguageResponse(String text) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/AccountResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/AccountResponse.java deleted file mode 100644 index 187b2902..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/AccountResponse.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.account.SavingGoal; -import com.jongsoft.finance.schedule.Periodicity; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.Objects; -import java.util.Set; - -@Serdeable.Serializable -public class AccountResponse { - - private final Account wrapped; - - public AccountResponse(final Account wrapped) { - Objects.requireNonNull(wrapped, "Account cannot be null for JSON response."); - this.wrapped = wrapped; - } - - @Schema(description = "The identifier of the account", example = "3212", required = true) - public long getId() { - return wrapped.getId(); - } - - @Schema( - description = "The account name, is unique for the user", - example = "Fast food & co", - required = true) - public String getName() { - return wrapped.getName(); - } - - @Schema(description = "The description for the account") - public String getDescription() { - return wrapped.getDescription(); - } - - @Schema( - description = "The type of account, as defined by the account type API", - example = "creditor", - required = true) - public String getType() { - return wrapped.getType(); - } - - @Schema(description = "The file code for the image of the account") - public String getIconFileCode() { - return wrapped.getImageFileToken(); - } - - @Schema(description = "Bank identification numbers for the account", required = true) - public NumberInformation getAccount() { - return new NumberInformation(); - } - - @Schema( - description = - "The interest information for the account, only used for loans, debts and" + " mortgage") - public InterestInformation getInterest() { - return new InterestInformation(); - } - - @Schema(description = "Transaction history information for the account") - public History getHistory() { - return new History(); - } - - @Schema( - description = - "The saving goals for the account, only valid for type savings and" + " joined_savings") - public Set getSavingGoals() { - if (wrapped.getSavingGoals() != null) { - return wrapped.getSavingGoals().map(SavingGoalResponse::new).toJava(); - } - - return null; - } - - @Serdeable.Serializable - public static class SavingGoalResponse { - - private final SavingGoal wrapped; - - public SavingGoalResponse(SavingGoal wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The identifier of the saving goal", example = "132", required = true) - public long getId() { - return wrapped.getId(); - } - - @Schema(description = "The name of the saving goal", example = "Car replacement") - public String getName() { - return wrapped.getName(); - } - - @Schema(description = "The description of the saving goal") - public String getDescription() { - return wrapped.getDescription(); - } - - @Schema(description = "The schedule that allocations are created automatically") - public ScheduleResponse getSchedule() { - if (wrapped.getSchedule() != null) { - return new ScheduleResponse(wrapped.getSchedule()); - } - - return null; - } - - @Schema( - description = "The goal one wishes to achieve by the end date", - type = "number", - example = "1500.40", - required = true) - public BigDecimal getGoal() { - return wrapped.getGoal(); - } - - @Schema( - description = "The amount of money reserved for this saving goal", - type = "number", - example = "200", - required = true) - public BigDecimal getReserved() { - return wrapped.getAllocated(); - } - - @Schema( - description = "The amount of money allocated each interval, only when schedule is set", - type = "number", - example = "25.50") - public BigDecimal getInstallments() { - if (wrapped.getSchedule() != null) { - return wrapped.computeAllocation(); - } - - return null; - } - - @Schema( - description = "The date before which the goal must be met", - example = "2021-01-12", - required = true) - public LocalDate getTargetDate() { - return wrapped.getTargetDate(); - } - - @Schema(description = "The amount of months left until the target date", example = "23") - public long getMonthsLeft() { - var monthsLeft = ChronoUnit.MONTHS.between(LocalDate.now(), wrapped.getTargetDate()); - return Math.max(0, monthsLeft); - } - } - - @Serdeable.Serializable - public class InterestInformation { - - @Schema(description = "The interval the interest is calculated on", example = "MONTHS") - public Periodicity getPeriodicity() { - return wrapped.getInterestPeriodicity(); - } - - @Schema(description = "The amount of interest that is owed", example = "0.0754") - public double getInterest() { - return wrapped.getInterest(); - } - } - - @Serdeable.Serializable - public class NumberInformation { - - public String getIban() { - return wrapped.getIban(); - } - - public String getBic() { - return wrapped.getBic(); - } - - public String getNumber() { - return wrapped.getNumber(); - } - - public String getCurrency() { - return wrapped.getCurrency(); - } - } - - @Serdeable.Serializable - public class History { - - @Schema(description = "The date of the first recorded transaction for the account") - public LocalDate getFirstTransaction() { - return wrapped.getFirstTransaction(); - } - - @Schema(description = "The date of the latest recorded transaction for the account") - public LocalDate getLastTransaction() { - return wrapped.getLastTransaction(); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/BudgetResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/BudgetResponse.java deleted file mode 100644 index 7f3d7992..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/BudgetResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.user.Budget; -import io.micronaut.serde.annotation.Serdeable; -import java.time.LocalDate; -import java.util.Comparator; -import java.util.stream.Stream; - -@Serdeable -public class BudgetResponse { - - private final Budget wrapped; - - public BudgetResponse(Budget wrapped) { - this.wrapped = wrapped; - } - - public double getIncome() { - return wrapped.getExpectedIncome(); - } - - public Period getPeriod() { - return new Period(); - } - - public Stream getExpenses() { - return wrapped.getExpenses().map(ExpenseResponse::new).stream() - .sorted(Comparator.comparing(ExpenseResponse::getName)); - } - - @Serdeable - public class Period { - - public LocalDate getFrom() { - return wrapped.getStart(); - } - - public LocalDate getUntil() { - return wrapped.getEnd(); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CSVImporterConfigResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CSVImporterConfigResponse.java deleted file mode 100644 index 50d696b1..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CSVImporterConfigResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.importer.BatchImportConfig; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable.Serializable -public class CSVImporterConfigResponse { - - private final BatchImportConfig wrapped; - - public CSVImporterConfigResponse(BatchImportConfig wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The configuration identifier") - public Long getId() { - return wrapped.getId(); - } - - @Schema(description = "The name of the configuration") - public String getName() { - return wrapped.getName(); - } - - @Schema(description = "The type of importer that will be used") - public String getType() { - return wrapped.getType(); - } - - @Schema(description = "The file code to get the contents of the configuration") - public String getFile() { - return wrapped.getFileCode(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CategoryResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CategoryResponse.java deleted file mode 100644 index 229e19ca..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CategoryResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.user.Category; -import io.micronaut.serde.annotation.Serdeable; -import java.time.LocalDate; - -@Serdeable.Serializable -public class CategoryResponse { - - private final Category wrapped; - - public CategoryResponse(Category wrapped) { - this.wrapped = wrapped; - } - - public long getId() { - return wrapped.getId(); - } - - public String getLabel() { - return wrapped.getLabel(); - } - - public String getDescription() { - return wrapped.getDescription(); - } - - public LocalDate getLastUsed() { - return wrapped.getLastActivity(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ContractResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ContractResponse.java deleted file mode 100644 index 3ba7ca79..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ContractResponse.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.account.Contract; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; - -@Serdeable.Serializable -public class ContractResponse { - - private final Contract wrapped; - - public ContractResponse(Contract wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The identifier of the contract", required = true) - public long getId() { - return wrapped.getId(); - } - - @Schema(description = "The name of the contract", required = true, example = "Cable company") - public String getName() { - return wrapped.getName(); - } - - @Schema(description = "The description for the contract") - public String getDescription() { - return wrapped.getDescription(); - } - - @Schema( - description = "Indicator for an digital copy of the contract being present", - required = true) - public boolean isContractAvailable() { - return wrapped.isUploaded(); - } - - @Schema(description = "The file token to get the digital copy") - public String getFileToken() { - return wrapped.getFileToken(); - } - - @Schema(description = "The start date of the contract") - public LocalDate getStart() { - return wrapped.getStartDate(); - } - - @Schema(description = "The end date of the contract") - public LocalDate getEnd() { - return wrapped.getEndDate(); - } - - @Schema(description = "Indicator that the contract has ended and is closed by the user") - public boolean isTerminated() { - return wrapped.isTerminated(); - } - - @Schema(description = "Indicator if a pre-emptive warning is active before the contract end date") - public boolean isNotification() { - return wrapped.isNotifyBeforeEnd(); - } - - @Schema(description = "The company / account the contract is with", required = true) - public AccountResponse getCompany() { - if (wrapped.getCompany() == null) { - return null; - } - - return new AccountResponse(wrapped.getCompany()); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CurrencyResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CurrencyResponse.java deleted file mode 100644 index 809a6daf..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/CurrencyResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.core.Currency; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable.Serializable -public class CurrencyResponse { - - private final Currency wrapped; - - public CurrencyResponse(Currency wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The name of the currency", example = "United States dollar") - public String getName() { - return wrapped.getName(); - } - - @Schema(description = "The ISO code of the currency", example = "USD") - public String getCode() { - return wrapped.getCode(); - } - - @Schema(description = "The currency symbol", example = "$") - public char getSymbol() { - return wrapped.getSymbol(); - } - - @Schema(description = "The default amount of decimal places for this currency", example = "2") - public int getNumberDecimals() { - return wrapped.getDecimalPlaces(); - } - - @Schema(description = "Indication if the currency is enabled for the application") - public boolean isEnabled() { - return wrapped.isEnabled(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/DateRangeResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/DateRangeResponse.java deleted file mode 100644 index e3e25701..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/DateRangeResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.lang.time.Range; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; - -@Serdeable.Serializable -public class DateRangeResponse { - - private final Range wrapped; - - public DateRangeResponse(final Range wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The start of the date range", example = "2020-01-01", required = true) - public LocalDate getStart() { - return wrapped.from(); - } - - @Schema(description = "The end of the date range", example = "2020-01-31", required = true) - public LocalDate getEnd() { - return wrapped.until(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ExpenseResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ExpenseResponse.java deleted file mode 100644 index b63b035b..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ExpenseResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.user.Budget; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class ExpenseResponse { - - private final Budget.Expense wrapped; - - public ExpenseResponse(Budget.Expense wrapped) { - this.wrapped = wrapped; - } - - public long getId() { - return wrapped.getId(); - } - - public String getName() { - return wrapped.getName(); - } - - public double getExpected() { - return wrapped.computeBudget(); - } - - public Bounds getBounds() { - return new Bounds(); - } - - @Serdeable.Serializable - public class Bounds { - - public double getLower() { - return wrapped.getLowerBound(); - } - - public double getUpper() { - return wrapped.getUpperBound(); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ImporterResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ImporterResponse.java deleted file mode 100644 index ea55b700..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ImporterResponse.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.importer.BatchImport; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Date; - -@Serdeable.Serializable -public class ImporterResponse { - - private final BatchImport wrapped; - - public ImporterResponse(BatchImport wrapped) { - this.wrapped = wrapped; - } - - @Schema( - description = "The unique identifier of the import job", - required = true, - example = "83c3a405939f741cee534d48e600528c") - public String getSlug() { - return wrapped.getSlug(); - } - - @Schema( - description = "The date the job was created", - required = true, - example = "2020-02-02T10:00:00.000Z") - public Date getCreated() { - return wrapped.getCreated(); - } - - @Schema(description = "The date the job was finished", example = "2020-03-02T12:00:00.000Z") - public Date getFinished() { - return wrapped.getFinished(); - } - - @Schema(description = "Get the configuration used during the import") - public CSVImporterConfigResponse getConfig() { - if (wrapped.getConfig() == null) { - return null; - } - - return new CSVImporterConfigResponse(wrapped.getConfig()); - } - - @Schema(description = "Get the affected balance during the import") - public Balance getBalance() { - return new Balance(); - } - - @Serdeable.Serializable - public class Balance { - - @Schema(description = "The total amount of money earned in this import") - public double getTotalIncome() { - return wrapped.getTotalIncome(); - } - - @Schema(description = "The total amount of money spent in this import") - public double getTotalExpense() { - return wrapped.getTotalExpense(); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessResponse.java deleted file mode 100644 index 27ac6d1a..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import io.micronaut.serde.annotation.Serdeable; -import java.util.Optional; -import org.camunda.bpm.engine.runtime.Execution; -import org.camunda.bpm.engine.runtime.ProcessInstance; - -@Serdeable.Serializable -public class ProcessResponse { - - private final ProcessInstance wrapped; - - public ProcessResponse(ProcessInstance wrapped) { - this.wrapped = wrapped; - } - - public String getId() { - return Optional.ofNullable(wrapped).map(Execution::getId).orElse(null); - } - - public String getProcess() { - return Optional.ofNullable(wrapped) - .map(ProcessInstance::getProcessDefinitionId) - .orElse(null); - } - - public String getBusinessKey() { - return Optional.ofNullable(wrapped).map(ProcessInstance::getBusinessKey).orElse(null); - } - - public String getState() { - return Optional.ofNullable(wrapped).map(Execution::isEnded).orElse(true) - ? "COMPLETED" - : "ACTIVE"; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessTaskResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessTaskResponse.java deleted file mode 100644 index caf3ceec..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessTaskResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import io.micronaut.serde.annotation.Serdeable; -import java.util.Date; -import org.camunda.bpm.engine.task.Task; - -@Serdeable.Serializable -public class ProcessTaskResponse { - - private final Task wrapped; - - public ProcessTaskResponse(Task wrapped) { - this.wrapped = wrapped; - } - - public String getId() { - return wrapped.getId(); - } - - public String getDefinition() { - return wrapped.getTaskDefinitionKey(); - } - - public Date getCreated() { - return wrapped.getCreateTime(); - } - - public String getForm() { - return wrapped.getFormKey(); - } - - public String getName() { - return wrapped.getName(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessVariableResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessVariableResponse.java deleted file mode 100644 index 539bfce0..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ProcessVariableResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import io.micronaut.serde.annotation.Serdeable; -import org.camunda.bpm.engine.runtime.VariableInstance; - -@Serdeable.Serializable -public class ProcessVariableResponse { - - private final VariableInstance wrapped; - - public ProcessVariableResponse(VariableInstance wrapped) { - this.wrapped = wrapped; - } - - public String getId() { - return wrapped.getId(); - } - - public String getName() { - return wrapped.getName(); - } - - public Object getValue() { - return wrapped.getValue(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ResultPageResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ResultPageResponse.java deleted file mode 100644 index 53ce784f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ResultPageResponse.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.ResultPage; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import java.util.List; - -@Serdeable.Serializable -public class ResultPageResponse { - - private final ResultPage wrapped; - - public ResultPageResponse(ResultPage wrapped) { - this.wrapped = wrapped; - } - - @NotNull - @Schema(description = "The actual contents of the page", required = true) - public List getContent() { - return wrapped.content().toJava(); - } - - @Schema(description = "The meta-information for the page", required = true) - public Info getInfo() { - return new Info(); - } - - @Serdeable.Serializable - public class Info { - - @Schema(description = "The total amount of matches", required = true, example = "20") - public long getRecords() { - return wrapped.total(); - } - - @Schema(description = "The amount of pages available", required = true, example = "2") - public Integer getPages() { - if (wrapped.hasPages()) { - return wrapped.pages(); - } - - return null; - } - - @Schema(description = "The amount of matches per page", required = true, example = "15") - public Integer getPageSize() { - if (wrapped.hasPages()) { - return wrapped.pageSize(); - } - - return null; - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ScheduleResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ScheduleResponse.java deleted file mode 100644 index d53c6a5d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ScheduleResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.schedule.Periodicity; -import com.jongsoft.finance.schedule.Schedule; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable.Serializable -public class ScheduleResponse { - - private final Schedule wrapped; - - public ScheduleResponse(final Schedule wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The type of the interval", required = true, example = "MONTHS") - public Periodicity getPeriodicity() { - return wrapped.periodicity(); - } - - @Schema(description = "The actual interval", required = true, example = "3") - public int getInterval() { - return wrapped.interval(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ScheduledTransactionResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ScheduledTransactionResponse.java deleted file mode 100644 index cd9dd0a6..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/ScheduledTransactionResponse.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.transaction.ScheduledTransaction; -import com.jongsoft.lang.Dates; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class ScheduledTransactionResponse { - - private final ScheduledTransaction wrapped; - - public ScheduledTransactionResponse(final ScheduledTransaction wrapped) { - this.wrapped = wrapped; - } - - public long getId() { - return wrapped.getId(); - } - - public String getName() { - return wrapped.getName(); - } - - public String getDescription() { - return wrapped.getDescription(); - } - - public DateRangeResponse getRange() { - return new DateRangeResponse(Dates.range(wrapped.getStart(), wrapped.getEnd())); - } - - public ScheduleResponse getSchedule() { - return new ScheduleResponse(wrapped.getSchedule()); - } - - public double getAmount() { - return wrapped.getAmount(); - } - - public AccountResponse getSource() { - return new AccountResponse(wrapped.getSource()); - } - - public AccountResponse getDestination() { - return new AccountResponse(wrapped.getDestination()); - } - - public ContractResponse getContract() { - if (wrapped.getContract() == null) { - return null; - } - - return new ContractResponse(wrapped.getContract()); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SessionResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SessionResponse.java deleted file mode 100644 index d0dcc5f2..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SessionResponse.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.user.SessionToken; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; - -@Serdeable.Serializable -public class SessionResponse { - - private final SessionToken wrapped; - - public SessionResponse(SessionToken wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The identifier of the active session", required = true) - public long getId() { - return wrapped.getId(); - } - - @Schema(description = "The description of the session") - public String getDescription() { - return wrapped.getDescription(); - } - - @Schema(description = "The long lived token of the session", required = true) - public String getToken() { - return wrapped.getToken(); - } - - @Schema(description = "The start date of the session", required = true) - public LocalDateTime getValidFrom() { - return wrapped.getValidity().from(); - } - - @Schema(description = "The end date of the session", required = true) - public LocalDateTime getValidUntil() { - return wrapped.getValidity().until(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SettingResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SettingResponse.java deleted file mode 100644 index 76291995..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SettingResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.core.SettingType; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class SettingResponse { - - private String name; - private String value; - private SettingType type; - - public SettingResponse(String name, String value, SettingType type) { - this.name = name; - this.value = value; - this.type = type; - } - - public String getName() { - return name; - } - - public String getValue() { - return value; - } - - public SettingType getType() { - return type; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SpendingInsightResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SpendingInsightResponse.java deleted file mode 100644 index 149b762c..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SpendingInsightResponse.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.insight.InsightType; -import com.jongsoft.finance.domain.insight.Severity; -import com.jongsoft.finance.domain.insight.SpendingInsight; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; -import java.util.Map; - -@Serdeable.Serializable -public class SpendingInsightResponse { - private final SpendingInsight insight; - - public SpendingInsightResponse(SpendingInsight insight) { - this.insight = insight; - } - - @Schema(description = "The type of insight") - public InsightType getType() { - return insight.getType(); - } - - @Schema(description = "The category of the insight") - public String getCategory() { - return insight.getCategory(); - } - - @Schema(description = "The severity of the insight") - public Severity getSeverity() { - return insight.getSeverity(); - } - - @Schema(description = "The confidence score of the insight (0.0 to 1.0)") - public double getScore() { - return insight.getScore(); - } - - @Schema( - description = "The date when the insight was detected", - implementation = String.class, - format = "yyyy-mm-dd") - public LocalDate getDetectedDate() { - return insight.getDetectedDate(); - } - - @Schema(description = "The message describing the insight") - public String getMessage() { - return insight.getMessage(); - } - - @Schema(description = "The ID of the transaction related to this insight, if any") - public Long getTransactionId() { - return insight.getTransactionId(); - } - - @Schema(description = "Additional metadata for the insight") - public Map getMetadata() { - return insight.getMetadata(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SpendingPatternResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SpendingPatternResponse.java deleted file mode 100644 index e9ac5687..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/SpendingPatternResponse.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.insight.PatternType; -import com.jongsoft.finance.domain.insight.SpendingPattern; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; -import java.util.Map; - -@Serdeable.Serializable -public class SpendingPatternResponse { - private final SpendingPattern pattern; - - public SpendingPatternResponse(SpendingPattern pattern) { - this.pattern = pattern; - } - - @Schema(description = "The type of pattern") - public PatternType getType() { - return pattern.getType(); - } - - @Schema(description = "The category of the pattern") - public String getCategory() { - return pattern.getCategory(); - } - - @Schema(description = "The confidence score of the pattern (0.0 to 1.0)") - public double getConfidence() { - return pattern.getConfidence(); - } - - @Schema( - description = "The date when the pattern was detected", - implementation = String.class, - format = "yyyy-mm-dd") - public LocalDate getDetectedDate() { - return pattern.getDetectedDate(); - } - - @Schema(description = "Additional metadata for the pattern") - public Map getMetadata() { - return pattern.getMetadata(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TagResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TagResponse.java deleted file mode 100644 index 8084f955..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TagResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.transaction.Tag; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class TagResponse { - - private final Tag wrapped; - - public TagResponse(Tag wrapped) { - this.wrapped = wrapped; - } - - public String getName() { - return wrapped.name(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionResponse.java deleted file mode 100644 index 74fe13ec..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionResponse.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.jongsoft.finance.core.FailureCode; -import com.jongsoft.finance.domain.transaction.Transaction; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; -import java.util.List; - -@Serdeable.Serializable -public class TransactionResponse { - - private final transient Transaction wrapped; - - public TransactionResponse(Transaction wrapped) { - this.wrapped = wrapped; - } - - @Schema(description = "The identifier of this transaction", example = "1") - public long getId() { - return wrapped.getId(); - } - - @Schema(description = "The description of the transaction", example = "Purchase of flowers") - public String getDescription() { - return wrapped.getDescription(); - } - - @Schema(description = "The currency the transaction was in", example = "EUR") - public String getCurrency() { - return wrapped.getCurrency(); - } - - @Schema( - description = "The amount of money transferred from one account into the other", - example = "30.50") - public double getAmount() { - return wrapped.computeAmount(wrapped.computeTo()); - } - - @Schema(description = "The meta-information of the transaction") - public Metadata getMetadata() { - return new Metadata(); - } - - @Schema(description = "The type of transaction") - public Type getType() { - return new Type(); - } - - @Schema(description = "All dates relevant for this transaction") - public Dates getDates() { - return new Dates(); - } - - @Schema(description = "The account where the money went to") - public AccountResponse getDestination() { - return new AccountResponse(wrapped.computeTo()); - } - - @Schema(description = "The account where the money came from") - public AccountResponse getSource() { - return new AccountResponse(wrapped.computeFrom()); - } - - @Schema(description = "The multi-line split of the transaction, eg: purchased items") - public List getSplit() { - if (!wrapped.isSplit()) { - return null; - } - - var splitAccount = - switch (wrapped.computeType()) { - case DEBIT -> wrapped.computeFrom(); - case CREDIT -> wrapped.computeTo(); - case TRANSFER -> - throw new IllegalStateException("Split transaction cannot be a transfer"); - }; - - return wrapped - .getTransactions() - .filter(t -> t.getAccount().equals(splitAccount)) - .map(SplitAmount::new) - .toJava(); - } - - @Serdeable.Serializable - public class Metadata { - - @Schema( - description = "The category this transaction was linked to", - example = "Food related expenses") - public String getCategory() { - return wrapped.getCategory(); - } - - @Schema( - description = "The budget expense this transaction contributes to", - example = "Dining out") - public String getBudget() { - return wrapped.getBudget(); - } - - @Schema(description = "This transaction is part of this contract", example = "Weekly dining") - public String getContract() { - return wrapped.getContract(); - } - - @Schema( - description = "The import job that created the transaction", - example = "0b9b79faddd9ad388f3aa3b59048b7cd") - public String getImport() { - return wrapped.getImportSlug(); - } - - public FailureCode getFailureCode() { - return wrapped.getFailureCode(); - } - - @Schema(description = "The tags that the transaction has", example = "[\"food\",\"dining\"]") - public List getTags() { - return wrapped.getTags() != null ? wrapped.getTags().toJava() : null; - } - } - - @Serdeable.Serializable - public class Type { - - @Schema( - description = "The type of transaction", - allowableValues = {"\"CREDIT\"", "\"DEBIT\"", "\"TRANSFER\""}, - requiredMode = Schema.RequiredMode.REQUIRED) - public String getCode() { - return wrapped.computeType().name(); - } - - @JsonProperty("class") - @Schema( - description = "The font-awesome class for this transaction type", - example = "exchange-alt") - public String getClazz() { - return wrapped.computeType().getStyle(); - } - } - - @Serdeable.Serializable - public class Dates { - @Schema(description = "The date this transaction was created") - public LocalDate getTransaction() { - return wrapped.getDate(); - } - - @Schema(description = "The date the transaction was recorded into the books") - public LocalDate getBooked() { - return wrapped.getBookDate(); - } - - @Schema(description = "The date from which the transaction gets interest applied") - public LocalDate getInterest() { - return wrapped.getInterestDate(); - } - } - - @Serdeable.Serializable - public static class SplitAmount { - private final transient Transaction.Part wrapped; - - public SplitAmount(Transaction.Part wrapped) { - this.wrapped = wrapped; - } - - public String getDescription() { - return wrapped.getDescription(); - } - - public double getAmount() { - return Math.abs(wrapped.getAmount()); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionRuleGroupResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionRuleGroupResponse.java deleted file mode 100644 index 5aa10e40..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionRuleGroupResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.transaction.TransactionRuleGroup; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class TransactionRuleGroupResponse { - - private final TransactionRuleGroup wrapped; - - public TransactionRuleGroupResponse(TransactionRuleGroup wrapped) { - this.wrapped = wrapped; - } - - public String getName() { - return wrapped.getName(); - } - - public int getSort() { - return wrapped.getSort(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionRuleResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionRuleResponse.java deleted file mode 100644 index efb98f1d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/TransactionRuleResponse.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.core.RuleOperation; -import com.jongsoft.finance.domain.transaction.TransactionRule; -import com.jongsoft.lang.Collections; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -@Serdeable.Serializable -public class TransactionRuleResponse { - - private final TransactionRule wrapped; - - public TransactionRuleResponse(TransactionRule wrapped) { - this.wrapped = wrapped; - } - - public long getId() { - return wrapped.getId(); - } - - public String getName() { - return wrapped.getName(); - } - - @Schema(description = "The description for this transaction rule.") - public String getDescription() { - return wrapped.getDescription(); - } - - @Schema(description = "True if the rule is active in the system.") - public boolean isActive() { - return wrapped.isActive(); - } - - @Schema(description = "True if the rule terminates the flow of rule execution.") - public boolean isRestrictive() { - return wrapped.isRestrictive(); - } - - @Schema(description = "The sort order of the rule.") - public int getSort() { - return wrapped.getSort(); - } - - @Schema( - description = "The changes this rule will apply on any transaction matching the condition.") - public List getChanges() { - return Collections.List(wrapped.getChanges().map(Change::new)).toJava(); - } - - @Schema(description = "The conditions this rule will check for on any given transaction.") - public List getConditions() { - return Collections.List(wrapped.getConditions()).map(Condition::new).toJava(); - } - - @Serdeable.Serializable - public static class Change { - private final TransactionRule.Change wrapped; - - public Change(TransactionRule.Change wrapped) { - this.wrapped = wrapped; - } - - public long getId() { - return wrapped.getId(); - } - - public RuleColumn getField() { - return wrapped.getField(); - } - - public String getChange() { - return wrapped.getChange(); - } - } - - @Serdeable.Serializable - public static class Condition { - private final TransactionRule.Condition wrapped; - - public Condition(TransactionRule.Condition wrapped) { - this.wrapped = wrapped; - } - - public long getId() { - return wrapped.getId(); - } - - public RuleColumn getField() { - return wrapped.getField(); - } - - public RuleOperation getOperation() { - return wrapped.getOperation(); - } - - public String getCondition() { - return wrapped.getCondition(); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/UserProfileResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/UserProfileResponse.java deleted file mode 100644 index 880d052d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/model/UserProfileResponse.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.jongsoft.finance.rest.model; - -import com.jongsoft.finance.domain.user.UserAccount; -import com.jongsoft.lang.Control; -import io.micronaut.serde.annotation.Serdeable; -import java.util.Currency; - -@Serdeable.Serializable -public class UserProfileResponse { - - private final transient UserAccount wrappedModel; - - public UserProfileResponse(final UserAccount wrappedModel) { - this.wrappedModel = wrappedModel; - } - - public String getTheme() { - return wrappedModel.getTheme(); - } - - public String getCurrency() { - return Control.Option(wrappedModel.getPrimaryCurrency()) - .map(Currency::getCurrencyCode) - .getOrSupply(() -> null); - } - - public String getProfilePicture() { - return wrappedModel.getProfilePicture(); - } - - public boolean isMfa() { - return wrappedModel.isTwoFactorEnabled(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/ProcessTaskResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/ProcessTaskResource.java deleted file mode 100644 index 31bc0a7b..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/ProcessTaskResource.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_AUTOMATION_PROCESSES; - -import com.jongsoft.finance.rest.model.ProcessTaskResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.HashMap; -import java.util.List; -import org.camunda.bpm.engine.TaskService; -import org.camunda.bpm.engine.variable.Variables; - -@Tag(name = TAG_AUTOMATION_PROCESSES) -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/runtime-process/{processDefinitionKey}/{businessKey}/{instanceId}/tasks") -public class ProcessTaskResource { - - private final TaskService taskService; - - public ProcessTaskResource(TaskService taskService) { - this.taskService = taskService; - } - - @Get - @Operation( - summary = "List Tasks", - description = "List all available tasks for the provided process", - operationId = "getTasks") - public List tasks( - @PathVariable String processDefinitionKey, @PathVariable String instanceId) { - return Collections.List(taskService - .createTaskQuery() - .processDefinitionKey(processDefinitionKey) - .processInstanceId(instanceId) - .initializeFormKeys() - .list()) - .map(ProcessTaskResponse::new) - .toJava(); - } - - @Get("/{taskId}/variables") - @Operation( - summary = "Get Task", - description = "Get the details of the given task.", - operationId = "getTask") - public synchronized VariableMap variables( - @PathVariable String taskId, @Nullable @QueryValue String variable) { - var variableMap = new VariableMap(); - if (variable != null) { - variableMap.put(variable, taskService.getVariable(taskId, variable)); - } else { - taskService.getVariables(taskId).forEach(variableMap::put); - } - - return variableMap; - } - - @Post("/{taskId}/complete") - @Operation( - summary = "Complete Task", - description = "Completes the given task with the provided data.", - operationId = "completeTask") - public void complete(@PathVariable String taskId, @Body VariableMap variables) { - var javaMap = new HashMap(); - variables.keySet().forEach(key -> javaMap.put(key, variables.get(key))); - taskService.complete(taskId, Variables.fromMap(javaMap)); - } - - @Delete("/{taskId}") - @Operation( - summary = "Complete Task", - description = "Completes the given task without any additional data.", - operationId = "deleteTask") - public void complete(@PathVariable String taskId) { - taskService.complete(taskId); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/ProcessVariableResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/ProcessVariableResource.java deleted file mode 100644 index 48a6445a..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/ProcessVariableResource.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_AUTOMATION_PROCESSES; - -import com.jongsoft.finance.rest.model.ProcessVariableResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; -import org.camunda.bpm.engine.RuntimeService; - -@Tag(name = TAG_AUTOMATION_PROCESSES) -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/runtime-process/{processDefinitionKey}/{businessKey}/{instanceId}/variables") -public class ProcessVariableResource { - - private final RuntimeService runtimeService; - - public ProcessVariableResource(RuntimeService runtimeService) { - this.runtimeService = runtimeService; - } - - @Get - @Operation( - summary = "Get variables", - description = "This operation lists all process variables available for the provided process", - operationId = "getVariables") - public List variables( - @PathVariable String processDefinitionKey, @PathVariable String instanceId) { - return Collections.List(runtimeService - .createVariableInstanceQuery() - .processInstanceIdIn(instanceId) - .list()) - .map(ProcessVariableResponse::new) - .toJava(); - } - - @Get("/{variable}") - @Operation( - summary = "Get variable", - description = "This operation lists variables of a given name for a process", - operationId = "getVariable") - public List variable( - @PathVariable String processDefinitionKey, - @PathVariable String instanceId, - @PathVariable String variable) { - return Collections.List(runtimeService - .createVariableInstanceQuery() - .processInstanceIdIn(instanceId) - .variableName(variable) - .list()) - .map(ProcessVariableResponse::new) - .toJava(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/RuntimeResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/RuntimeResource.java deleted file mode 100644 index 93432a6b..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/RuntimeResource.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_AUTOMATION_PROCESSES; - -import com.jongsoft.finance.rest.model.ProcessResponse; -import com.jongsoft.finance.security.AuthenticationFacade; -import com.jongsoft.lang.Collections; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; -import java.util.Map; -import org.camunda.bpm.engine.HistoryService; -import org.camunda.bpm.engine.RuntimeService; - -@Tag(name = TAG_AUTOMATION_PROCESSES) -@Controller("/api/runtime-process") -@Secured(SecurityRule.IS_AUTHENTICATED) -public class RuntimeResource { - - private static final String KEY_USERNAME = "username"; - private final HistoryService historyService; - private final RuntimeService runtimeService; - private final AuthenticationFacade authenticationFacade; - - public RuntimeResource( - HistoryService historyService, - RuntimeService runtimeService, - AuthenticationFacade authenticationFacade) { - this.historyService = historyService; - this.runtimeService = runtimeService; - this.authenticationFacade = authenticationFacade; - } - - @Put("/{processDefinitionKey}/start") - @Operation( - summary = "Create Process", - description = "Creates and executes a new process for the selected definition, with the" - + " provided map as parameters", - operationId = "startProcess") - public ProcessResponse startProcess( - @PathVariable String processDefinitionKey, @Body Map parameters) { - - var instanceBuilder = runtimeService.createProcessInstanceByKey(processDefinitionKey); - parameters.forEach(instanceBuilder::setVariable); - - if (parameters.containsKey("businessKey")) { - instanceBuilder.businessKey(parameters.get("businessKey").toString()); - } - - instanceBuilder.setVariable(KEY_USERNAME, authenticationFacade.authenticated()); - - var process = instanceBuilder.execute(); - return new ProcessResponse(runtimeService - .createProcessInstanceQuery() - .processInstanceId(process.getProcessInstanceId()) - .singleResult()); - } - - @Get("/{processDefinitionKey}") - @Operation( - summary = "Process History", - description = "Lists the historic executions for the provided process definition key", - operationId = "getProcessHistory") - public List history(@PathVariable String processDefinitionKey) { - return Collections.List(runtimeService - .createProcessInstanceQuery() - .processDefinitionKey(processDefinitionKey) - .variableValueEquals(KEY_USERNAME, authenticationFacade.authenticated()) - .orderByProcessInstanceId() - .desc() - .list()) - .map(ProcessResponse::new) - .toJava(); - } - - @Get("/{processDefinitionKey}/{businessKey}") - @Operation( - summary = "Process History for key", - description = "List the history executions for the provided definition key, but only once" - + " with matching business key", - operationId = "getProcessHistoryByBusinessKey") - public List history( - @PathVariable String processDefinitionKey, @PathVariable String businessKey) { - return Collections.List(runtimeService - .createProcessInstanceQuery() - .processDefinitionKey(processDefinitionKey) - .processInstanceBusinessKey(businessKey) - .variableValueEquals(KEY_USERNAME, authenticationFacade.authenticated()) - .orderByProcessInstanceId() - .desc() - .list()) - .map(ProcessResponse::new) - .toJava(); - } - - @Status(HttpStatus.NO_CONTENT) - @Delete("/{processDefinitionKey}/{businessKey}/{instanceId}") - @Operation( - summary = "Delete Process", - description = "Removes a active process from the execution list", - operationId = "deleteProcess", - responses = @ApiResponse(responseCode = "204")) - public void deleteProcess( - @PathVariable String processDefinitionKey, - @PathVariable String businessKey, - @PathVariable String instanceId) { - runtimeService.deleteProcessInstance(instanceId, "User termination"); - } - - @Get("/clean-up") - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Force History Clean", - description = "Trigger a history clean-up job to run", - operationId = "triggerHistoryCleaning", - responses = @ApiResponse(responseCode = "204")) - public void cleanHistory() { - historyService.cleanUpHistoryAsync(true); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/VariableMap.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/VariableMap.java deleted file mode 100644 index 5fb49e2e..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/process/VariableMap.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import com.jongsoft.finance.ProcessVariable; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Set; - -@Serdeable -@Schema(name = "VariableMap", description = "A map of variables used in tasks.") -public class VariableMap { - @Serdeable - @Schema(name = "VariableList", description = "A list of variables wrapped for the task.") - public record VariableList(List content) implements ProcessVariable {} - - @Serdeable - @Schema(name = "WrappedVariable", description = "A variable wrapped for the task.") - public record WrappedVariable( - @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "_type") T value) - implements ProcessVariable {} - - @Schema(description = "The actual map of all the variables set for the task.") - private HashMap variables = new HashMap<>(); - - public T get(String key) { - return (T) convertFrom(variables.get(key)); - } - - public void put(String key, Object value) { - variables.put(key, convertTo(value)); - } - - public Set keySet() { - return variables.keySet(); - } - - public void setVariables(HashMap variables) { - this.variables = variables; - } - - HashMap getVariables() { - return variables; - } - - private ProcessVariable convertTo(Object value) { - if (value instanceof Collection list) { - return new VariableList(list.stream().map(this::convertTo).toList()); - } else if (value instanceof ProcessVariable variable) { - return variable; - } else { - return new WrappedVariable<>(value); - } - } - - private Object convertFrom(ProcessVariable value) { - if (value instanceof VariableList list) { - return list.content.stream().map(this::convertFrom).toList(); - } else if (value instanceof WrappedVariable wrapped) { - return wrapped.value; - } - - return value; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/MultiFactorRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/MultiFactorRequest.java deleted file mode 100644 index 7877eda1..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/MultiFactorRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -@Serdeable.Deserializable -record MultiFactorRequest(@NotNull @Size(min = 4, max = 8) String verificationCode) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/PatchProfileRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/PatchProfileRequest.java deleted file mode 100644 index 2bb3810e..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/PatchProfileRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Deserializable -public record PatchProfileRequest(String theme, String currency, String password) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/ProfileExportResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/ProfileExportResource.java deleted file mode 100644 index 8f1d384f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/ProfileExportResource.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SECURITY_USERS; - -import com.jongsoft.finance.Exportable; -import com.jongsoft.finance.StorageService; -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.account.Contract; -import com.jongsoft.finance.domain.transaction.Tag; -import com.jongsoft.finance.domain.transaction.TransactionRule; -import com.jongsoft.finance.domain.user.Budget; -import com.jongsoft.finance.domain.user.Category; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.DataProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.security.AuthenticationFacade; -import com.jongsoft.finance.serialized.*; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.collection.Sequence; -import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import java.util.List; -import java.util.function.Supplier; - -@Controller("/api/profile/export") -@Secured(SecurityRule.IS_AUTHENTICATED) -@io.swagger.v3.oas.annotations.tags.Tag(name = TAG_SECURITY_USERS) -public class ProfileExportResource { - - private final AuthenticationFacade authenticationFacade; - private final List> exportable; - private final List> dataProviders; - private final StorageService storageService; - private final TransactionProvider transactionProvider; - private final FilterFactory filterFactory; - - public ProfileExportResource( - AuthenticationFacade authenticationFacade, - List> exportable, - List> dataProviders, - StorageService storageService, - TransactionProvider transactionProvider, - FilterFactory filterFactory) { - this.authenticationFacade = authenticationFacade; - this.exportable = exportable; - this.dataProviders = dataProviders; - this.storageService = storageService; - this.transactionProvider = transactionProvider; - this.filterFactory = filterFactory; - } - - @Get - @Operation( - summary = "Export to JSON", - description = "Exports the profile of the authenticated user to JSON", - operationId = "exportProfile") - public HttpResponse export() { - var exportFileName = authenticationFacade.authenticated() + "-profile.json"; - var exportJson = ExportJson.builder() - .accounts(lookupAllOf(Account.class) - .map(account -> - AccountJson.fromDomain(account, loadFromStorage(account.getImageFileToken()))) - .toJava()) - .budgetPeriods(lookupAllOf(Budget.class).map(BudgetJson::fromDomain).toJava()) - .categories(lookupAllOf(Category.class).map(CategoryJson::fromDomain).toJava()) - .tags(lookupAllOf(Tag.class).map(Tag::name).toJava()) - .contracts(lookupAllOf(Contract.class) - .map(c -> ContractJson.fromDomain(c, loadFromStorage(c.getFileToken()))) - .toJava()) - .rules(lookupAllOf(TransactionRule.class) - .map(rule -> RuleConfigJson.RuleJson.fromDomain(rule, this::loadRelation)) - .toJava()) - .transactions(lookupRelevantTransactions()) - .build(); - - return HttpResponse.ok(exportJson) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + exportFileName + "\"") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); - } - - private Supplier loadFromStorage(String fileToken) { - return () -> storageService.read(fileToken).getOrSupply(() -> new byte[0]); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - private Sequence lookupAllOf(Class forClass) { - for (Exportable exporter : exportable) { - if (exporter.supports(forClass)) { - return ((Exportable) exporter).lookup(); - } - } - - return Collections.List(); - } - - private List lookupRelevantTransactions() { - // we also want to export all opening balance transactions for liability accounts - var filter = - filterFactory.transaction().page(0, Integer.MAX_VALUE).description("Opening balance", true); - - return transactionProvider - .lookup(filter) - .content() - .map(TransactionJson::fromDomain) - .toJava(); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - private String loadRelation(RuleColumn column, String value) { - if (column == RuleColumn.TAGS) { - return value; - } - - Class genericType = - switch (column) { - case TO_ACCOUNT, SOURCE_ACCOUNT, CHANGE_TRANSFER_FROM, CHANGE_TRANSFER_TO -> - Account.class; - case CATEGORY -> Category.class; - case BUDGET -> Budget.Expense.class; - case CONTRACT -> Contract.class; - default -> throw new IllegalArgumentException("Unsupported type"); - }; - - for (DataProvider provider : dataProviders) { - if (provider.supports(genericType)) { - return provider.lookup(Long.parseLong(value)).get().toString(); - } - } - - return null; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/ProfileResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/ProfileResource.java deleted file mode 100644 index 517d75e9..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/ProfileResource.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SECURITY_USERS; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.FinTrack; -import com.jongsoft.finance.providers.UserProvider; -import com.jongsoft.finance.rest.model.SessionResponse; -import com.jongsoft.finance.rest.model.UserProfileResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import com.jongsoft.finance.security.TwoFactorHelper; -import dev.samstevens.totp.exceptions.QrGenerationException; -import dev.samstevens.totp.qr.ZxingPngQrGenerator; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.temporal.ChronoUnit; -import java.util.Currency; -import java.util.List; -import java.util.UUID; - -@Tag(name = TAG_SECURITY_USERS) -@Controller("/api/profile") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class ProfileResource { - - private final FinTrack application; - private final CurrentUserProvider currentUserProvider; - private final UserProvider userProvider; - - public ProfileResource( - FinTrack application, CurrentUserProvider currentUserProvider, UserProvider userProvider) { - this.application = application; - this.currentUserProvider = currentUserProvider; - this.userProvider = userProvider; - } - - @Get - @Operation(operationId = "getProfile", summary = "Get profile of authenticated user") - public UserProfileResponse get() { - return new UserProfileResponse(currentUserProvider.currentUser()); - } - - @Patch - @Operation( - operationId = "patchProfile", - summary = "Update part of the user profile", - description = "This change will be applied to the authenticated user") - public UserProfileResponse patch(@Body PatchProfileRequest request) { - var userAccount = currentUserProvider.currentUser(); - - if (request.currency() != null) { - userAccount.changeCurrency(Currency.getInstance(request.currency())); - } - - if (request.theme() != null) { - userAccount.changeTheme(request.theme()); - } - - if (request.password() != null) { - userAccount.changePassword(application.getHashingAlgorithm().encrypt(request.password())); - } - - return new UserProfileResponse(userAccount); - } - - @Get(value = "/sessions") - @Operation( - operationId = "getActiveSessions", - summary = "Active Sessions", - description = "Get a list of active session for the current user.") - List sessions() { - return userProvider - .tokens(currentUserProvider.currentUser().getUsername()) - .map(SessionResponse::new) - .toJava(); - } - - @Put(value = "/sessions") - @Operation( - operationId = "createToken", - summary = "Create session token", - description = "Create a new session token that has a longer validity then default" - + " authentication tokens.") - List createSession(@Body @Valid TokenCreateRequest request) { - application.registerToken( - currentUserProvider.currentUser().getUsername().email(), - UUID.randomUUID().toString(), - (int) ChronoUnit.SECONDS.between( - LocalDateTime.now(), request.expires().atTime(LocalTime.MIN))); - - return sessions(); - } - - @Delete(value = "/sessions/{id}") - @Status(HttpStatus.NO_CONTENT) - void deleteSession(@PathVariable long id) { - userProvider - .tokens(currentUserProvider.currentUser().getUsername()) - .filter(token -> token.getId() == id) - .head() - .revoke(); - } - - @Get(value = "/multi-factor/qr-code", produces = MediaType.IMAGE_PNG) - @Operation( - operationId = "getQrCode", - summary = "QR Code", - description = "Use this API to obtain a QR code that can be used to scan in a 2-factor" - + " application") - byte[] qrCode() { - var qrCode = TwoFactorHelper.build2FactorQr(currentUserProvider.currentUser()); - try { - var generator = new ZxingPngQrGenerator(); - generator.setImageSize(150); - return generator.generate(qrCode); - } catch (QrGenerationException e) { - throw StatusException.internalError("Could not successfully generate QR code"); - } - } - - @Post("/multi-factor/enable") - @Status(HttpStatus.NO_CONTENT) - @Operation( - operationId = "enable2Factor", - summary = "Enable 2-factor authentication", - description = "This will activate 2-factor authentication when the security code matches the" - + " one recorded") - void enableMfa(@Body @Valid MultiFactorRequest multiFactorRequest) { - var userAccount = currentUserProvider.currentUser(); - if (!TwoFactorHelper.verifySecurityCode( - userAccount.getSecret(), multiFactorRequest.verificationCode())) { - throw StatusException.badRequest("Invalid verification code provided."); - } - - userAccount.enableMultiFactorAuthentication(); - } - - @Post("/multi-factor/disable") - @Status(HttpStatus.NO_CONTENT) - @Operation( - operationId = "disable2Factor", - summary = "Disable 2-factor authentication", - description = "This operation will disable 2-factor authentication, but will only work if it" - + " was enabled on the authorized account") - void disableMfa() { - currentUserProvider.currentUser().disableMultiFactorAuthentication(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/TokenCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/TokenCreateRequest.java deleted file mode 100644 index 0a7738af..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/profile/TokenCreateRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import io.micronaut.serde.annotation.Serdeable; -import java.time.LocalDate; - -@Serdeable.Deserializable -public record TokenCreateRequest(String description, LocalDate expires) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduleSearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduleSearchRequest.java deleted file mode 100644 index c9f4e476..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduleSearchRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.jongsoft.finance.rest.scheduler; - -import com.jongsoft.lang.Control; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotNull; -import java.util.List; - -@Serdeable.Deserializable -public class ScheduleSearchRequest { - - public ScheduleSearchRequest(List contracts, List accounts) { - this.contracts = contracts; - this.accounts = accounts; - } - - @Serdeable.Deserializable - public record EntityRef(@NotNull long id) {} - - private final List contracts; - - private final List accounts; - - public List getAccounts() { - return Control.Option(accounts).getOrSupply(List::of); - } - - public List getContracts() { - return Control.Option(contracts).getOrSupply(List::of); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionCreateRequest.java deleted file mode 100644 index c7bdf438..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionCreateRequest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.jongsoft.finance.rest.scheduler; - -import com.jongsoft.finance.schedule.Periodicity; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@Serdeable.Deserializable -public class ScheduledTransactionCreateRequest { - - @Serdeable.Deserializable - public record EntityRef(@NotNull Long id, String name) {} - - @Serdeable.Deserializable - public record ScheduleValue(@NotNull Periodicity periodicity, @Min(1) int interval) {} - - @NotBlank - private final String name; - - private final double amount; - - @NotNull - private final EntityRef source; - - @NotNull - private final EntityRef destination; - - @NotNull - private final ScheduleValue schedule; - - public ScheduledTransactionCreateRequest( - String name, double amount, EntityRef source, EntityRef destination, ScheduleValue schedule) { - this.name = name; - this.amount = amount; - this.source = source; - this.destination = destination; - this.schedule = schedule; - } - - public com.jongsoft.finance.domain.transaction.ScheduleValue getSchedule() { - return new com.jongsoft.finance.domain.transaction.ScheduleValue( - schedule.periodicity, schedule.interval); - } - - public String getName() { - return name; - } - - public double getAmount() { - return amount; - } - - public EntityRef getSource() { - return source; - } - - public EntityRef getDestination() { - return destination; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionPatchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionPatchRequest.java deleted file mode 100644 index f872beab..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionPatchRequest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.jongsoft.finance.rest.scheduler; - -import com.jongsoft.finance.schedule.Periodicity; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.Valid; -import java.time.LocalDate; - -@Serdeable.Deserializable -public class ScheduledTransactionPatchRequest { - - public ScheduledTransactionPatchRequest( - DateRange range, ScheduleValue schedule, String name, String description) { - this.range = range; - this.schedule = schedule; - this.name = name; - this.description = description; - } - - @Serdeable.Deserializable - public record DateRange(LocalDate start, LocalDate end) {} - - @Serdeable.Deserializable - public record ScheduleValue(Periodicity periodicity, int interval) {} - - @Valid - private final DateRange range; - - @Valid - private final ScheduleValue schedule; - - private final String name; - private final String description; - - public com.jongsoft.finance.domain.transaction.ScheduleValue getSchedule() { - if (schedule != null) { - return new com.jongsoft.finance.domain.transaction.ScheduleValue( - schedule.periodicity, schedule.interval); - } - - return null; - } - - public DateRange getRange() { - return range; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionResource.java deleted file mode 100644 index d60aa1a2..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionResource.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.jongsoft.finance.rest.scheduler; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.TransactionScheduleProvider; -import com.jongsoft.finance.rest.model.ScheduledTransactionResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; -import java.util.Objects; - -@Tag(name = "Automation::Scheduling") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/schedule/transaction") -public class ScheduledTransactionResource { - - private final AccountProvider accountProvider; - private final TransactionScheduleProvider scheduleProvider; - private final FilterFactory filterFactory; - - public ScheduledTransactionResource( - AccountProvider accountProvider, - TransactionScheduleProvider scheduleProvider, - FilterFactory filterFactory) { - this.accountProvider = accountProvider; - this.scheduleProvider = scheduleProvider; - this.filterFactory = filterFactory; - } - - @Get - @Operation( - operationId = "listTransactionSchedule", - summary = "List all available transaction schedules") - public List list() { - return scheduleProvider.lookup().map(ScheduledTransactionResponse::new).toJava(); - } - - @Put - @Operation( - operationId = "createTransactionSchedule", - summary = "Create a new transaction schedule") - @Status(HttpStatus.CREATED) - public ScheduledTransactionResponse create( - @Valid @Body ScheduledTransactionCreateRequest request) { - var account = accountProvider.lookup(request.getSource().id()); - var destination = accountProvider.lookup(request.getDestination().id()); - - if (!account.isPresent() || !destination.isPresent()) { - throw StatusException.badRequest("Either source or destination account cannot be located."); - } - - account - .get() - .createSchedule( - request.getName(), request.getSchedule(), destination.get(), request.getAmount()); - - return scheduleProvider - .lookup() - .filter(schedule -> request.getName().equals(schedule.getName())) - .map(ScheduledTransactionResponse::new) - .head(); - } - - @Post - @Operation(operationId = "searchTransactionSchedule", summary = "Search schedule") - public List search(@Valid @Body ScheduleSearchRequest request) { - var filter = filterFactory - .schedule() - .contract(Collections.List(request.getContracts()).map(c -> new EntityRef(c.id()))) - .activeOnly(); - - return scheduleProvider - .lookup(filter) - .map(ScheduledTransactionResponse::new) - .content() - .toJava(); - } - - @Get("/{scheduleId}") - @Operation( - operationId = "fetchTransactionSchedule", - summary = "Get a single transaction schedule", - description = "Lookup a transaction schedule in the system by its technical id", - parameters = - @Parameter( - name = "scheduleId", - description = "The technical id of the transaction schedule", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH)) - public ScheduledTransactionResponse get(@PathVariable long scheduleId) { - var scheduleOption = scheduleProvider - .lookup() - .filter(s -> s.getId() == scheduleId) - .map(ScheduledTransactionResponse::new); - - if (scheduleOption.isEmpty()) { - throw StatusException.notFound("No scheduled transaction found with id " + scheduleId); - } - - return scheduleOption.head(); - } - - @Patch("/{scheduleId}") - @Operation( - operationId = "patchTransactionSchedule", - summary = "Update part of a transaction schedule", - parameters = - @Parameter( - name = "scheduleId", - description = "The technical id of the transaction schedule", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH)) - public ScheduledTransactionResponse patch( - @PathVariable long scheduleId, @Valid @Body ScheduledTransactionPatchRequest request) { - var scheduleOption = scheduleProvider.lookup().filter(s -> s.getId() == scheduleId); - - if (scheduleOption.isEmpty()) { - throw StatusException.notFound("No scheduled transaction found with id " + scheduleId); - } - - var schedule = scheduleOption.head(); - if (Objects.nonNull(request.getName())) { - schedule.describe(request.getName(), request.getDescription()); - } - - if (Objects.nonNull(request.getSchedule())) { - schedule.adjustSchedule( - request.getSchedule().periodicity(), request.getSchedule().interval()); - } - - if (Objects.nonNull(request.getRange())) { - schedule.limit(request.getRange().start(), request.getRange().end()); - } - - return new ScheduledTransactionResponse(schedule); - } - - @Delete("/{scheduleId}") - @Status(HttpStatus.NO_CONTENT) - @Operation( - operationId = "removeTransactionSchedule", - summary = "Remove a transaction schedule", - parameters = - @Parameter( - name = "scheduleId", - description = "The technical id of the transaction schedule", - schema = @Schema(implementation = Long.class), - in = ParameterIn.PATH)) - public void remove(@PathVariable long scheduleId) { - var toRemove = scheduleProvider.lookup().filter(schedule -> schedule.getId() == scheduleId); - - if (toRemove.isEmpty()) { - throw StatusException.notFound("No scheduled transaction found with id " + scheduleId); - } - - toRemove.get().terminate(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/AuthenticationRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/AuthenticationRequest.java deleted file mode 100644 index 7c9f5073..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/AuthenticationRequest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotNull; - -@Serdeable.Deserializable -class AuthenticationRequest - implements io.micronaut.security.authentication.AuthenticationRequest { - - @Email - @NotNull - @Schema( - description = "The username, must be a valid e-mail address.", - required = true, - implementation = String.class, - example = "me@example.com") - private String username; - - @NotNull - @Schema( - description = "The password", - required = true, - implementation = String.class, - example = "password123") - private String password; - - public AuthenticationRequest( - @Email @NotNull final String username, @NotNull final String password) { - this.username = username; - this.password = password; - } - - @Override - @JsonIgnore - public String getIdentity() { - return username; - } - - public String getUsername() { - return username; - } - - public String getPassword() { - return password; - } - - @Override - @JsonIgnore - public String getSecret() { - return password; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/MultiFactorRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/MultiFactorRequest.java deleted file mode 100644 index 28c3af5b..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/MultiFactorRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -@Serdeable.Deserializable -record MultiFactorRequest( - @NotNull - @Size(min = 4, max = 8) - @Schema( - description = "The 2-factor verification code from a hardware device.", - required = true, - pattern = "[\\d]{6}", - implementation = String.class) - String verificationCode) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/MultiFactorResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/MultiFactorResource.java deleted file mode 100644 index f85b1576..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/MultiFactorResource.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SECURITY; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.user.Role; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import com.jongsoft.finance.security.TwoFactorHelper; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.MediaType; -import io.micronaut.http.MutableHttpResponse; -import io.micronaut.http.annotation.Body; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Post; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.authentication.Authentication; -import io.micronaut.security.handlers.LoginHandler; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.UUID; - -@Tag(name = TAG_SECURITY) -@Controller(consumes = MediaType.APPLICATION_JSON, value = "/api/security/2-factor") -public class MultiFactorResource { - - private final CurrentUserProvider currentUserProvider; - private final LoginHandler, MutableHttpResponse> loginHandler; - - public MultiFactorResource( - CurrentUserProvider currentUserProvider, - LoginHandler, MutableHttpResponse> loginHandler) { - this.currentUserProvider = currentUserProvider; - this.loginHandler = loginHandler; - } - - @Post - @ApiDefaults - @Secured(AuthenticationRoles.TWO_FACTOR_NEEDED) - @Operation( - summary = "Verify MFA token", - description = "Used to verify the user token against that what is expected. If valid the user" - + " will get a new JWT with updated authorizations.") - HttpResponse validateToken( - @Valid @Body MultiFactorRequest verification, HttpRequest request) { - var user = currentUserProvider.currentUser(); - if (!TwoFactorHelper.verifySecurityCode(user.getSecret(), verification.verificationCode())) { - throw StatusException.forbidden("Invalid verification code"); - } - - var authentication = Authentication.build( - user.getUsername().email(), user.getRoles().stream().map(Role::getName).toList()); - - return loginHandler.loginRefresh(authentication, UUID.randomUUID().toString(), request); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/OpenIdResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/OpenIdResource.java deleted file mode 100644 index 57d83d69..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/OpenIdResource.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SECURITY; - -import com.jongsoft.finance.security.OpenIdConfiguration; -import io.micronaut.context.annotation.Requires; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Controller -@Requires(env = "openid") -@Tag(name = TAG_SECURITY) -public class OpenIdResource { - - private final OpenIdConfiguration configuration; - - public OpenIdResource(OpenIdConfiguration openIdConfiguration) { - this.configuration = openIdConfiguration; - } - - @Secured(SecurityRule.IS_ANONYMOUS) - @Get(value = "/.well-known/openid-connect") - @Operation( - summary = "Get the OpenId Connect", - description = "Use this operation to get the OpenId connect details.") - public OpenIdConfiguration openIdConfiguration() { - return configuration; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/RegistrationResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/RegistrationResource.java deleted file mode 100644 index d993d1f6..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/RegistrationResource.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SECURITY; - -import com.jongsoft.finance.domain.FinTrack; -import com.jongsoft.finance.domain.user.UserIdentifier; -import com.jongsoft.finance.rest.ApiDefaults; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Body; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.Put; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.micronaut.security.token.jwt.signature.rsa.RSASignatureConfiguration; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.Map; -import org.camunda.bpm.engine.ProcessEngine; -import org.camunda.bpm.engine.impl.digest._apacheCommonsCodec.Base64; - -@Tag(name = TAG_SECURITY) -@Controller(consumes = MediaType.APPLICATION_JSON) -public class RegistrationResource { - - private final RSASignatureConfiguration rsaSignatureConfiguration; - private final FinTrack application; - private final ProcessEngine processEngine; - - public RegistrationResource( - RSASignatureConfiguration rsaSignatureConfiguration, - FinTrack application, - ProcessEngine processEngine) { - this.rsaSignatureConfiguration = rsaSignatureConfiguration; - this.application = application; - this.processEngine = processEngine; - } - - @ApiDefaults - @Secured(SecurityRule.IS_ANONYMOUS) - @Put("/api/security/create-account") - @Operation( - summary = "Create account", - description = "Creates a new account", - operationId = "createAccount") - @ApiResponse(responseCode = "201", content = @Content(schema = @Schema(nullable = true))) - HttpResponse createAccount(@Valid @Body AuthenticationRequest authenticationRequest) { - processEngine - .getRuntimeService() - .startProcessInstanceByKey( - "RegisterUserAccount", - Map.of( - "username", - new UserIdentifier(authenticationRequest.getIdentity()), - "passwordHash", - application.getHashingAlgorithm().encrypt(authenticationRequest.getSecret()))); - - return HttpResponse.created((Void) null); - } - - @Secured(SecurityRule.IS_ANONYMOUS) - @Get(value = "/.well-known/public-key") - @Operation( - summary = "Get the signing key", - description = "Use this operation to obtain the public signing key used to sign the JWT.") - public String publicKey() { - return Base64.encodeBase64String(rsaSignatureConfiguration.getPublicKey().getEncoded()); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/TokenRefreshRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/TokenRefreshRequest.java deleted file mode 100644 index c57bccd7..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/security/TokenRefreshRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; - -@Serdeable.Deserializable -public class TokenRefreshRequest { - - @NotBlank - @Schema( - description = - "The refresh token that, this can be obtained from the JWT provided after" + " login.", - required = true, - implementation = String.class) - private String token; - - public String getToken() { - return token; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyPatchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyPatchRequest.java deleted file mode 100644 index 3ddaee8d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyPatchRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Deserializable -public record CurrencyPatchRequest(Integer decimalPlaces, Boolean enabled) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyRequest.java deleted file mode 100644 index a75f6f81..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -@Serdeable.Deserializable -public record CurrencyRequest( - @NotBlank @Size(max = 255) String name, - @NotBlank @Size(min = 1, max = 3) String code, - @NotNull @Size(min = 1, max = 1) String symbol) { - public char getSymbol() { - return symbol.charAt(0); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyResource.java deleted file mode 100644 index 8a36fbff..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/CurrencyResource.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SETTINGS_CURRENCIES; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.core.Currency; -import com.jongsoft.finance.providers.CurrencyProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.rest.model.CurrencyResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.http.hateoas.JsonError; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; - -@Tag(name = TAG_SETTINGS_CURRENCIES) -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/settings/currencies") -public class CurrencyResource { - - private static final String NO_CURRENCY_WITH_CODE_MESSAGE = "No currency exists with code "; - private final CurrencyProvider currencyProvider; - - public CurrencyResource(CurrencyProvider currencyProvider) { - this.currencyProvider = currencyProvider; - } - - @Get - @Operation( - summary = "List all", - description = "List all available currencies in the system", - operationId = "getAllCurrencies") - public List available() { - return currencyProvider.lookup().map(CurrencyResponse::new).toJava(); - } - - @Put - @Secured(AuthenticationRoles.IS_ADMIN) - @Status(HttpStatus.CREATED) - @Operation( - summary = "Create currency", - description = "Add a new currency to the system", - operationId = "createCurrency") - @ApiResponse( - responseCode = "201", - content = @Content(schema = @Schema(implementation = CurrencyResponse.class))) - public CurrencyResponse create(@Valid @Body CurrencyRequest request) { - var existing = currencyProvider.lookup(request.code()); - if (existing.isPresent()) { - throw StatusException.badRequest("Currency with code " + request.code() + " already exists"); - } - - return new CurrencyResponse(new Currency(request.name(), request.code(), request.getSymbol())); - } - - @Get("/{currencyCode}") - @Operation( - summary = "Get currency", - description = "Returns an existing currency in the syste,", - operationId = "getCurrency", - responses = { - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = CurrencyResponse.class)), - description = "The currency entity"), - @ApiResponse( - responseCode = "404", - content = @Content(schema = @Schema(implementation = JsonError.class)), - description = "The exception that occurred") - }) - @ApiDefaults - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = CurrencyResponse.class)), - description = "The currency entity") - public CurrencyResponse get(@PathVariable String currencyCode) { - return currencyProvider - .lookup(currencyCode) - .map(CurrencyResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CURRENCY_WITH_CODE_MESSAGE + currencyCode)); - } - - @Secured(AuthenticationRoles.IS_ADMIN) - @Post("/{currencyCode}") - @Operation( - summary = "Update currency", - description = "Updates an existing currency in the system", - operationId = "updateCurrency", - responses = { - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = CurrencyResponse.class)), - description = "The currency entity"), - @ApiResponse( - responseCode = "404", - content = @Content(schema = @Schema(implementation = JsonError.class)), - description = "The exception that occurred") - }) - @ApiDefaults - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = CurrencyResponse.class)), - description = "The currency entity") - public CurrencyResponse update( - @PathVariable String currencyCode, @Valid @Body CurrencyRequest request) { - return currencyProvider - .lookup(currencyCode) - .map(currency -> { - currency.rename(request.name(), request.code(), request.getSymbol()); - return currency; - }) - .map(CurrencyResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CURRENCY_WITH_CODE_MESSAGE + currencyCode)); - } - - @Secured(AuthenticationRoles.IS_ADMIN) - @Patch("/{currencyCode}") - @Operation( - summary = "Patch currency", - description = "Partially update an existing currency in the system", - operationId = "patchCurrency") - @ApiDefaults - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = CurrencyResponse.class)), - description = "The currency entity") - public CurrencyResponse patch( - @PathVariable String currencyCode, @Valid @Body CurrencyPatchRequest request) { - return currencyProvider - .lookup(currencyCode) - .map(currency -> { - if (request.enabled() != null) { - if (request.enabled()) { - currency.enable(); - } else { - currency.disable(); - } - } - - if (request.decimalPlaces() != null) { - currency.accuracy(request.decimalPlaces()); - } - - return currency; - }) - .map(CurrencyResponse::new) - .getOrThrow(() -> StatusException.notFound(NO_CURRENCY_WITH_CODE_MESSAGE + currencyCode)); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/SettingResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/SettingResource.java deleted file mode 100644 index 04675e77..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/SettingResource.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_SETTINGS; - -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.rest.model.SettingResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; - -@Secured(AuthenticationRoles.IS_ADMIN) -@Controller("/api/settings") -@Tag(name = TAG_SETTINGS) -public class SettingResource { - - private final SettingProvider settingProvider; - - public SettingResource(SettingProvider settingProvider) { - this.settingProvider = settingProvider; - } - - @Get - @Operation( - summary = "Get settings", - description = "List all available settings in the system", - operationId = "getSettings") - @Secured({SecurityRule.IS_ANONYMOUS, SecurityRule.IS_AUTHENTICATED}) - List list() { - return settingProvider - .lookup() - .map(setting -> - new SettingResponse(setting.getName(), setting.getValue(), setting.getType())) - .toJava(); - } - - @Post("/{setting}") - @Operation( - summary = "Update setting", - description = "Update a single setting in the system", - operationId = "updateSettings") - @ApiResponse(responseCode = "204") - void update(@PathVariable String setting, @Body SettingUpdateRequest request) { - settingProvider.lookup(setting).ifPresent(value -> value.update(request.value())); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/SettingUpdateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/SettingUpdateRequest.java deleted file mode 100644 index e86f4289..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/setting/SettingUpdateRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Deserializable -public record SettingUpdateRequest(String value) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalancePartitionResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalancePartitionResponse.java deleted file mode 100644 index 34e7e3a8..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalancePartitionResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable.Serializable -public class BalancePartitionResponse { - - private final String partition; - private final double balance; - - public BalancePartitionResponse(String partition, double balance) { - this.partition = partition; - this.balance = balance; - } - - public String getPartition() { - return partition; - } - - public double getBalance() { - return balance; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceRequest.java deleted file mode 100644 index 7b776dce..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceRequest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import com.jongsoft.finance.core.AggregateBase; -import com.jongsoft.lang.Control; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; -import java.util.List; - -@Serdeable -public class BalanceRequest { - - @Serdeable - public record DateRange( - @Schema( - description = "Earliest date a transaction may be.", - implementation = String.class, - format = "yyyy-mm-dd") - LocalDate start, - @Schema( - description = "Latest date a transaction may be.", - implementation = String.class, - format = "yyyy-mm-dd") - LocalDate end) {} - - @Serdeable - public static class EntityRef implements AggregateBase { - - @Schema(description = "The unique identifier of the entity", required = true) - private final Long id; - - private final String name; - - public EntityRef(Long id, String name) { - this.id = id; - this.name = name; - } - - @Override - public Long getId() { - return id; - } - } - - private final List accounts; - private final List categories; - private final List contracts; - private final List expenses; - private final DateRange dateRange; - - @Schema(description = "Indicator if only income or only expense should be included") - private final boolean onlyIncome; - - @Schema(description = "Indicator that all money (income and expense) should be included") - private final boolean allMoney; - - @Schema(description = "The currency the transaction should be in") - private final String currency; - - private final String importSlug; - - public BalanceRequest( - List accounts, - List categories, - List contracts, - List expenses, - DateRange dateRange, - boolean onlyIncome, - boolean allMoney, - String currency, - String importSlug) { - this.accounts = accounts; - this.categories = categories; - this.contracts = contracts; - this.expenses = expenses; - this.dateRange = dateRange; - this.onlyIncome = onlyIncome; - this.allMoney = allMoney; - this.currency = currency; - this.importSlug = importSlug; - } - - public List getAccounts() { - return Control.Option(accounts).getOrSupply(List::of); - } - - public List getCategories() { - return Control.Option(categories).getOrSupply(List::of); - } - - public List getContracts() { - return Control.Option(contracts).getOrSupply(List::of); - } - - public List getExpenses() { - return Control.Option(expenses).getOrSupply(List::of); - } - - public DateRange getDateRange() { - return dateRange; - } - - public boolean onlyIncome() { - return onlyIncome; - } - - public boolean allMoney() { - return allMoney; - } - - public String currency() { - return currency; - } - - public String importSlug() { - return importSlug; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceResource.java deleted file mode 100644 index 1e718430..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceResource.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_REPORTS; - -import com.jongsoft.finance.core.AggregateBase; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.CategoryProvider; -import com.jongsoft.finance.providers.ExpenseProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Dates; -import com.jongsoft.lang.collection.Sequence; -import io.micronaut.context.ApplicationContext; -import io.micronaut.http.annotation.Body; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.http.annotation.Post; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Function; - -@Tag(name = TAG_REPORTS) -@Controller("/api/statistics/balance") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class BalanceResource { - - private final FilterFactory filterFactory; - private final TransactionProvider transactionProvider; - private final ApplicationContext applicationContext; - - public BalanceResource( - FilterFactory filterFactory, - TransactionProvider transactionProvider, - ApplicationContext applicationContext) { - this.filterFactory = filterFactory; - this.transactionProvider = transactionProvider; - this.applicationContext = applicationContext; - } - - @Post - @Operation( - summary = "Calculate balance", - description = "This operation will calculate the balance for the current user based upon the" - + " given filters", - operationId = "getBalance") - BalanceResponse calculate(@Valid @Body BalanceRequest request) { - TransactionProvider.FilterCommand filter = buildFilterCommand(request); - - var balance = transactionProvider.balance(filter).getOrSupply(() -> BigDecimal.ZERO); - - return new BalanceResponse(balance.doubleValue()); - } - - @Post("/partitioned/{partitionKey}") - @Operation( - summary = "Partitioned balance", - description = "Partition all transaction matching the balance request using the partitionKey" - + " provided.", - operationId = "partitionedBalance", - parameters = { - @Parameter( - name = "partitionKey", - in = ParameterIn.PATH, - schema = @Schema(implementation = String.class), - description = "The partition key can be one of: account, budget or category") - }) - public List calculatePartitioned( - @PathVariable String partitionKey, @Valid @Body BalanceRequest request) { - Sequence entityProvider = - switch (partitionKey) { - case "account" -> applicationContext.getBean(AccountProvider.class).lookup(); - case "budget" -> - applicationContext - .getBean(ExpenseProvider.class) - .lookup(filterFactory.expense()) - .content(); - case "category" -> applicationContext.getBean(CategoryProvider.class).lookup(); - default -> - throw new IllegalArgumentException("Unsupported partition used " + partitionKey); - }; - - Function, TransactionProvider.FilterCommand> filterBuilder = - switch (partitionKey) { - case "account" -> (e) -> buildFilterCommand(request).accounts(e); - case "budget" -> (e) -> buildFilterCommand(request).expenses(e); - case "category" -> (e) -> buildFilterCommand(request).categories(e); - default -> - throw new IllegalArgumentException("Unsupported partition used " + partitionKey); - }; - - var result = new ArrayList(); - var total = - transactionProvider.balance(buildFilterCommand(request)).getOrSupply(() -> BigDecimal.ZERO); - - for (AggregateBase entity : entityProvider) { - var filter = filterBuilder.apply(Collections.List(new EntityRef(entity.getId()))); - var balance = transactionProvider.balance(filter).getOrSupply(() -> BigDecimal.ZERO); - - result.add(new BalancePartitionResponse(entity.toString(), balance.doubleValue())); - total = total.subtract(BigDecimal.valueOf(balance.doubleValue())); - } - - result.add(new BalancePartitionResponse("", total.doubleValue())); - return result; - } - - @Post("/daily") - @Operation( - summary = "Daily balance", - description = "Compute the daily balance based upon the provided request", - operationId = "dailyBalance") - List daily(@Valid @Body BalanceRequest request) { - return transactionProvider - .daily(buildFilterCommand(request)) - .map(DailyResponse::new) - .toJava(); - } - - @Post("/monthly") - @Operation( - summary = "Monthly balance", - description = "Compute the monthly balance based upon the provided request", - operationId = "monthlyBalance") - List monthly(@Valid @Body BalanceRequest request) { - return transactionProvider - .monthly(buildFilterCommand(request)) - .map(DailyResponse::new) - .toJava(); - } - - private TransactionProvider.FilterCommand buildFilterCommand(BalanceRequest request) { - var filter = filterFactory.transaction(); - - if (!request.getAccounts().isEmpty()) { - filter.accounts(Collections.List(request.getAccounts()).map(a -> new EntityRef(a.getId()))); - } else { - filter.ownAccounts(); - } - - if (!request.getCategories().isEmpty()) { - filter.categories( - Collections.List(request.getCategories()).map(a -> new EntityRef(a.getId()))); - } - - if (!request.getExpenses().isEmpty()) { - filter.expenses(Collections.List(request.getExpenses()).map(a -> new EntityRef(a.getId()))); - } - - if (request.getDateRange() != null) { - filter.range( - Dates.range(request.getDateRange().start(), request.getDateRange().end())); - } - - if (!request.allMoney()) { - filter.onlyIncome(request.onlyIncome()); - } - - if (request.currency() != null) { - filter.currency(request.currency()); - } - - if (request.importSlug() != null) { - filter.importSlug(request.importSlug()); - } - return filter; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceResponse.java deleted file mode 100644 index 016b8d8f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/BalanceResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -class BalanceResponse { - - private double balance; - - public BalanceResponse(final double balance) { - this.balance = balance; - } - - public double getBalance() { - return balance; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/DailyResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/DailyResponse.java deleted file mode 100644 index f56825a7..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/DailyResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import com.jongsoft.finance.providers.TransactionProvider; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; - -@Serdeable.Serializable -class DailyResponse { - - private final TransactionProvider.DailySummary wrapped; - - DailyResponse(TransactionProvider.DailySummary wrapped) { - this.wrapped = wrapped; - } - - @Schema( - description = "The date of the summary.", - implementation = String.class, - format = "yyyy-mm-dd") - public LocalDate getDate() { - return wrapped.day(); - } - - @Schema(description = "The amount of money for the given date.") - public double getAmount() { - return wrapped.summary(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/SpendingInsightResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/SpendingInsightResource.java deleted file mode 100644 index 9e2b387f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/statistic/SpendingInsightResource.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_REPORTS; - -import com.jongsoft.finance.providers.SpendingInsightProvider; -import com.jongsoft.finance.providers.SpendingPatternProvider; -import com.jongsoft.finance.rest.model.SpendingInsightResponse; -import com.jongsoft.finance.rest.model.SpendingPatternResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.QueryValue; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.time.YearMonth; -import java.util.List; - -@Tag(name = TAG_REPORTS) -@Controller("/api/statistics/spending") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class SpendingInsightResource { - - private final SpendingInsightProvider spendingInsightProvider; - private final SpendingPatternProvider spendingPatternProvider; - - public SpendingInsightResource( - SpendingInsightProvider spendingInsightProvider, - SpendingPatternProvider spendingPatternProvider) { - this.spendingInsightProvider = spendingInsightProvider; - this.spendingPatternProvider = spendingPatternProvider; - } - - @Get("/insights") - @Operation( - summary = "Get spending insights", - description = "Get spending insights for a specific year and month", - operationId = "getSpendingInsights") - List getInsights( - @QueryValue @Parameter(description = "The year", example = "2023") int year, - @QueryValue @Parameter(description = "The month (1-12)", example = "1") @Min(1) @Max(12) - int month) { - return spendingInsightProvider - .lookup(YearMonth.of(year, month)) - .map(SpendingInsightResponse::new) - .toJava(); - } - - @Get("/patterns") - @Operation( - summary = "Get spending patterns", - description = "Get spending patterns for a specific year and month", - operationId = "getSpendingPatterns") - List getPatterns( - @QueryValue @Parameter(description = "The year", example = "2023") int year, - @QueryValue @Parameter(description = "The month (1-12)", example = "1") @Min(1) @Max(12) - int month) { - return spendingPatternProvider - .lookup(YearMonth.of(year, month)) - .map(SpendingPatternResponse::new) - .toJava(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/CreateRuleRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/CreateRuleRequest.java deleted file mode 100644 index 6402346d..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/CreateRuleRequest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.core.RuleOperation; -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.util.List; - -@Serdeable -class CreateRuleRequest { - - @Serdeable - public record Condition( - @Schema(description = "The identifier of an existing pre-condition") Long id, - @NotNull @Schema(description = "The column on which to look for the pre-condition") - RuleColumn column, - @NotNull @Schema(description = "The type of comparison operation to perform") - RuleOperation operation, - @NotBlank - @Schema( - description = "The value the column must have to match the pre-condition", - example = "My personal account") - String value) {} - - @Serdeable - public record Change( - @Schema(description = "The identifier of an already existing change") Long id, - @NotNull - @Schema(description = "The column on which the change is effected", example = "CATEGORY") - RuleColumn column, - @NotBlank - @Schema( - description = "The value to be applied, this could be an identifier", - example = "1") - String value) {} - - @NotBlank - @Size(max = 255) - @Schema(description = "The name of the rule", requiredMode = Schema.RequiredMode.REQUIRED) - private final String name; - - @Size(max = 1024) - @Schema(description = "A long description of the rule") - private final String description; - - @Schema(description = "Should the rule execution stop after a positive match") - private final boolean restrictive; - - @Schema(description = "Should the rule be executed when the engine runs") - private final boolean active; - - @NotNull - @Size(min = 1) - @Schema(description = "List of all pre-conditions that must be met") - private final List conditions; - - @NotNull - @Size(min = 1) - @Schema(description = "List of all the changes to be applied") - private final List changes; - - public CreateRuleRequest( - String name, - String description, - boolean restrictive, - boolean active, - List conditions, - List changes) { - this.name = name; - this.description = description; - this.restrictive = restrictive; - this.active = active; - this.conditions = conditions; - this.changes = changes; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public boolean isRestrictive() { - return restrictive; - } - - public boolean isActive() { - return active; - } - - public List getConditions() { - return conditions; - } - - public List getChanges() { - return changes; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/GroupRenameRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/GroupRenameRequest.java deleted file mode 100644 index efa83337..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/GroupRenameRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@Serdeable -record GroupRenameRequest( - @NotNull - @NotBlank - @Schema(description = "The new name of the group", example = "My renamed group") - String name) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TagCreateRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TagCreateRequest.java deleted file mode 100644 index f1c787ed..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TagCreateRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -/** The tag create request is used to add new tags to FinTrack */ -@Serdeable.Deserializable -public record TagCreateRequest( - @Schema( - description = "The name of the tag to be created", - implementation = String.class, - example = "Car expenses", - required = true) - @NotNull - @NotBlank - String tag) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionBulkEditRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionBulkEditRequest.java deleted file mode 100644 index d117da1f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionBulkEditRequest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.util.List; - -@Serdeable.Deserializable -public class TransactionBulkEditRequest { - - @Serdeable.Deserializable - @Schema(name = "EntityRef") - public record EntityRef( - @NotNull @Schema(description = "The unique identifier of the entity.") Long id, - String name) {} - - @NotNull - @Size(min = 1) - @Schema( - description = "A list of all transaction identifiers that should be updated.", - minLength = 1) - private final List transactions; - - @Schema(description = "The contract to set to all transactions") - private final EntityRef contract; - - @Schema(description = "The budget expense to set to all transactions") - private final EntityRef budget; - - @Schema(description = "The category to set to all transactions") - private final EntityRef category; - - @Schema(description = "The list of tags to set to the transactions") - private final List tags; - - public TransactionBulkEditRequest( - List transactions, - EntityRef contract, - EntityRef budget, - EntityRef category, - List tags) { - this.transactions = transactions; - this.contract = contract; - this.budget = budget; - this.category = category; - this.tags = tags; - } - - public List getTransactions() { - return transactions; - } - - public EntityRef getContract() { - return contract; - } - - public EntityRef getBudget() { - return budget; - } - - public EntityRef getCategory() { - return category; - } - - public List getTags() { - return tags; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionExtractRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionExtractRequest.java deleted file mode 100644 index 806cbe27..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionExtractRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable -record TransactionExtractRequest( - @Schema(description = "The text to extract the transaction information from.") - String fromText) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionExtractResponse.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionExtractResponse.java deleted file mode 100644 index 21243ff2..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionExtractResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import com.jongsoft.finance.core.TransactionType; -import com.jongsoft.finance.learning.TransactionResult; -import io.micronaut.serde.annotation.Serdeable; -import java.time.LocalDate; - -@Serdeable -public record TransactionExtractResponse( - TransactionType type, - LocalDate date, - AccountRef from, - AccountRef to, - String description, - double amount) { - - @Serdeable - public record AccountRef(long id, String name) {} - - public static TransactionExtractResponse from(TransactionResult transactionResult) { - return new TransactionExtractResponse( - transactionResult.type(), - transactionResult.date(), - new AccountRef(transactionResult.from().id(), transactionResult.from().name()), - new AccountRef(transactionResult.to().id(), transactionResult.to().name()), - transactionResult.description(), - transactionResult.amount()); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionForSuggestionRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionForSuggestionRequest.java deleted file mode 100644 index 8e234bcc..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionForSuggestionRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; - -@Serdeable -record TransactionForSuggestionRequest( - @Schema(description = "The source account name.") String source, - @Schema(description = "The destination account name.") String destination, - @Schema(description = "The amount of the transaction.") Double amount, - @Schema(description = "The description of the transaction.") String description) {} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionResource.java deleted file mode 100644 index e85d1949..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionResource.java +++ /dev/null @@ -1,288 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_TRANSACTION; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.domain.transaction.Transaction; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.messaging.EventBus; -import com.jongsoft.finance.messaging.InternalAuthenticationEvent; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.AccountTypeProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.model.ResultPageResponse; -import com.jongsoft.finance.rest.model.TransactionResponse; -import com.jongsoft.finance.rest.process.RuntimeResource; -import com.jongsoft.finance.security.AuthenticationFacade; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import com.jongsoft.lang.Dates; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.*; -import io.micronaut.http.hateoas.JsonError; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.util.Map; -import java.util.concurrent.Executors; - -@Tag(name = TAG_TRANSACTION) -@Controller("/api/transactions") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -public class TransactionResource { - - private final TransactionProvider transactionProvider; - private final AccountProvider accountProvider; - private final FilterFactory filterFactory; - private final AccountTypeProvider accountTypeProvider; - private final RuntimeResource runtimeResource; - private final AuthenticationFacade authenticationFacade; - - public TransactionResource( - TransactionProvider transactionProvider, - AccountProvider accountProvider, - FilterFactory filterFactory, - AccountTypeProvider accountTypeProvider, - RuntimeResource runtimeResource, - AuthenticationFacade authenticationFacade) { - this.transactionProvider = transactionProvider; - this.accountProvider = accountProvider; - this.filterFactory = filterFactory; - this.accountTypeProvider = accountTypeProvider; - this.runtimeResource = runtimeResource; - this.authenticationFacade = authenticationFacade; - } - - @Post - @Operation( - operationId = "searchTransactions", - summary = "Search transactions", - description = "Search in all transactions using the given search request.") - ResultPageResponse search(@Valid @Body TransactionSearchRequest request) { - var command = filterFactory - .transaction() - .range( - Dates.range(request.getDateRange().start(), request.getDateRange().end())) - .page(request.getPage(), Integer.MAX_VALUE); - - Control.Option(request.getCategory()) - .map(e -> new EntityRef(e.id())) - .ifPresent(category -> command.categories(Collections.List(category))); - Control.Option(request.getBudget()) - .map(e -> new EntityRef(e.id())) - .ifPresent(category -> command.expenses(Collections.List(category))); - - if (request.getDescription() != null && !request.getDescription().isBlank()) { - command.description(request.getDescription(), false); - } - - if (request.getAccount() != null && !request.getAccount().isBlank()) { - command.name(request.getAccount(), false); - } - - if (request.isTransfers()) { - command.transfers(); - } else { - command.ownAccounts(); - } - - if (request.isOnlyIncome()) { - command.onlyIncome(true); - } else if (request.isOnlyExpense()) { - command.onlyIncome(false); - } - - if (request.getCurrency() != null) { - command.currency(request.getCurrency()); - } - - var response = transactionProvider.lookup(command).map(TransactionResponse::new); - - return new ResultPageResponse<>(response); - } - - @Patch - @Status(HttpStatus.NO_CONTENT) - @Operation( - operationId = "patchTransactions", - summary = "Patch given transactions", - description = "Update the transactions with the given transaction ids using the request.") - void patch(@Body TransactionBulkEditRequest request) { - for (var id : request.getTransactions()) { - var isPresent = transactionProvider.lookup(id); - if (!isPresent.isPresent()) { - continue; - } - - var transaction = isPresent.get(); - Control.Option(request.getBudget()) - .map(TransactionBulkEditRequest.EntityRef::name) - .ifPresent(transaction::linkToBudget); - - Control.Option(request.getCategory()) - .map(TransactionBulkEditRequest.EntityRef::name) - .ifPresent(transaction::linkToCategory); - - Control.Option(request.getContract()) - .map(TransactionBulkEditRequest.EntityRef::name) - .ifPresent(transaction::linkToContract); - - Control.Option(request.getTags()).ifPresent(adding -> { - var tags = - Control.Option(transaction.getTags()).getOrSupply(Collections::List).union(adding); - - transaction.tag(tags); - }); - } - } - - @Post("/locate-first") - @Operation( - operationId = "getFirstTransactionDate", - summary = "Get oldest date", - description = "Get the oldest transaction in the system based upon the provided request.") - @ApiResponse( - responseCode = "404", - content = @Content(schema = @Schema(implementation = JsonError.class))) - LocalDate firstTransaction(@Body TransactionSearchRequest request) { - var command = filterFactory.transaction(); - - if (request.getAccount() != null) { - command.name(request.getAccount(), true); - } else { - command.ownAccounts(); - } - - if (request.getDateRange() != null) { - command.range( - Dates.range(request.getDateRange().start(), request.getDateRange().end())); - } - - if (request.isTransfers()) { - command.transfers(); - } - - return transactionProvider - .first(command) - .map(Transaction::getDate) - .getOrThrow(() -> StatusException.notFound("No transaction found")); - } - - @Get(value = "/export", produces = MediaType.TEXT_PLAIN) - @Operation( - operationId = "exportTransactions", - summary = "Export transactions", - description = "Creates a CSV export of all transactions in the system.") - String export() throws IOException { - var outputStream = new ByteArrayOutputStream(); - outputStream.write(("Date,Booking Date,Interest Date,From name,From IBAN," - + "To name,To IBAN,Description,Category,Budget,Contract,Amount\n") - .getBytes(StandardCharsets.UTF_8)); - - var filterCommand = filterFactory - .transaction() - .accounts(accountProvider - .lookup(filterFactory.account().types(accountTypeProvider.lookup(false))) - .content() - .map(account -> new EntityRef(account.getId()))) - .page(0, 100); - - int currentPage = 0; - filterCommand.page(currentPage, 100); - var page = transactionProvider.lookup(filterCommand); - do { - for (Transaction transaction : page.content()) { - outputStream.write(convertTransaction(transaction)); - } - - filterCommand.page(++currentPage, 100); - page = transactionProvider.lookup(filterCommand); - } while (page.hasNext()); - - return outputStream.toString(); - } - - @Get("/apply-all-rules") - @Operation(hidden = true) - void applyRules() { - try (var executors = Executors.newFixedThreadPool(25)) { - executors.execute(() -> { - EventBus.getBus() - .sendSystemEvent( - new InternalAuthenticationEvent(this, authenticationFacade.authenticated())); - - var filterCommand = filterFactory - .transaction() - .accounts(accountProvider - .lookup(filterFactory.account().types(accountTypeProvider.lookup(false))) - .content() - .map(account -> new EntityRef(account.getId()))) - .page(0, 100); - - int currentPage = 0; - var page = transactionProvider.lookup(filterCommand); - do { - page.content() - .map(Transaction::getId) - .forEach(transaction -> executors.execute(() -> { - EventBus.getBus() - .sendSystemEvent(new InternalAuthenticationEvent( - this, authenticationFacade.authenticated())); - - runtimeResource.startProcess("analyzeRule", Map.of("transactionId", transaction)); - })); - - filterCommand.page(++currentPage, 100); - page = transactionProvider.lookup(filterCommand); - } while (page.hasNext()); - }); - } - } - - private byte[] convertTransaction(Transaction transaction) { - return (transaction.getDate() - + "," - + valueOrEmpty(transaction.getBookDate()) - + "," - + valueOrEmpty(transaction.getInterestDate()) - + "," - + transaction.computeFrom().getName() - + "," - + valueOrEmpty(transaction.computeFrom().getIban()) - + "," - + transaction.computeTo().getName() - + "," - + valueOrEmpty(transaction.computeTo().getIban()) - + "," - + transaction.getDescription() - + "," - + valueOrEmpty(transaction.getCategory()) - + "," - + valueOrEmpty(transaction.getBudget()) - + "," - + valueOrEmpty(transaction.getContract()) - + "," - + transaction.computeAmount(transaction.computeFrom()) - + "\n") - .getBytes(StandardCharsets.UTF_8); - } - - private String valueOrEmpty(T value) { - if (value == null) { - return ""; - } - - return value.toString(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionRuleResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionRuleResource.java deleted file mode 100644 index 574d0f17..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionRuleResource.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_AUTOMATION_RULES; - -import com.jongsoft.finance.core.Removable; -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.transaction.TransactionRule; -import com.jongsoft.finance.domain.transaction.TransactionRuleGroup; -import com.jongsoft.finance.messaging.EventBus; -import com.jongsoft.finance.messaging.commands.rule.CreateRuleGroupCommand; -import com.jongsoft.finance.providers.TransactionRuleGroupProvider; -import com.jongsoft.finance.providers.TransactionRuleProvider; -import com.jongsoft.finance.rest.ApiDefaults; -import com.jongsoft.finance.rest.model.TransactionRuleGroupResponse; -import com.jongsoft.finance.rest.model.TransactionRuleResponse; -import com.jongsoft.finance.security.CurrentUserProvider; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.micronaut.security.rules.SecurityRule; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; - -@Tag(name = TAG_AUTOMATION_RULES) -@Controller("/api/transaction-rules") -@Secured(SecurityRule.IS_AUTHENTICATED) -public class TransactionRuleResource { - - private final TransactionRuleGroupProvider ruleGroupProvider; - private final TransactionRuleProvider ruleProvider; - private final CurrentUserProvider currentUserProvider; - - public TransactionRuleResource( - TransactionRuleGroupProvider ruleGroupProvider, - TransactionRuleProvider ruleProvider, - CurrentUserProvider currentUserProvider) { - this.ruleGroupProvider = ruleGroupProvider; - this.ruleProvider = ruleProvider; - this.currentUserProvider = currentUserProvider; - } - - @Get("/groups") - @Operation( - summary = "List rule groups", - description = "List all the transaction rule groups available", - operationId = "getRuleGroups") - List groups() { - return ruleGroupProvider.lookup().map(TransactionRuleGroupResponse::new).toJava(); - } - - @Put("/groups") - @Operation( - summary = "Create rule group", - description = "Creates a new rule group in the system", - operationId = "createRuleGroup") - @ApiResponse(responseCode = "204", description = "Group successfully created") - void createGroup(@Body GroupRenameRequest request) { - if (ruleGroupProvider.lookup(request.name()).isPresent()) { - throw new IllegalArgumentException("Group name not unique."); - } - - EventBus.getBus().send(new CreateRuleGroupCommand(request.name())); - } - - @Get("/groups/{group}") - @Operation( - summary = "List transaction rules", - description = "Lists all transaction rules present in the requested group", - operationId = "getRules") - List rules(@PathVariable String group) { - return ruleProvider.lookup(group).map(TransactionRuleResponse::new).toJava(); - } - - @Delete("/groups/{group}") - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Delete rule group", - description = "Deletes the rule group from the system, including all rules within it", - operationId = "deleteRuleGroup") - void deleteGroup(@PathVariable String group) { - ruleProvider.lookup(group).forEach(TransactionRule::remove); - - ruleGroupProvider.lookup(group).ifPresent(TransactionRuleGroup::delete); - } - - @Status(HttpStatus.NO_CONTENT) - @Get("/groups/{group}/move-up") - @Operation( - summary = "Move group up", - description = "Move the transaction rule group up one in the ordering", - operationId = "moveGroupUp") - @ApiResponse(responseCode = "204", description = "Successfully moved up") - void groupUp(@PathVariable String group) { - ruleGroupProvider.lookup(group).ifPresent(g -> g.changeOrder(g.getSort() - 1)); - } - - @Status(HttpStatus.NO_CONTENT) - @Get("/groups/{group}/move-down") - @Operation( - summary = "Move group down", - description = "Move the transaction rule group down one in the ordering", - operationId = "moveGroupDown") - @ApiResponse(responseCode = "204", description = "Successfully moved down") - void groupDown(@PathVariable String group) { - ruleGroupProvider.lookup(group).ifPresent(g -> g.changeOrder(g.getSort() + 1)); - } - - @Patch("/groups/{group}") - @Status(HttpStatus.NO_CONTENT) - @Operation( - summary = "Rename rule group", - description = "Renames the transaction rule group", - operationId = "renameRuleGroup") - @ApiResponse(responseCode = "204", description = "Successfully updated name") - void rename(@PathVariable String group, @Body GroupRenameRequest request) { - ruleGroupProvider.lookup(group).ifPresent(g -> g.rename(request.name())); - } - - @Put("/groups/{group}") - @Status(HttpStatus.CREATED) - @Operation( - summary = "Create transaction rule", - description = "Creates a new transaction rule in the desired group", - operationId = "createTransactionRule") - @ApiResponse( - responseCode = "201", - description = "Rule successfully created", - content = @Content(schema = @Schema(implementation = TransactionRuleResponse.class))) - void create(@PathVariable String group, @Valid @Body CreateRuleRequest request) { - var rule = - currentUserProvider.currentUser().createRule(request.getName(), request.isRestrictive()); - - rule.assign(group); - rule.change( - request.getName(), request.getDescription(), request.isRestrictive(), request.isActive()); - - request.getChanges().forEach(change -> rule.registerChange(change.column(), change.value())); - - request - .getConditions() - .forEach(condition -> - rule.registerCondition(condition.column(), condition.operation(), condition.value())); - - ruleProvider.save(rule); - } - - @Get("/groups/{group}/{id}") - @Operation( - summary = "Get transaction rule", - description = "Returns a single transaction rule by the identified group and rule", - operationId = "getTransactionRule") - @ApiDefaults - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = TransactionRuleResponse.class))) - TransactionRuleResponse getRule(@PathVariable String group, @PathVariable long id) { - return ruleProvider - .lookup(id) - .map(TransactionRuleResponse::new) - .getOrThrow(() -> StatusException.notFound("Rule not found with id " + id)); - } - - @Status(HttpStatus.NO_CONTENT) - @Get("/groups/{group}/{id}/move-up") - @Operation( - summary = "Move transaction rule up", - description = "Moves the transaction rule up by one in the ordering", - operationId = "moveTransactionRuleUp") - @ApiResponse(responseCode = "204", description = "Successfully moved up") - void ruleUp(@PathVariable String group, @PathVariable long id) { - ruleProvider.lookup(id).ifPresent(rule -> rule.changeOrder(rule.getSort() - 1)); - } - - @Status(HttpStatus.NO_CONTENT) - @Get("/groups/{group}/{id}/move-down") - @Operation( - summary = "Move transaction rule down", - description = "Moves the transaction rule down by one in the ordering", - operationId = "moveTransactionRuleDown") - @ApiResponse(responseCode = "204", description = "Successfully moved down") - void ruleDown(@PathVariable String group, @PathVariable long id) { - ruleProvider.lookup(id).ifPresent(rule -> rule.changeOrder(rule.getSort() + 1)); - } - - @Post("/groups/{group}/{id}") - @Operation( - summary = "Update transaction rule", - description = "Updates the transaction rule with the provided settings", - operationId = "updateTransactionRule") - @ApiDefaults - @ApiResponse( - responseCode = "200", - content = @Content(schema = @Schema(implementation = TransactionRuleResponse.class))) - TransactionRuleResponse updateRule( - @PathVariable String group, @PathVariable long id, @Valid @Body CreateRuleRequest request) { - return ruleProvider - .lookup(id) - .map(rule -> { - rule.change( - request.getName(), - request.getDescription(), - request.isRestrictive(), - request.isActive()); - - rule.getChanges().forEach(Removable::delete); - - request - .getChanges() - .forEach(change -> rule.registerChange(change.column(), change.value())); - - rule.getConditions().forEach(Removable::delete); - - request - .getConditions() - .forEach(condition -> rule.registerCondition( - condition.column(), condition.operation(), condition.value())); - - ruleProvider.save(rule); - return ruleProvider.lookup(id).get(); - }) - .map(TransactionRuleResponse::new) - .getOrThrow(() -> StatusException.notFound("Rule not found with id " + id)); - } - - @Status(HttpStatus.NO_CONTENT) - @Delete("/groups/{group}/{id}") - @Operation( - summary = "Delete transaction rule", - description = "Removes the desired rule from the system", - operationId = "deleteTransactionRule") - @ApiResponse(responseCode = "204", description = "Successfully deleted") - void deleteRule(@PathVariable String group, @PathVariable long id) { - var rule = ruleProvider.lookup(id).get(); - rule.remove(); - ruleProvider.save(rule); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionSearchRequest.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionSearchRequest.java deleted file mode 100644 index 9aa6c787..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionSearchRequest.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import io.micronaut.serde.annotation.Serdeable; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; - -@Serdeable -class TransactionSearchRequest { - - @Serdeable - public record DateRange( - @Schema(description = "Any matching transaction must be after this date") LocalDate start, - @Schema(description = "Any matching transaction must be before this date") LocalDate end) {} - - @Serdeable - public record EntityRef(@Schema(description = "The identifier of the relationship") long id) {} - - @Schema( - description = "The partial description the transaction should match", - example = "saving tra") - private final String description; - - @Schema(description = "The partial name of one of the accounts involved in the transaction") - private final String account; - - @Schema(description = "The currency the transaction must have") - private final String currency; - - @Schema(description = "Only include transactions considered as expense from one own accounts") - private final boolean onlyExpense; - - @Schema(description = "Only include transactions considered as income from one own accounts") - private final boolean onlyIncome; - - @Schema(description = "The category that the transaction must have") - private final EntityRef category; - - @Schema(description = "The budget expense that the transaction must have") - private final EntityRef budget; - - @Min(0) - @Schema(description = "Set the page number in the resulting pages") - private final int page; - - @Schema(description = "Only include transactions between one own accounts") - private final boolean transfers; - - @NotNull - @Schema(description = "The range wherein the transaction date must be") - private final DateRange dateRange; - - TransactionSearchRequest( - String description, - String account, - String currency, - boolean onlyExpense, - boolean onlyIncome, - EntityRef category, - EntityRef budget, - int page, - boolean transfers, - DateRange dateRange) { - this.description = description; - this.account = account; - this.currency = currency; - this.onlyExpense = onlyExpense; - this.onlyIncome = onlyIncome; - this.category = category; - this.budget = budget; - this.page = page; - this.transfers = transfers; - this.dateRange = dateRange; - } - - public String getDescription() { - return description; - } - - public String getAccount() { - return account; - } - - public String getCurrency() { - return currency; - } - - public boolean isOnlyExpense() { - return onlyExpense; - } - - public boolean isOnlyIncome() { - return onlyIncome; - } - - public EntityRef getCategory() { - return category; - } - - public EntityRef getBudget() { - return budget; - } - - public boolean isTransfers() { - return transfers; - } - - public DateRange getDateRange() { - return dateRange; - } - - public int getPage() { - return Math.max(0, page - 1); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionSuggestionResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionSuggestionResource.java deleted file mode 100644 index 6c3b9149..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionSuggestionResource.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_TRANSACTION_ANALYTICS; - -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.learning.SuggestionEngine; -import com.jongsoft.finance.learning.SuggestionInput; -import com.jongsoft.finance.rest.model.TagResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import io.micronaut.http.annotation.Body; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Post; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.LocalDate; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -@Tag(name = TAG_TRANSACTION_ANALYTICS) -@Controller("/api/transactions") -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -class TransactionSuggestionResource { - - private final SuggestionEngine suggestionEngine; - - TransactionSuggestionResource(SuggestionEngine suggestionEngine) { - this.suggestionEngine = suggestionEngine; - } - - @Post("suggestions") - @Operation( - operationId = "suggestTransaction", - summary = "Suggest changes", - description = "Suggest changes to a transaction based upon the rules in the system.") - Map suggest(@Body TransactionForSuggestionRequest request) { - var transactionInput = new SuggestionInput( - LocalDate.now(), - request.description(), - request.source(), - request.destination(), - Optional.ofNullable(request.amount()).orElse(0D)); - var suggestions = suggestionEngine.makeSuggestions(transactionInput); - - var output = new HashMap(); - - if (suggestions.budget() != null) { - output.put(RuleColumn.BUDGET.toString(), suggestions.budget()); - } - if (suggestions.tags() != null) { - output.put( - RuleColumn.TAGS.toString(), - suggestions.tags().stream() - .map(tag -> new TagResponse(new com.jongsoft.finance.domain.transaction.Tag(tag))) - .toList()); - } - if (suggestions.category() != null) { - output.put(RuleColumn.CATEGORY.toString(), suggestions.category()); - } - - return output; - } - - @Post("/generate-transaction") - @Operation( - operationId = "extractTransaction", - summary = "Extract transaction info", - description = "Extract transaction information from the presented text.") - public TransactionExtractResponse extractTransaction(@Body TransactionExtractRequest request) { - return suggestionEngine - .extractTransaction(request.fromText()) - .map(TransactionExtractResponse::from) - .orElseThrow( - () -> StatusException.badRequest("No extractor configured.", "llm.not.configured")); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionTagResource.java b/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionTagResource.java deleted file mode 100644 index 8bc02eaa..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/rest/transaction/TransactionTagResource.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import static com.jongsoft.finance.rest.ApiConstants.TAG_TRANSACTION_TAGGING; - -import com.jongsoft.finance.core.exception.StatusException; -import com.jongsoft.finance.domain.transaction.Tag; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.providers.TagProvider; -import com.jongsoft.finance.rest.model.TagResponse; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.finance.security.CurrentUserProvider; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.annotation.*; -import io.micronaut.security.annotation.Secured; -import io.swagger.v3.oas.annotations.Operation; -import jakarta.validation.Valid; -import java.util.List; - -@Secured(AuthenticationRoles.IS_AUTHENTICATED) -@Controller("/api/transactions/tags") -@io.swagger.v3.oas.annotations.tags.Tag(name = TAG_TRANSACTION_TAGGING) -public class TransactionTagResource { - - private final SettingProvider settingProvider; - private final TagProvider tagProvider; - private final FilterFactory filterFactory; - - private final CurrentUserProvider currentUserProvider; - - public TransactionTagResource( - SettingProvider settingProvider, - TagProvider tagProvider, - FilterFactory filterFactory, - CurrentUserProvider currentUserProvider) { - this.settingProvider = settingProvider; - this.tagProvider = tagProvider; - this.filterFactory = filterFactory; - this.currentUserProvider = currentUserProvider; - } - - @Post - @Operation( - summary = "Create tag", - description = "Creates a new tag into the system", - operationId = "createTag") - TagResponse create(@Valid @Body TagCreateRequest tag) { - return new TagResponse(currentUserProvider.currentUser().createTag(tag.tag())); - } - - @Get - @Operation( - operationId = "getTags", - summary = "List tags", - description = "Get all tags available in the system.") - List list() { - return tagProvider.lookup().map(TagResponse::new).toJava(); - } - - @Delete("/{tag}") - @Operation( - operationId = "deleteTag", - summary = "Delete tag", - description = "Removes a tag from the system, this prevents it being used in updates. But" - + " will not remove old relations between tags and transactions.") - void delete(@PathVariable String tag) { - tagProvider - .lookup(tag) - .ifPresent(Tag::archive) - .elseThrow(() -> StatusException.notFound("No active tag found with contents " + tag)); - } - - @Get("/auto-complete{?token}") - @Operation( - summary = "Search tag", - description = "Look for tags with the partial token in the name", - operationId = "lookupTags") - List autoCompleteTag(@Nullable String token) { - var filter = - filterFactory.tag().name(token, false).page(0, settingProvider.getAutocompleteLimit()); - - return tagProvider.lookup(filter).content().map(TagResponse::new).toJava(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationFacadeImpl.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationFacadeImpl.java deleted file mode 100644 index 445250e6..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationFacadeImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.jongsoft.finance.security; - -import com.jongsoft.finance.messaging.InternalAuthenticationEvent; -import io.micronaut.runtime.event.annotation.EventListener; -import io.micronaut.security.authentication.Authentication; -import io.micronaut.security.event.LoginSuccessfulEvent; -import io.micronaut.security.event.LogoutEvent; -import jakarta.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Singleton -public class AuthenticationFacadeImpl implements AuthenticationFacade { - - private static final Logger log = LoggerFactory.getLogger(AuthenticationFacadeImpl.class); - private static final ThreadLocal AUTHENTICATED_USER = new ThreadLocal<>(); - - @EventListener - void authenticated(LoginSuccessfulEvent event) { - if (event.getSource() instanceof Authentication u) { - log.info("Login processed for {}", u.getName()); - AUTHENTICATED_USER.set(u.getName()); - } - } - - @EventListener - void internalAuthenticated(InternalAuthenticationEvent event) { - log.trace("[{}] - Setting internal authentication on thread", event.getUsername()); - AUTHENTICATED_USER.set(event.getUsername()); - } - - @EventListener - void logout(LogoutEvent event) { - log.info("Logout processed for {}", event.getSource()); - AUTHENTICATED_USER.remove(); - } - - @Override - public String authenticated() { - log.trace("[{}] - request authenticated user.", AUTHENTICATED_USER.get()); - return AUTHENTICATED_USER.get(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationProvider.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationProvider.java deleted file mode 100644 index 84de296f..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationProvider.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.jongsoft.finance.security; - -import com.jongsoft.finance.domain.FinTrack; -import com.jongsoft.finance.domain.user.Role; -import com.jongsoft.finance.domain.user.UserAccount; -import com.jongsoft.finance.domain.user.UserIdentifier; -import com.jongsoft.finance.providers.UserProvider; -import io.micronaut.http.HttpRequest; -import io.micronaut.security.authentication.AuthenticationFailureReason; -import io.micronaut.security.authentication.AuthenticationRequest; -import io.micronaut.security.authentication.AuthenticationResponse; -import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider; -import jakarta.inject.Singleton; -import java.util.ArrayList; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Singleton -public class AuthenticationProvider implements HttpRequestAuthenticationProvider> { - - private final Logger log = LoggerFactory.getLogger(AuthenticationProvider.class); - - private final FinTrack application; - private final UserProvider userProvider; - - public AuthenticationProvider(FinTrack application, UserProvider userProvider) { - this.application = application; - this.userProvider = userProvider; - } - - @Override - public AuthenticationResponse authenticate( - HttpRequest> requestContext, - AuthenticationRequest authRequest) { - log.info("Authentication Http request for user {}", authRequest.getIdentity()); - return authenticate(authRequest.getIdentity(), authRequest.getSecret()); - } - - public AuthenticationResponse authenticate(String username, String password) { - log.debug("Authentication basic request for user {}", username); - var authenticated = userProvider.lookup(new UserIdentifier(username)); - if (authenticated.isPresent()) { - var userAccount = authenticated.get(); - return validateUser(userAccount, password); - } else { - throw AuthenticationResponse.exception(AuthenticationFailureReason.USER_NOT_FOUND); - } - } - - private AuthenticationResponse validateUser(UserAccount userAccount, String secret) { - boolean matches = application.getHashingAlgorithm().matches(userAccount.getPassword(), secret); - if (matches) { - List roles = new ArrayList<>(); - if (userAccount.isTwoFactorEnabled()) { - roles.add("PRE_VERIFICATION_USER"); - } else { - userAccount.getRoles().map(Role::getName).forEach(roles::add); - } - - return AuthenticationResponse.success(userAccount.getUsername().email(), roles); - } else { - throw AuthenticationResponse.exception(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH); - } - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationRoles.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationRoles.java deleted file mode 100644 index 0aa2d771..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/AuthenticationRoles.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jongsoft.finance.security; - -public class AuthenticationRoles { - public static final String IS_AUTHENTICATED = "accountant"; - public static final String IS_ADMIN = "admin"; - public static final String TWO_FACTOR_NEEDED = "PRE_VERIFICATION_USER"; -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/CurrentUserProviderImpl.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/CurrentUserProviderImpl.java deleted file mode 100644 index 68125326..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/CurrentUserProviderImpl.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.jongsoft.finance.security; - -import com.jongsoft.finance.domain.user.UserAccount; -import com.jongsoft.finance.domain.user.UserIdentifier; -import com.jongsoft.finance.providers.UserProvider; -import com.jongsoft.lang.Control; -import jakarta.inject.Named; -import jakarta.inject.Singleton; - -@Singleton -@Named("currentUserProvider") -public class CurrentUserProviderImpl implements CurrentUserProvider { - - private final AuthenticationFacade authenticationFacade; - private final UserProvider userProvider; - - public CurrentUserProviderImpl( - AuthenticationFacade authenticationFacade, UserProvider userProvider) { - this.authenticationFacade = authenticationFacade; - this.userProvider = userProvider; - } - - @Override - public UserAccount currentUser() { - var username = Control.Option(authenticationFacade.authenticated()); - return username - .map(s -> userProvider.lookup(new UserIdentifier(s)).getOrSupply(() -> null)) - .getOrSupply(() -> null); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/OpenIdConfiguration.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/OpenIdConfiguration.java deleted file mode 100644 index 87d86e11..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/OpenIdConfiguration.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.jongsoft.finance.security; - -import io.micronaut.context.annotation.ConfigurationProperties; -import io.micronaut.context.annotation.Requires; -import io.micronaut.serde.annotation.Serdeable; - -@Requires(env = "openid") -@Serdeable.Serializable -@ConfigurationProperties("application.openid") -public class OpenIdConfiguration { - - private String authority; - private String clientId; - private String clientSecret; - - public String getAuthority() { - return authority; - } - - public void setAuthority(String authority) { - this.authority = authority; - } - - public String getClientId() { - return clientId; - } - - public void setClientId(String clientId) { - this.clientId = clientId; - } - - public String getClientSecret() { - return clientSecret; - } - - public void setClientSecret(String clientSecret) { - this.clientSecret = clientSecret; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/PasswordEncoder.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/PasswordEncoder.java deleted file mode 100644 index d3ec1d41..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/PasswordEncoder.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.jongsoft.finance.security; - -import at.favre.lib.crypto.bcrypt.BCrypt; -import at.favre.lib.crypto.bcrypt.LongPasswordStrategies; -import com.jongsoft.finance.core.Encoder; -import jakarta.inject.Singleton; -import java.security.SecureRandom; - -@Singleton -public class PasswordEncoder implements Encoder { - - private static final int HASHING_STRENGTH = 10; - - private final BCrypt.Hasher hashApplier; - - public PasswordEncoder() { - this.hashApplier = BCrypt.with( - BCrypt.Version.VERSION_2A, - new SecureRandom(), - LongPasswordStrategies.hashSha512(BCrypt.Version.VERSION_2A)); - } - - public String encrypt(String password) { - return hashApplier.hashToString(HASHING_STRENGTH, password.toCharArray()); - } - - public boolean matches(String hash, String password) { - var result = BCrypt.verifyer().verify(password.toCharArray(), hash); - - return result.verified; - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/SignatureConfiguration.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/SignatureConfiguration.java deleted file mode 100644 index 52c3b424..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/SignatureConfiguration.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.jongsoft.finance.security; - -import com.nimbusds.jose.JWSAlgorithm; -import io.micronaut.context.annotation.Bean; -import io.micronaut.context.annotation.Factory; -import io.micronaut.context.annotation.Value; -import io.micronaut.security.token.jwt.signature.SignatureGeneratorConfiguration; -import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGenerator; -import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGeneratorConfiguration; -import jakarta.inject.Named; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.security.KeyPair; -import java.security.Security; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.util.Optional; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.openssl.PEMException; -import org.bouncycastle.openssl.PEMKeyPair; -import org.bouncycastle.openssl.PEMParser; -import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Factory -public class SignatureConfiguration implements RSASignatureGeneratorConfiguration { - - private static final Logger log = LoggerFactory.getLogger(SignatureConfiguration.class); - private RSAPrivateKey rsaPrivateKey; - private RSAPublicKey rsaPublicKey; - - public SignatureConfiguration( - @Value("${micronaut.application.storage.location}/rsa-2048bit-key-pair.pem") String pemPath) { - var keyPair = SignatureConfiguration.keyPair(pemPath); - if (keyPair.isPresent()) { - this.rsaPrivateKey = (RSAPrivateKey) keyPair.get().getPrivate(); - this.rsaPublicKey = (RSAPublicKey) keyPair.get().getPublic(); - } - } - - @Override - public RSAPrivateKey getPrivateKey() { - return rsaPrivateKey; - } - - @Override - public JWSAlgorithm getJwsAlgorithm() { - return JWSAlgorithm.PS256; - } - - @Override - public RSAPublicKey getPublicKey() { - return rsaPublicKey; - } - - @Bean - @Named("generator") - SignatureGeneratorConfiguration signatureGeneratorConfiguration() { - return new RSASignatureGenerator(this); - } - - static Optional keyPair(String pemPath) { - // Load BouncyCastle as JCA provider - Security.addProvider(new BouncyCastleProvider()); - - // Parse the EC key pair - try (var pemParser = - new PEMParser(new InputStreamReader(Files.newInputStream(Paths.get(pemPath))))) { - var pemKeyPair = (PEMKeyPair) pemParser.readObject(); - - // Convert to Java (JCA) format - var converter = new JcaPEMKeyConverter(); - var keyPair = converter.getKeyPair(pemKeyPair); - - return Optional.of(keyPair); - } catch (FileNotFoundException e) { - log.warn("file not found: {}", pemPath); - } catch (PEMException e) { - log.warn("PEMException {}", e.getMessage()); - } catch (IOException e) { - log.warn("IOException {}", e.getMessage()); - } - - return Optional.empty(); - } -} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/security/TwoFactorHelper.java b/fintrack-api/src/main/java/com/jongsoft/finance/security/TwoFactorHelper.java deleted file mode 100644 index 69f7bc74..00000000 --- a/fintrack-api/src/main/java/com/jongsoft/finance/security/TwoFactorHelper.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.jongsoft.finance.security; - -import com.jongsoft.finance.domain.user.UserAccount; -import dev.samstevens.totp.code.CodeGenerator; -import dev.samstevens.totp.code.DefaultCodeGenerator; -import dev.samstevens.totp.code.DefaultCodeVerifier; -import dev.samstevens.totp.code.HashingAlgorithm; -import dev.samstevens.totp.qr.QrData; -import dev.samstevens.totp.time.SystemTimeProvider; -import dev.samstevens.totp.time.TimeProvider; -import java.util.regex.Pattern; - -public class TwoFactorHelper { - - private static final TimeProvider timeProvider = new SystemTimeProvider(); - private static final CodeGenerator codeGenerator = new DefaultCodeGenerator(); - - private static final Pattern SECURITY_CODE_PATTER = Pattern.compile("[0-9]{6}"); - - public static QrData build2FactorQr(UserAccount userAccount) { - return new QrData.Builder() - .label("Pledger.io: " + userAccount.getUsername()) - .secret(userAccount.getSecret()) - .issuer("Pledger.io") - .algorithm(HashingAlgorithm.SHA1) - .digits(6) - .period(30) - .build(); - } - - public static boolean verifySecurityCode(String secret, String securityCode) { - if (securityCode != null && SECURITY_CODE_PATTER.matcher(securityCode).matches()) { - var verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); - return verifier.isValidCode(secret, securityCode); - } - - return false; - } -} diff --git a/fintrack-api/src/main/resources/application-docker.yml b/fintrack-api/src/main/resources/application-docker.yml deleted file mode 100644 index dc22803d..00000000 --- a/fintrack-api/src/main/resources/application-docker.yml +++ /dev/null @@ -1,4 +0,0 @@ -micronaut: - application: - storage: - location: /opt/storage/ \ No newline at end of file diff --git a/fintrack-api/src/main/resources/application-openid.yaml b/fintrack-api/src/main/resources/application-openid.yaml deleted file mode 100644 index 63df6500..00000000 --- a/fintrack-api/src/main/resources/application-openid.yaml +++ /dev/null @@ -1,16 +0,0 @@ -micronaut: - security: - token: - jwt: - signatures: - jwks: - keycloak: - url: ${OPENID_URI} - key-type: RSA - -application: - openid: - client-id: ${OPENID_CLIENT:pledger-io} - client-secret: ${OPENID_SECRET:-} - authority: ${OPENID_AUTHORITY:-} - diff --git a/fintrack-api/src/main/resources/application-postmark.yml b/fintrack-api/src/main/resources/application-postmark.yml deleted file mode 100644 index 0b5e2b6e..00000000 --- a/fintrack-api/src/main/resources/application-postmark.yml +++ /dev/null @@ -1,5 +0,0 @@ -application: - mail: postmark - -postmark: - api-token: ${POSTMARK_API_TOKEN:} diff --git a/fintrack-api/src/main/resources/application-smtp.yml b/fintrack-api/src/main/resources/application-smtp.yml deleted file mode 100644 index 26062518..00000000 --- a/fintrack-api/src/main/resources/application-smtp.yml +++ /dev/null @@ -1,12 +0,0 @@ -application: - mail: smtp - -javamail: - authentication: - username: ${SMTP_USER} - password: ${SMTP_PASSWORD} - properties: - mail.smtp.host: ${SMTP_HOST} - mail.smtp.port: 587 - mail.smtp.auth: true - mail.smtp.starttls.enable: true diff --git a/fintrack-api/src/main/resources/application.yml b/fintrack-api/src/main/resources/application.yml deleted file mode 100644 index 50f00a26..00000000 --- a/fintrack-api/src/main/resources/application.yml +++ /dev/null @@ -1,91 +0,0 @@ -micronaut: - application: - name: pledger.io - security: - secret: MyLittleSecret - encrypt: true - storage: - location: ${java.io.tmpdir} - security: - authentication: bearer - token: - jwt: - enabled: true - bearer: - enabled: true - endpoints: - oauth: - path: /api/security/oauth - enabled: true - login: - path: /api/security/authenticate - enabled: true - logout: - enabled: true - path: /api/security/logout - get-allowed: true - router: - static-resources: - docs: - paths: classpath:docs - mapping: /openapi/** - swagger: - paths: classpath:META-INF/swagger - mapping: /spec/** - - server: - cors: - enabled: true - multipart: - enabled: true - location: "${micronaut.application.storage.location}/temp" - max-file-size: 20971520 - max-request-size: 20971520 - executors: - io: - type: fixed - n-threads: 75 - email: - from: - email: noreply@pledger.local - name: Pledger.io - -application: - mail: mock - ai: - vectors: - storageType: memory - pass-key: E5MC00ZWUxLWJiMm - storage: ${micronaut.application.storage.location}/vector_stores - -endpoints: - health: - enabled: true - details-visible: anonymous - loggers: - enabled: true - write-sensitive: true - -jackson: - serialization-inclusion: non_absent - -jpa: - default: - compile-time-hibernate-proxies: true - packages-to-scan: - - 'com.jongsoft.finance.jpa' - properties: - jdbc: - time_zone: UTC - hibernate: - hbm2ddl: - auto: none - physical_naming_strategy: 'com.jongsoft.finance.jpa.DefaultNamingStrategy' - show_sql: false - -otel: - trace: - exporter: otlp - instrumentation: - jdbc: - enabled: true diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/DiskStorageServiceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/DiskStorageServiceTest.java deleted file mode 100644 index cfb05cb6..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/DiskStorageServiceTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.jongsoft.finance; - -import com.jongsoft.finance.configuration.SecuritySettings; -import com.jongsoft.finance.configuration.StorageSettings; -import com.jongsoft.finance.domain.user.UserAccount; -import com.jongsoft.finance.messaging.commands.storage.ReplaceFileCommand; -import com.jongsoft.finance.security.CurrentUserProvider; -import dev.samstevens.totp.secret.DefaultSecretGenerator; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import java.io.File; -import java.security.GeneralSecurityException; - -class DiskStorageServiceTest { - - private DiskStorageService subject; - - @Mock - private SecuritySettings securitySettings; - @Mock - private StorageSettings storageSettings; - @Mock - private CurrentUserProvider currentUserProvider; - - @BeforeEach - void setUp() throws GeneralSecurityException { - MockitoAnnotations.openMocks(this); - Mockito.when(securitySettings.getSecret()).thenReturn("mySalt"); - Mockito.when(storageSettings.getLocation()).thenReturn(System.getProperty("java.io.tmpdir")); - - subject = new DiskStorageService(securitySettings, currentUserProvider, storageSettings); - - Mockito.when(currentUserProvider.currentUser()).thenReturn( - UserAccount.builder() - .secret(new DefaultSecretGenerator().generate()) - .build()); - } - - @Test - void unencryptedStore() { - var storageKey = subject.store("My private text".getBytes()); - var read = subject.read(storageKey).get(); - - Assertions.assertThat(new String(read)).isEqualTo("My private text"); - Assertions.assertThat(new File(System.getProperty("java.io.tmpdir") + "/upload/" + storageKey)).exists(); - } - - @Test - void encryptedStore() { - Mockito.when(securitySettings.isEncrypt()).thenReturn(true); - - var storageKey = subject.store("My private text".getBytes()); - var read = subject.read(storageKey).get(); - - Assertions.assertThat(new String(read)).isEqualTo("My private text"); - Assertions.assertThat(new File(System.getProperty("java.io.tmpdir") + "/upload/" + storageKey)).exists(); - subject.remove(storageKey); - } - - @Test - void storageChange() { - var storageKey = subject.store("My private text".getBytes()); - - var command = Mockito.mock(ReplaceFileCommand.class); - Mockito.when(command.oldFileCode()).thenReturn(storageKey); - subject.onStorageChangeEvent(command); - Assertions.assertThat(new File(System.getProperty("java.io.tmpdir") + "/upload/" + storageKey)).doesNotExist(); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/factory/EventBusFactoryTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/factory/EventBusFactoryTest.java deleted file mode 100644 index 2c591746..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/factory/EventBusFactoryTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.jongsoft.finance.factory; - -import com.jongsoft.finance.messaging.EventBus; -import io.micronaut.context.event.ApplicationEventPublisher; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import static org.junit.jupiter.api.Assertions.*; - -class EventBusFactoryTest { - - @Test - void eventBus() { - new EventBusFactory().eventBus(Mockito.mock(ApplicationEventPublisher.class)); - - Assertions.assertNotNull(EventBus.getBus()); - } - -} \ No newline at end of file diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/factory/MailDaemonFactoryTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/factory/MailDaemonFactoryTest.java deleted file mode 100644 index d9470ce5..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/factory/MailDaemonFactoryTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.jongsoft.finance.factory; - -import io.micronaut.email.Contact; -import io.micronaut.email.Email; -import io.micronaut.email.EmailSender; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.util.List; -import java.util.Properties; - -class MailDaemonFactoryTest { - - @Test - void createMailDaemon() { - // Given - var subject = new MailDaemonFactory(); - var mockMailer = Mockito.mock(EmailSender.class); - - // When - var mailDaemon = subject.createMailDaemon("smtp", mockMailer); - mailDaemon.send("test@localhost", "test", new Properties()); - - // Then - Assertions.assertThat(mailDaemon) - .isNotNull(); - - var captor = ArgumentCaptor.captor(); - Mockito.verify(mockMailer, Mockito.times(1)) - .send(captor.capture()); - - var email = captor.getValue().build(); - Assertions.assertThat(email) - .isNotNull() - .extracting(Email::getTo, Email::getSubject) - .contains(List.of(new Contact("test@localhost")), "Pleger.io: Welcome to the family!"); - } - -} \ No newline at end of file diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/factory/MessageSourceFactoryTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/factory/MessageSourceFactoryTest.java deleted file mode 100644 index ce961cc3..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/factory/MessageSourceFactoryTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.jongsoft.finance.factory; - -import io.micronaut.context.MessageSource; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class MessageSourceFactoryTest { - - @Test - void messageSource() { - var messageSource = new MessageSourceFactory().messageSource(); - - var message = messageSource.getMessage("common.action.save", MessageSource.MessageContext.DEFAULT); - Assertions.assertTrue(message.isPresent()); - } - -} \ No newline at end of file diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/filter/CurrencyHeaderFilterTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/filter/CurrencyHeaderFilterTest.java deleted file mode 100644 index 2590f0ce..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/filter/CurrencyHeaderFilterTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.jongsoft.finance.filter; - -import com.jongsoft.finance.domain.core.Currency; -import com.jongsoft.finance.providers.CurrencyProvider; -import com.jongsoft.lang.Control; -import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.filter.ServerFilterChain; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Optional; - -class CurrencyHeaderFilterTest { - - private CurrencyProvider currencyProvider; - private CurrencyHeaderFilter subject; - - @BeforeEach - void setup() { - currencyProvider = Mockito.mock(CurrencyProvider.class); - subject = new CurrencyHeaderFilter(currencyProvider); - } - - @Test - void doFilterOnce() { - var mockRequest = Mockito.mock(HttpRequest.class); - var headers = Mockito.mock(HttpHeaders.class); - var currency = Currency.builder() - .id(2L) - .code("USD") - .build(); - - Mockito.doReturn(headers).when(mockRequest).getHeaders(); - Mockito.doReturn(Optional.of("USD")).when(headers).get("X-Accept-Currency", String.class); - Mockito.doReturn(Control.Option(currency)).when(currencyProvider).lookup("USD"); - - subject.doFilter(mockRequest, Mockito.mock(ServerFilterChain.class)); - - Mockito.verify(mockRequest).setAttribute(RequestAttributes.CURRENCY, currency); - } - - @Test - void doFilterOnce_missingCurrency() { - var mockRequest = Mockito.mock(HttpRequest.class); - var headers = Mockito.mock(HttpHeaders.class); - - Mockito.doReturn(headers).when(mockRequest).getHeaders(); - Mockito.doReturn(Optional.of("USD")).when(headers).get("X-Accept-Currency", String.class); - Mockito.doReturn(Control.Option()).when(currencyProvider).lookup("USD"); - - subject.doFilter(mockRequest, Mockito.mock(ServerFilterChain.class)); - - Mockito.verify(mockRequest, Mockito.never()).setAttribute(Mockito.eq(RequestAttributes.CURRENCY), Mockito.any()); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/filter/LocaleHeaderFilterTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/filter/LocaleHeaderFilterTest.java deleted file mode 100644 index f142eca4..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/filter/LocaleHeaderFilterTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.jongsoft.finance.filter; - -import java.util.Locale; -import java.util.Optional; - -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.filter.ServerFilterChain; - -class LocaleHeaderFilterTest { - - private LocaleHeaderFilter subject = new LocaleHeaderFilter(); - - @Test - void doFilterOnce() { - var mockRequest = Mockito.mock(HttpRequest.class); - var headers = Mockito.mock(HttpHeaders.class); - - Mockito.doReturn(headers).when(mockRequest).getHeaders(); - Mockito.doReturn(Optional.of("en")).when(headers).get(HttpHeaders.ACCEPT_LANGUAGE, String.class); - - subject.doFilter(mockRequest, Mockito.mock(ServerFilterChain.class)); - - Mockito.verify(mockRequest).setAttribute(RequestAttributes.LOCALIZATION, Locale.forLanguageTag("en")); - } - - @Test - void doFilterOnce_noLocalization() { - var mockRequest = Mockito.mock(HttpRequest.class); - var headers = Mockito.mock(HttpHeaders.class); - - Mockito.doReturn(headers).when(mockRequest).getHeaders(); - Mockito.doReturn(Optional.empty()).when(headers).get(HttpHeaders.ACCEPT_LANGUAGE, String.class); - - subject.doFilter(mockRequest, Mockito.mock(ServerFilterChain.class)); - - Mockito.verify(mockRequest, Mockito.never()).setAttribute(Mockito.eq(RequestAttributes.LOCALIZATION), Mockito.any()); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/jackson/LocalDateSerializerTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/jackson/LocalDateSerializerTest.java deleted file mode 100644 index f8d4f127..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/jackson/LocalDateSerializerTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jongsoft.finance.jackson; - -import io.micronaut.serde.ObjectMapper; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.time.LocalDate; - -@DisplayName("LocalDate serializer") -@MicronautTest(environments = {"no-camunda", "no-analytics"} ) -class LocalDateSerializerTest { - - @Test - @DisplayName("Serialize LocalDate") - void serialize(ObjectMapper objectMapper) throws IOException { - var json = objectMapper.writeValueAsString(LocalDate.of(2019, 2, 1)); - Assertions.assertThat(json).isEqualTo("\"2019-02-01\""); - } - - @Test - @DisplayName("Deserialize LocalDate") - void deserialize(ObjectMapper objectMapper) throws IOException { - var localDate = objectMapper.readValue("\"2019-02-01\"", LocalDate.class); - Assertions.assertThat(localDate).isEqualTo(LocalDate.of(2019, 2, 1)); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/jackson/LocalDateTimeSerializerTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/jackson/LocalDateTimeSerializerTest.java deleted file mode 100644 index 5d5fe8f9..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/jackson/LocalDateTimeSerializerTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jongsoft.finance.jackson; - -import io.micronaut.serde.ObjectMapper; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.time.LocalDateTime; - -@DisplayName("Serializer for LocalDateTime") -@MicronautTest(environments = {"no-camunda", "no-analytics"} ) -class LocalDateTimeSerializerTest { - - @Test - @DisplayName("Should serialize LocalDateTime to ISO format") - void serialize(ObjectMapper objectMapper) throws IOException { - var serialized = objectMapper.writeValueAsString(LocalDateTime.of(2019, 2, 1, 12, 12)); - Assertions.assertThat(serialized).isEqualTo("\"2019-02-01T12:12:00\""); - } - - @Test - @DisplayName("Should deserialize LocalDateTime from ISO format") - void deserialize(ObjectMapper objectMapper) throws IOException { - var deserialized = objectMapper.readValue("\"2019-02-01T12:12:00\"", LocalDateTime.class); - Assertions.assertThat(deserialized).isEqualTo(LocalDateTime.of(2019, 2, 1, 12, 12)); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/jackson/VariableMapSerializerTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/jackson/VariableMapSerializerTest.java deleted file mode 100644 index 6015a12b..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/jackson/VariableMapSerializerTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.jongsoft.finance.jackson; - -import com.jongsoft.finance.bpmn.delegate.importer.ExtractionMapping; -import com.jongsoft.finance.rest.process.VariableMap; -import io.micronaut.serde.ObjectMapper; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.util.List; - -@DisplayName("Variable map serializer") -@MicronautTest(environments = {"no-camunda", "no-analytics"} ) -class VariableMapSerializerTest { - - public static final String JSON = "{\"variables\":{\"number\":{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$WrappedVariable\",\"value\":123},\"boolean\":{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$WrappedVariable\",\"value\":true},\"string\":{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$WrappedVariable\",\"value\":\"value\"},\"list\":{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$VariableList\",\"content\":[{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$WrappedVariable\",\"value\":\"one\"},{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$WrappedVariable\",\"value\":\"two\"},{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$WrappedVariable\",\"value\":\"three\"}]}}}"; - public static final String JSON_WITH_EXTRACTION_MAPPINGS = "{\"variables\":{\"mappings\":{\"_type\":\"com.jongsoft.finance.rest.process.VariableMap$VariableList\",\"content\":[{\"_type\":\"com.jongsoft.finance.bpmn.delegate.importer.ExtractionMapping\",\"name\":\"account 1\",\"accountId\":123},{\"_type\":\"com.jongsoft.finance.bpmn.delegate.importer.ExtractionMapping\",\"name\":\"account 2\"}]}}}"; - - @Test - @DisplayName("Deserialize variable map with simple values") - void deserialize(ObjectMapper objectMapper) throws IOException { - var value = objectMapper.readValue(JSON, VariableMap.class); - - Assertions.assertThat(value) - .isNotNull(); - Assertions.assertThat((String)value.get("string")).isEqualTo("value"); - Assertions.assertThat((int)value.get("number")).isEqualTo(123); - Assertions.assertThat((boolean)value.get("boolean")).isTrue(); - Assertions.assertThat((List)value.get("list")).containsExactly("one", "two", "three"); - } - - @Test - @DisplayName("Serialize variable map with simple values") - void serialize(ObjectMapper objectMapper) throws IOException { - var variables = new VariableMap(); - variables.put("string", "value"); - variables.put("number", 123); - variables.put("boolean", true); - variables.put("list", List.of("one", "two", "three")); - - var json = objectMapper.writeValueAsString(variables); - - Assertions.assertThat(json).isEqualTo(JSON); - } - - @Test - @DisplayName("Deserialize variable map with extraction mappings") - void deserializeVariables(ObjectMapper objectMapper) throws IOException { - var value = objectMapper.readValue(JSON_WITH_EXTRACTION_MAPPINGS, VariableMap.class); - - Assertions.assertThat(value) - .isNotNull(); - Assertions.assertThat((List)value.get("mappings")).containsExactly( - new ExtractionMapping("account 1", 123L), - new ExtractionMapping("account 2", null)); - } - - @Test - @DisplayName("Serialize variable map with extraction mappings") - void serializeVariables(ObjectMapper objectMapper) throws IOException { - var variables = new VariableMap(); - variables.put("mappings", List.of( - new ExtractionMapping("account 1", 123L), - new ExtractionMapping("account 2", null))); - - var json = objectMapper.writeValueAsString(variables); - - Assertions.assertThat(json).isEqualTo(JSON_WITH_EXTRACTION_MAPPINGS); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/ReactControllerTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/ReactControllerTest.java deleted file mode 100644 index 2ae25068..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/ReactControllerTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.jongsoft.finance.rest; - -import io.micronaut.core.io.ResourceResolver; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.MediaType; -import io.micronaut.http.server.types.files.StreamedFile; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.*; - -class ReactControllerTest { - - @Test - void testIndexReturnsStreamedFileWhenResourceExists() { - // Arrange - ResourceResolver resourceResolver = mock(ResourceResolver.class); - ReactController reactController = new ReactController(resourceResolver); - InputStream mockStream = new ByteArrayInputStream("Mock index.html".getBytes()); - when(resourceResolver.getResourceAsStream("classpath:public/index.html")).thenReturn(Optional.of(mockStream)); - - // Act - HttpResponse response = reactController.index(); - - // Assert - assertEquals(HttpResponse.ok().body(new StreamedFile(mockStream, MediaType.TEXT_HTML_TYPE)).getStatus(), response.getStatus()); - verify(resourceResolver, times(1)).getResourceAsStream("classpath:public/index.html"); - } - - @Test - void testIndexReturnsNotFoundWhenResourceDoesNotExist() { - // Arrange - ResourceResolver resourceResolver = mock(ResourceResolver.class); - ReactController reactController = new ReactController(resourceResolver); - when(resourceResolver.getResourceAsStream("classpath:public/index.html")).thenReturn(Optional.empty()); - - // Act - HttpResponse response = reactController.index(); - - // Assert - assertEquals(HttpResponse.notFound("React app not found").getStatus(), response.getStatus()); - verify(resourceResolver, times(1)).getResourceAsStream("classpath:public/index.html"); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/StaticResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/StaticResourceTest.java deleted file mode 100644 index ea893bca..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/StaticResourceTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.jongsoft.finance.rest; - -import io.micronaut.core.io.ResourceResolver; -import io.micronaut.core.io.scan.DefaultClassPathResourceLoader; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.net.URISyntaxException; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class StaticResourceTest { - - @Spy - ResourceResolver resourceResolver = new ResourceResolver(List.of( - new DefaultClassPathResourceLoader(ClassLoader.getSystemClassLoader()))); - - @InjectMocks - private StaticResource subject = new StaticResource(); - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - } - - @Test - void index() throws URISyntaxException { - var response = subject.index(); - - assertThat(response.getHeaders().get("Location")).isEqualTo("/ui/dashboard"); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/TestSetup.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/TestSetup.java deleted file mode 100644 index 6d4b767a..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/TestSetup.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.jongsoft.finance.rest; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.jongsoft.finance.domain.user.Role; -import com.jongsoft.finance.domain.user.UserAccount; -import com.jongsoft.finance.domain.user.UserIdentifier; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.messaging.EventBus; -import com.jongsoft.finance.providers.*; -import com.jongsoft.finance.security.AuthenticationRoles; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import dev.samstevens.totp.secret.DefaultSecretGenerator; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.context.event.ApplicationEventPublisher; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.RestAssured; -import io.restassured.builder.RequestSpecBuilder; -import io.restassured.config.ObjectMapperConfig; -import jakarta.inject.Inject; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.mockito.Mockito; -import org.mockito.invocation.InvocationOnMock; - -import java.io.IOException; -import java.time.LocalDate; -import java.util.Currency; - -@MicronautTest(environments = {"no-camunda", "test", "no-analytics"}) -public class TestSetup { - - protected final UserIdentifier ACTIVE_USER_IDENTIFIER = new UserIdentifier("test-user"); - protected final UserAccount ACTIVE_USER = Mockito.spy(UserAccount.builder() - .id(1L) - .username(ACTIVE_USER_IDENTIFIER) - .password("$2a$10$mgkjpf4nZqCLqAzFCjv5F.2Sj1b8k7yFJZVM0MZ4J9dJKzgBYPKDi") - .theme("dark") - .primaryCurrency(Currency.getInstance("EUR")) - .secret(new DefaultSecretGenerator().generate()) - .roles(Collections.List(new Role(AuthenticationRoles.IS_ADMIN), new Role(AuthenticationRoles.IS_AUTHENTICATED))) - .build()); - - @Inject - protected UserProvider userProvider; - @Inject - protected FilterFactory filterFactory; - - @BeforeEach - void initialize() { - RestAssured.requestSpecification = new RequestSpecBuilder() - .setContentType("application/json") - // override the default object mapper to use the JavaTimeModule - .setConfig(RestAssured.config() - .objectMapperConfig( - ObjectMapperConfig.objectMapperConfig() - .jackson2ObjectMapperFactory( - (type, s) -> { - var mapper = new ObjectMapper(); - var module = new SimpleModule(); - module.addSerializer(LocalDate.class, new JsonSerializer<>() { - @Override - public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeString(value.toString()); - } - }); - mapper.registerModule(module); - mapper.setVisibility(mapper.getVisibilityChecker() - .withFieldVisibility(JsonAutoDetect.Visibility.ANY)); - return mapper; - } - )) - .logConfig(RestAssured.config().getLogConfig() - .enableLoggingOfRequestAndResponseIfValidationFails())) - .addHeader("Authorization", "Basic dGVzdC11c2VyOjEyMzQ=") - .build(); - - Mockito.when(userProvider.lookup(ACTIVE_USER.getUsername())).thenReturn(Control.Option(ACTIVE_USER)); - - // initialize the event bus - new EventBus(Mockito.mock(ApplicationEventPublisher.class)); - } - - @AfterEach - void after() { - Mockito.reset(userProvider, filterFactory); - } - - @MockBean - UserProvider userProvider() { - return Mockito.mock(UserProvider.class); - } - - @MockBean - CurrencyProvider currencyProvider() { - return Mockito.mock(CurrencyProvider.class); - } - - @MockBean - SettingProvider settingProvider() { - return Mockito.mock(SettingProvider.class); - } - - @MockBean - @Replaces - FilterFactory generateFilterMock() { - final FilterFactory filterFactory = Mockito.mock(FilterFactory.class); - Mockito.when(filterFactory.transaction()) - .thenReturn(Mockito.mock(TransactionProvider.FilterCommand.class, InvocationOnMock::getMock)); - Mockito.when(filterFactory.account()) - .thenReturn(Mockito.mock(AccountProvider.FilterCommand.class, InvocationOnMock::getMock)); - Mockito.when(filterFactory.expense()) - .thenReturn(Mockito.mock(ExpenseProvider.FilterCommand.class, InvocationOnMock::getMock)); - Mockito.when(filterFactory.category()) - .thenReturn(Mockito.mock(CategoryProvider.FilterCommand.class, InvocationOnMock::getMock)); - Mockito.when(filterFactory.tag()) - .thenReturn(Mockito.mock(TagProvider.FilterCommand.class, InvocationOnMock::getMock)); - Mockito.when(filterFactory.schedule()) - .thenReturn(Mockito.mock(TransactionScheduleProvider.FilterCommand.class, InvocationOnMock::getMock)); - return filterFactory; - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountEditResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountEditResourceTest.java deleted file mode 100644 index 822901aa..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountEditResourceTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.account.SavingGoal; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.CoreMatchers; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.math.BigDecimal; -import java.time.LocalDate; - -@DisplayName("Account edit resource") -class AccountEditResourceTest extends TestSetup { - private static final String CREATE_ACCOUNT_JSON = """ - { - "name": "Sample account", - "currency": "EUR", - "type": "checking" - }"""; - - @Inject - private AccountProvider accountProvider; - - @BeforeEach - void setup() { - Mockito.when(accountProvider.lookup(Mockito.anyLong())).thenReturn(Control.Option()); - } - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @AfterEach - void after() { - Mockito.reset(accountProvider); - } - - @Test - @DisplayName("Fetch a missing account") - void get_missing(RequestSpecification spec) { - // @formatter:off - spec.when() - .get("/api/accounts/{id}", "1") - .then() - .statusCode(404) - .body("message", CoreMatchers.equalTo("Account not found")); - // @formatter:on - } - - @Test - @DisplayName("Create account") - void get(RequestSpecification spec) { - // @formatter:off - Mockito.when(accountProvider.lookup(123L)) - .thenReturn(Control.Option(Account.builder() - .id(1L) - .user(ACTIVE_USER_IDENTIFIER) - .balance(0D) - .name("Sample account") - .currency("EUR") - .build())); - - spec.when() - .get("/api/accounts/{id}", "123") - .then() - .statusCode(200) - .body("name", CoreMatchers.equalTo("Sample account")); - - // @formatter:on - - Mockito.verify(accountProvider).lookup(123L); - } - - @Test - @DisplayName("Update a missing account") - void update_missing(RequestSpecification spec) { - // @formatter:off - spec.when() - .body(CREATE_ACCOUNT_JSON) - .post("/api/accounts/{id}", "1") - .then() - .statusCode(404) - .body("message", CoreMatchers.equalTo("No account found with id 1")); - // @formatter:on - - Mockito.verify(accountProvider).lookup(1L); - } - - @Test - @DisplayName("Update account with valid data") - void update(RequestSpecification spec) { - Mockito.when(accountProvider.lookup(123L)) - .thenReturn(Control.Option(Account.builder() - .id(1L) - .user(ACTIVE_USER_IDENTIFIER) - .balance(0D) - .name("Sample account") - .currency("EUR") - .build())); - - // @formatter:off - spec.when() - .body(CREATE_ACCOUNT_JSON) - .post("/api/accounts/{id}", "123") - .then() - .statusCode(200) - .body("name", CoreMatchers.equalTo("Sample account")); - // @formatter:on - - Mockito.verify(accountProvider).lookup(123L); - } - - @Test - @DisplayName("Set account image") - void updateIcon(RequestSpecification spec) { - Account account = Mockito.spy(Account.builder() - .id(1L) - .user(ACTIVE_USER_IDENTIFIER) - .balance(0D) - .name("Sample account") - .currency("EUR") - .build()); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - - // @formatter:off - spec.when() - .body(new AccountImageRequest("file-code")) - .post("/api/accounts/{id}/image", "1") - .then() - .statusCode(200) - .body("iconFileCode", CoreMatchers.equalTo("file-code")); - // @formatter:on - - Mockito.verify(account).registerIcon("file-code"); - } - - @Test - @DisplayName("Delete existing account") - void delete(RequestSpecification spec) { - Account account = Mockito.spy(Account.builder() - .id(1L) - .user(ACTIVE_USER_IDENTIFIER) - .balance(0D) - .name("Sample account") - .currency("EUR") - .build()); - Mockito.when(accountProvider.lookup(123L)) - .thenReturn(Control.Option(account)); - - // @formatter:off - spec.when() - .delete("/api/accounts/{id}", "123") - .then() - .statusCode(200); - // @formatter:on - - Mockito.verify(account).terminate(); - } - - @Test - @DisplayName("Create saving goal") - void createSavingGoal(RequestSpecification spec) { - Account account = Mockito.spy(Account.builder() - .id(1L) - .user(ACTIVE_USER_IDENTIFIER) - .balance(0D) - .name("Sample account") - .currency("EUR") - .type("savings") - .savingGoals(Collections.Set( - SavingGoal.builder() - .id(132L) - .name("Saving for washer") - .goal(BigDecimal.valueOf(1000)) - .targetDate(LocalDate.now().plusDays(300)) - .build())) - .build()); - - Mockito.when(accountProvider.lookup(123L)) - .thenReturn(Control.Option(account)); - - // @formatter:off - spec.when() - .body(""" - { - "goal": 1500, - "targetDate": "%s", - "name": "Saving for washer" - }""".formatted(LocalDate.now().plusDays(300))) - .post("/api/accounts/{id}/savings", "123") - .then() - .statusCode(200) - .assertThat() - .body("name", CoreMatchers.equalTo("Sample account")) - .body("savingGoals.name", CoreMatchers.hasItem("Saving for washer")); - // @formatter:on - - Mockito.verify(account).createSavingGoal( - "Saving for washer", - BigDecimal.valueOf(1500), - LocalDate.now().plusDays(300)); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountResourceTest.java deleted file mode 100644 index ed76dff2..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountResourceTest.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.AccountTypeProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.finance.schedule.Periodicity; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.CoreMatchers; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; -import java.util.List; - -@DisplayName("Account list / create resource") -class AccountResourceTest extends TestSetup { - - @Inject - private AccountTypeProvider accountTypeProvider; - - @Inject - private AccountProvider accountProvider; - @Inject - private FilterFactory filterFactory; - - @Replaces - @MockBean - protected AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @MockBean - @Replaces - private AccountTypeProvider accountTypeProvider() { - return Mockito.mock(AccountTypeProvider.class); - } - - @Test - @DisplayName("Fetch own accounts") - void ownAccounts(RequestSpecification spec) { - var resultPage = Mockito.mock(ResultPage.class); - Mockito.when(resultPage.content()).thenReturn(Collections.List( - Account.builder() - .id(1L) - .name("Sample account") - .description("Long description") - .iban("NL123INGb23039283") - .currency("EUR") - .balance(2000.2D) - .firstTransaction(LocalDate.of(2019, 1, 1)) - .lastTransaction(LocalDate.of(2022, 3, 23)) - .type("checking") - .build())); - - Mockito.when(accountTypeProvider.lookup(false)).thenReturn(Collections.List("default", "savings")); - Mockito.when(accountProvider.lookup(Mockito.any(AccountProvider.FilterCommand.class))).thenReturn(resultPage); - - // @formatter:off - spec.when() - .get("/api/accounts/my-own") - .then() - .statusCode(200) - .body("$", Matchers.hasSize(1)) - .body("[0].name", CoreMatchers.equalTo("Sample account")) - .body("[0].description", CoreMatchers.equalTo("Long description")) - .body("[0].account.iban", CoreMatchers.equalTo("NL123INGb23039283")); - // @formatter:on - } - - @Test - @DisplayName("Fetch all accounts") - void allAccounts(RequestSpecification spec) { - var resultPage = Collections.List(Account.builder() - .id(1L) - .name("Sample account") - .description("Long description") - .iban("NL123INGb23039283") - .currency("EUR") - .balance(2000.2D) - .firstTransaction(LocalDate.of(2019, 1, 1)) - .lastTransaction(LocalDate.of(2022, 3, 23)) - .type("creditor") - .build()); - - Mockito.when(accountProvider.lookup()) - .thenReturn(resultPage); - - // @formatter:off - spec.when() - .get("/api/accounts/all") - .then() - .statusCode(200) - .body("$", Matchers.hasSize(1)) - .body("[0].name", CoreMatchers.equalTo("Sample account")) - .body("[0].description", CoreMatchers.equalTo("Long description")) - .body("[0].account.iban", CoreMatchers.equalTo("NL123INGb23039283")); - // @formatter:on - - Mockito.verify(accountProvider).lookup(); - } - - @Test - @DisplayName("Autocomplete accounts with token and type") - void autocomplete(RequestSpecification spec) { - var resultPage = Mockito.mock(ResultPage.class); - Mockito.when(resultPage.content()).thenReturn(Collections.List( - Account.builder() - .id(1L) - .name("Sample account") - .description("Long description") - .iban("NL123INGb23039283") - .currency("EUR") - .balance(2000.2D) - .firstTransaction(LocalDate.of(2019, 1, 1)) - .lastTransaction(LocalDate.of(2022, 3, 23)) - .type("checking") - .build())); - - Mockito.when(accountProvider.lookup(Mockito.any(AccountProvider.FilterCommand.class))) - .thenReturn(resultPage); - - // @formatter:off - spec.when() - .get("/api/accounts/auto-complete?token=sampl&type=creditor") - .then() - .statusCode(200) - .body("$", Matchers.hasSize(1)) - .body("[0].name", CoreMatchers.equalTo("Sample account")) - .body("[0].description", CoreMatchers.equalTo("Long description")) - .body("[0].account.iban", CoreMatchers.equalTo("NL123INGb23039283")); - // @formatter:on - - var mockCommand = filterFactory.account(); - Mockito.verify(accountProvider).lookup(Mockito.any(AccountProvider.FilterCommand.class)); - Mockito.verify(mockCommand).name("sampl", false); - Mockito.verify(mockCommand).types(Collections.List("creditor")); - } - - @Test - @DisplayName("Search accounts with type creditor") - void accounts_creditor(RequestSpecification spec) { - var resultPage = ResultPage.of(Account.builder() - .id(1L) - .name("Sample account") - .description("Long description") - .iban("NL123INGb23039283") - .currency("EUR") - .balance(2000.2D) - .firstTransaction(LocalDate.of(2019, 1, 1)) - .lastTransaction(LocalDate.of(2022, 3, 23)) - .type("creditor") - .build()); - - Mockito.when(accountProvider.lookup(Mockito.any(AccountProvider.FilterCommand.class))) - .thenReturn(resultPage); - - // @formatter:off - spec.when() - .body(""" - { - "accountTypes": ["creditor"], - "page": 0 - }""") - .post("/api/accounts") - .then() - .statusCode(200) - .body("content", Matchers.hasSize(1)) - .body("content[0].name", CoreMatchers.equalTo("Sample account")) - .body("content[0].description", CoreMatchers.equalTo("Long description")) - .body("content[0].account.iban", CoreMatchers.equalTo("NL123INGb23039283")); - // @formatter:on - - var mockCommand = filterFactory.account(); - Mockito.verify(accountProvider).lookup(Mockito.any(AccountProvider.FilterCommand.class)); - Mockito.verify(mockCommand).types(Collections.List("creditor")); - } - - @Test - @DisplayName("Create account") - void create(RequestSpecification spec) { - var account = Mockito.spy(Account.builder() - .id(1L) - .balance(0D) - .name("Sample account") - .type("checking") - .currency("EUR") - .build()); - - Mockito.when(accountProvider.lookup("Sample account")) - .thenReturn(Control.Option()) - .thenReturn(Control.Option(account)); - - // @formatter:off - spec.when() - .body(""" - { - "name": "Sample account", - "currency": "EUR", - "type": "checking", - "iban": "NL45RABO1979747032", - "interest": 0.22, - "interestPeriodicity": "MONTHS" - }""") - .put("/api/accounts") - .then() - .statusCode(200) - .body("name", CoreMatchers.equalTo("Sample account")) - .body("description", CoreMatchers.nullValue()) - .body("account.iban", CoreMatchers.equalTo("NL45RABO1979747032")) - .body("account.bic", CoreMatchers.nullValue()) - .body("account.number", CoreMatchers.nullValue()) - .body("history.firstTransaction", CoreMatchers.nullValue()) - .body("history.lastTransaction", CoreMatchers.nullValue()) - .body("type", CoreMatchers.equalTo("checking")) - .body("account.currency", CoreMatchers.equalTo("EUR")) - .body("interest.interest", CoreMatchers.equalTo(0.22F)) - .body("interest.periodicity", CoreMatchers.equalTo("MONTHS")); - // @formatter:on - - Mockito.verify(accountProvider, Mockito.times(2)).lookup("Sample account"); - Mockito.verify(account).interest(0.22, Periodicity.MONTHS); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountTopResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountTopResourceTest.java deleted file mode 100644 index ef588692..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountTopResourceTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.finance.core.DateUtils; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.factory.FilterFactory; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -@DisplayName("Account Top Resource") -class AccountTopResourceTest extends TestSetup { - - @Inject - private AccountProvider accountProvider; - @Inject - private FilterFactory filterFactory; - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Test - @DisplayName("Compute the top debtors") - void topDebtors(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("Sample account") - .description("Long description") - .iban("NL123INGb23039283") - .currency("EUR") - .balance(2000.2D) - .firstTransaction(LocalDate.of(2019, 1, 1)) - .lastTransaction(LocalDate.of(2022, 3, 23)) - .type("checking") - .build(); - - Mockito.doReturn(Collections.List(new AccountProvider.AccountSpending() { - @Override - public Account account() { - return account; - } - - @Override - public double total() { - return 1200D; - } - - @Override - public double average() { - return 50D; - } - })).when(accountProvider).top( - Mockito.any(AccountProvider.FilterCommand.class), - Mockito.eq(DateUtils.forMonth(2019, 1)), - Mockito.eq(true)); - - // @formatter:off - - spec.when() - .get("/api/accounts/top/debit/2019-01-01/2019-02-01") - .then() - .statusCode(200) - .body("$", Matchers.hasSize(1)) - .body("[0].account.id", Matchers.equalTo(1)) - .body("[0].account.name", Matchers.equalTo("Sample account")) - .body("[0].account.description", Matchers.equalTo("Long description")) - .body("[0].account.account.iban", Matchers.equalTo("NL123INGb23039283")) - .body("[0].account.account.currency", Matchers.equalTo("EUR")) - .body("[0].account.history.firstTransaction", Matchers.equalTo("2019-01-01")) - .body("[0].account.history.lastTransaction", Matchers.equalTo("2022-03-23")) - .body("[0].account.type", Matchers.equalTo("checking")) - .body("[0].total", Matchers.equalTo(1200F)) - .body("[0].average", Matchers.equalTo(50F)); - - // @formatter:on - - var mockCommand = filterFactory.account(); - Mockito.verify(accountProvider).top( - Mockito.any(AccountProvider.FilterCommand.class), - Mockito.eq(DateUtils.forMonth(2019, 1)), - Mockito.eq(true)); - Mockito.verify(mockCommand).types(Collections.List("debtor")); - } - - @Test - @DisplayName("Compute the top creditors") - void topCreditor(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("Sample account") - .description("Long description") - .iban("NL123INGb23039283") - .currency("EUR") - .balance(2000.2D) - .firstTransaction(LocalDate.of(2019, 1, 1)) - .lastTransaction(LocalDate.of(2022, 3, 23)) - .type("checking") - .build(); - - Mockito.doReturn(Collections.List(new AccountProvider.AccountSpending() { - @Override - public Account account() { - return account; - } - - @Override - public double total() { - return 1200D; - } - - @Override - public double average() { - return 50D; - } - })).when(accountProvider).top( - Mockito.any(AccountProvider.FilterCommand.class), - Mockito.eq(DateUtils.forMonth(2019, 1)), - Mockito.eq(false)); - - // @formatter:off - - spec.when() - .get("/api/accounts/top/creditor/2019-01-01/2019-02-01") - .then() - .statusCode(200) - .body("$", Matchers.hasSize(1)) - .body("[0].account.id", Matchers.equalTo(1)) - .body("[0].account.name", Matchers.equalTo("Sample account")) - .body("[0].account.description", Matchers.equalTo("Long description")) - .body("[0].account.account.iban", Matchers.equalTo("NL123INGb23039283")) - .body("[0].account.account.currency", Matchers.equalTo("EUR")) - .body("[0].account.history.firstTransaction", Matchers.equalTo("2019-01-01")) - .body("[0].account.history.lastTransaction", Matchers.equalTo("2022-03-23")) - .body("[0].account.type", Matchers.equalTo("checking")) - .body("[0].total", Matchers.equalTo(1200F)) - .body("[0].average", Matchers.equalTo(50F)); - - // @formatter:on - - var mockCommand = filterFactory.account(); - Mockito.verify(accountProvider).top( - Mockito.any(AccountProvider.FilterCommand.class), - Mockito.eq(DateUtils.forMonth(2019, 1)), - Mockito.eq(false)); - Mockito.verify(mockCommand).types(Collections.List("creditor")); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountTransactionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountTransactionResourceTest.java deleted file mode 100644 index 44748d08..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/account/AccountTransactionResourceTest.java +++ /dev/null @@ -1,510 +0,0 @@ -package com.jongsoft.finance.rest.account; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.transaction.Transaction; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -@DisplayName("Account transaction resource") -class AccountTransactionResourceTest extends TestSetup { - - private AccountTransactionResource subject; - - @Inject - private AccountProvider accountProvider; - @Inject - private TransactionProvider transactionProvider; - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Test - @DisplayName("Search for transactions") - void search(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .build(); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - - Mockito.when(transactionProvider.lookup(Mockito.any())) - .thenReturn(ResultPage.of( - Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder() - .id(2L) - .currency("EUR") - .type("debtor") - .name("From account") - .build()) - .amount(-20.00D) - .build() - )) - .build() - )); - - // @formatter:off - - spec.when() - .body(""" - { - "dateRange": { - "from": "2019-01-01", - "until": "2019-12-31" - } - }""") - .post("/api/accounts/{accountId}/transactions", 1) - .then() - .statusCode(200) - .body("info.records", Matchers.equalTo(1)) - .body("content[0].id", Matchers.equalTo(1)) - .body("content[0].description", Matchers.equalTo("Sample transaction")) - .body("content[0].currency", Matchers.equalTo("EUR")) - .body("content[0].type.code", Matchers.equalTo("DEBIT")) - .body("content[0].metadata.category", Matchers.equalTo("Grocery")) - .body("content[0].metadata.budget", Matchers.equalTo("Household")) - .body("content[0].dates.transaction", Matchers.equalTo("2019-01-15")) - .body("content[0].destination.id", Matchers.equalTo(1)) - .body("content[0].source.id", Matchers.equalTo(2)) - .body("content[0].amount", Matchers.equalTo(20.0F)); - - // @formatter:on - - Mockito.verify(accountProvider).lookup(1L); - Mockito.verify(transactionProvider).lookup(Mockito.any()); - } - - @Test - @DisplayName("Search for transactions with missing account") - void search_notfound(RequestSpecification spec) { - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option()); - - // @formatter:off - spec.when() - .body(""" - { - "dateRange": { - "from": "2019-01-01", - "until": "2019-12-31" - } - }""") - .post("/api/accounts/{accountId}/transactions", 1) - .then() - .statusCode(404) - .body("message", Matchers.equalTo("Account not found with id 1")); - // @formatter:on - } - - @Test - @DisplayName("Create a transaction for account") - void create(RequestSpecification spec) { - final Account myAccount = Mockito.spy(Account.builder().id(1L).currency("EUR").type("checking").name("My account").build()); - final Account toAccount = Account.builder().id(2L).currency("EUR").type("creditor").name("Target account").build(); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(myAccount)); - Mockito.when(accountProvider.lookup(2L)).thenReturn(Control.Option(toAccount)); - - // @formatter:off - - spec.when() - .body(""" - { - "date": "2019-01-01", - "currency": "EUR", - "description": "Sample transaction", - "amount": 20.2, - "source": { - "id": 1 - }, - "destination": { - "id": 2 - }, - "category": { - "id": 1 - }, - "budget": { - "id": 3 - } - }""") - .put("/api/accounts/{accountId}/transactions", 1) - .then() - .statusCode(204); - - // @formatter:on - - Mockito.verify(accountProvider).lookup(1L); - Mockito.verify(accountProvider).lookup(2L); - Mockito.verify(myAccount).createTransaction( - Mockito.eq(toAccount), - Mockito.eq(20.2D), - Mockito.eq(Transaction.Type.CREDIT), - Mockito.any()); - } - - @Test - @DisplayName("Fetch the first transaction for account") - void first(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .build(); - Transaction transaction = Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder().id(2L).currency("EUR").type("debtor").name("From account").build()) - .amount(-20.00D) - .build() - )) - .build(); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - Mockito.when(transactionProvider.first(Mockito.any(TransactionProvider.FilterCommand.class))) - .thenReturn(Control.Option(transaction)); - - // @formatter:off - - spec.when() - .get("/api/accounts/{accountId}/transactions/first", 1) - .then() - .statusCode(200) - .body("id", Matchers.equalTo(1)) - .body("description", Matchers.equalTo("Sample transaction")) - .body("currency", Matchers.equalTo("EUR")) - .body("type.code", Matchers.equalTo("DEBIT")) - .body("metadata.category", Matchers.equalTo("Grocery")) - .body("metadata.budget", Matchers.equalTo("Household")) - .body("dates.transaction", Matchers.equalTo("2019-01-15")) - .body("destination.id", Matchers.equalTo(1)) - .body("source.id", Matchers.equalTo(2)) - .body("amount", Matchers.equalTo(20.0F)); - - // @formatter:on - } - - @Test - @DisplayName("Fetch transaction by id") - void get(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .build(); - Transaction transaction = Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder().id(2L).currency("EUR").type("debtor").name("From account").build()) - .amount(-20.00D) - .build() - )) - .build(); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - Mockito.when(transactionProvider.lookup(123L)).thenReturn(Control.Option(transaction)); - - // @formatter:off - - spec.when() - .get("/api/accounts/{accountId}/transactions/{transactionId}", 1, 123) - .then() - .statusCode(200) - .body("id", Matchers.equalTo(1)) - .body("description", Matchers.equalTo("Sample transaction")) - .body("currency", Matchers.equalTo("EUR")) - .body("type.code", Matchers.equalTo("DEBIT")) - .body("metadata.category", Matchers.equalTo("Grocery")) - .body("metadata.budget", Matchers.equalTo("Household")) - .body("dates.transaction", Matchers.equalTo("2019-01-15")) - .body("destination.id", Matchers.equalTo(1)) - .body("source.id", Matchers.equalTo(2)) - .body("amount", Matchers.equalTo(20.0F)); - - // @formatter:on - } - - @Test - @DisplayName("Update transaction by id") - void update(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .currency("EUR") - .build(); - final Account toAccount = Account.builder() - .id(2L) - .currency("EUR") - .type("debtor") - .name("From account").build(); - - Transaction transaction = Mockito.spy(Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(toAccount) - .amount(-20.00D) - .build() - )) - .build()); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - Mockito.when(accountProvider.lookup(2L)).thenReturn(Control.Option(toAccount)); - Mockito.when(transactionProvider.lookup(123L)).thenReturn(Control.Option(transaction)); - - // @formatter:off - - spec.when() - .body(""" - { - "date": "2019-01-01", - "currency": "EUR", - "description": "Updated transaction", - "amount": 20.2, - "source": { - "id": 1 - }, - "destination": { - "id": 2 - }, - "category": { - "id": 1 - }, - "budget": { - "id": 3 - } - }""") - .post("/api/accounts/{accountId}/transactions/{transactionId}", 1, 123) - .then() - .statusCode(200) - .body("id", Matchers.equalTo(1)) - .body("description", Matchers.equalTo("Updated transaction")) - .body("currency", Matchers.equalTo("EUR")) - .body("type.code", Matchers.equalTo("CREDIT")) - .body("metadata.budget", Matchers.equalTo("Household")) - .body("dates.transaction", Matchers.equalTo("2019-01-01")) - .body("destination.id", Matchers.equalTo(2)) - .body("source.id", Matchers.equalTo(1)) - .body("amount", Matchers.equalTo(20.2F)); - - // @formatter:on - - Mockito.verify(transaction).changeAmount(20.2D, "EUR"); - Mockito.verify(transaction).changeAccount(true, account); - Mockito.verify(transaction).changeAccount(false, toAccount); - } - - @Test - @DisplayName("Split a transaction with missing transaction") - void split_noTransaction(RequestSpecification spec) { - Mockito.when(transactionProvider.lookup(123L)).thenReturn(Control.Option()); - - // @formatter:off - - spec.when() - .body(""" - { - "splits": [ - { - "description": "Part 1", - "amount": -5 - }, - { - "description": "Part 2", - "amount": -15 - } - ] - }""") - .patch("/api/accounts/{accountId}/transactions/{transactionId}", 1, 123) - .then() - .statusCode(404) - .body("message", Matchers.equalTo("No transaction found for id 123")); - - // @formatter:on - } - - @Test - @DisplayName("Split a transaction") - void split(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .user(ACTIVE_USER_IDENTIFIER) - .name("To account") - .type("checking") - .currency("EUR") - .build(); - final Account toAccount = Account.builder() - .id(2L) - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .type("debtor") - .name("From account").build(); - - Transaction transaction = Mockito.spy(Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(toAccount) - .amount(-20.00D) - .build() - )) - .build()); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - Mockito.when(transactionProvider.lookup(123L)).thenReturn(Control.Option(transaction)); - - // @formatter:off - - spec.when() - .body(""" - { - "splits": [ - { - "description": "Part 1", - "amount": -5 - }, - { - "description": "Part 2", - "amount": -15 - } - ] - }""") - .patch("/api/accounts/{accountId}/transactions/{transactionId}", 1, 123) - .then() - .statusCode(200) - .body("id", Matchers.equalTo(1)) - .body("description", Matchers.equalTo("Sample transaction")) - .body("currency", Matchers.equalTo("EUR")) - .body("type.code", Matchers.equalTo("DEBIT")) - .body("metadata.category", Matchers.equalTo("Grocery")) - .body("metadata.budget", Matchers.equalTo("Household")) - .body("dates.transaction", Matchers.equalTo("2019-01-15")) - .body("destination.id", Matchers.equalTo(1)) - .body("source.id", Matchers.equalTo(2)) - .body("amount", Matchers.equalTo(20.0F)) - .body("split[0].description", Matchers.equalTo("Part 1")) - .body("split[0].amount", Matchers.equalTo(5.0F)) - .body("split[1].description", Matchers.equalTo("Part 2")) - .body("split[1].amount", Matchers.equalTo(15.0F)); - - // @formatter:on - } - - @Test - @DisplayName("Delete a transaction by id") - void delete(RequestSpecification spec) { - Transaction transaction = Mockito.mock(Transaction.class); - - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .build(); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - Mockito.when(transactionProvider.lookup(123L)).thenReturn(Control.Option(transaction)); - - // @formatter:off - - spec.when() - .delete("/api/accounts/{accountId}/transactions/{transactionId}", 1, 123) - .then() - .statusCode(204); - - // @formatter:on - - Mockito.verify(transaction).delete(); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/budget/BudgetResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/budget/BudgetResourceTest.java deleted file mode 100644 index abd9e700..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/budget/BudgetResourceTest.java +++ /dev/null @@ -1,341 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.domain.user.Budget; -import com.jongsoft.finance.providers.BudgetProvider; -import com.jongsoft.finance.providers.ExpenseProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.core.reflect.ReflectionUtils; -import io.micronaut.http.HttpStatus; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.apache.commons.lang3.mutable.MutableLong; -import org.assertj.core.api.Assertions; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("Budget resource") -public class BudgetResourceTest extends TestSetup { - - @Inject - private BudgetProvider budgetProvider; - @Inject - private ExpenseProvider expenseProvider; - @Inject - private TransactionProvider transactionProvider; - - @Replaces - @MockBean - BudgetProvider budgetProvider() { - return Mockito.mock(BudgetProvider.class); - } - - @Replaces - @MockBean - ExpenseProvider expenseProvider() { - return Mockito.mock(ExpenseProvider.class); - } - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - private List knownBudgets; - - @BeforeEach - void setup() { - knownBudgets = new ArrayList<>(); - - Mockito.doAnswer(invocation -> { - var date = LocalDate.of(invocation.getArgument(0), invocation.getArgument(1, Integer.class), 1); - return Control.Option(knownBudgets.stream() - .filter(budget -> budget.getStart().isBefore(date) || budget.getStart().isEqual(date)) - .max(Comparator.comparing(Budget::getStart)) - .orElse(null)); - }).when(budgetProvider).lookup(Mockito.anyInt(), Mockito.anyInt()); - } - - @Test - @DisplayName("First budget date when a budget exists") - void firstBudgetDate(RequestSpecification spec) { - var budget = createBudget(); - Mockito.when(budgetProvider.first()).thenReturn( - Control.Option(budget)); - - // @formatter:off - var body = spec.given() - .contentType("application/json") - .when() - .get("/api/budgets") - .then() - .statusCode(HttpStatus.OK.getCode()) - .extract() - .asString(); - // @formatter:on - - Assertions.assertThat(body) - .as("The first date of a budget") - .isEqualTo("\"2018-02-01\""); - } - - @Test - @DisplayName("First budget without any budget existing") - void firstBudget_notFound(RequestSpecification spec) { - when(budgetProvider.first()).thenReturn(Control.Option()); - - // @formatter:off - spec.when() - .get("/api/budgets") - .then() - .statusCode(HttpStatus.NOT_FOUND.getCode()); - // @formatter:on - } - - @Test - @DisplayName("Get the budget for the current month") - void currentMonth(RequestSpecification spec) { - knownBudgets.add(createBudget()); - - // @formatter:off - spec.when() - .get("/api/budgets/current") - .then() - .statusCode(200) - .body("income", Matchers.equalTo(200.20F)) - .body("period.from", Matchers.equalTo("2018-02-01")) - .body("expenses", Matchers.hasSize(1)) - .body("expenses[0].id", Matchers.equalTo(1)) - .body("expenses[0].name", Matchers.equalTo("Grocery")) - .body("expenses[0].expected", Matchers.equalTo(40F)); - // @formatter:on - } - - @Test - @DisplayName("Get the budget for the year 2019 and month Feb") - void givenMonth(RequestSpecification spec) { - knownBudgets.add(createBudget()); - - // @formatter:off - spec.when() - .get("/api/budgets/{year}/{month}", 2019, 2) - .then() - .statusCode(200) - .body("income", Matchers.equalTo(200.20F)) - .body("period.from", Matchers.equalTo("2018-02-01")) - .body("expenses", Matchers.hasSize(1)) - .body("expenses[0].id", Matchers.equalTo(1)) - .body("expenses[0].name", Matchers.equalTo("Grocery")) - .body("expenses[0].expected", Matchers.equalTo(40F)); - // @formatter:on - } - - @Test - @DisplayName("Autocomplete expense based upon token") - void autocomplete(RequestSpecification spec) { - Mockito.when(expenseProvider.lookup(Mockito.any())).thenReturn(ResultPage.of( - new EntityRef.NamedEntity(1, "Groceries"))); - - // @formatter:off - spec.when() - .get("/api/budgets/auto-complete?token=gro") - .then() - .statusCode(200) - .body("name", Matchers.hasItem("Groceries")); - // @formatter:on - - var mockFilter = filterFactory.expense(); - verify(expenseProvider).lookup(Mockito.any(ExpenseProvider.FilterCommand.class)); - verify(mockFilter).name("gro", false); - } - - @Test - @DisplayName("Start the first budget for an account") - void create(RequestSpecification spec) { - // @formatter:off - spec.when() - .body(""" - { - "month": 2, - "year": 2019, - "income": 2300.33 - } - """) - .put("/api/budgets") - .then() - .statusCode(201); - // @formatter:on - - Mockito.verify(ACTIVE_USER).createBudget(LocalDate.of(2019, 2, 1), 2300.33); - } - - @Test - @DisplayName("Start the first budget, but one already exists") - void create_alreadyPresent(RequestSpecification spec) { - knownBudgets.add(createBudget()); - - // @formatter:off - spec.when() - .body(""" - { - "month": 2, - "year": 2019, - "income": 2300.33 - } - """) - .put("/api/budgets") - .then() - .statusCode(400) - .body("message", Matchers.equalTo("Cannot start a new budget, there is already a budget open.")); - // @formatter:on - } - - @Test - @DisplayName("Patch the budget, but there is no active budget") - void patchBudget_noActiveBudget(RequestSpecification spec) { - // @formatter:off - spec.when() - .body(""" - { - "month": 2, - "year": 2019, - "income": 2300.33 - } - """) - .patch("/api/budgets") - .then() - .statusCode(404) - .body("message", Matchers.equalTo("No budget is active yet, create a budget first.")); - // @formatter:on - } - - @Test - @DisplayName("Patch the budget") - void patchBudget(RequestSpecification spec) { - var budget = createBudget(); - knownBudgets.add(budget); - - // @formatter:off - spec.when() - .body(""" - { - "month": 2, - "year": 2019, - "income": 3500.00 - } - """) - .patch("/api/budgets") - .then() - .statusCode(200) - .body("period.from", Matchers.equalTo("2019-02-01")) - .body("income", Matchers.equalTo(3500F)) - .body("expenses[0].name", Matchers.equalTo("Grocery")) - .body("expenses[0].expected", Matchers.equalTo(700F)); - // @formatter:on - - Mockito.verify(budget).indexBudget(LocalDate.of(2019, 2, 1), 3500D); - } - - @Test - @DisplayName("Create a new expense in an existing budget") - void createNewExpense(RequestSpecification spec) { - knownBudgets.add(createBudget()); - - // @formatter:off - spec.when() - .body(new ExpensePatchRequest(null, "Car", 10)) - .patch("/api/budgets/expenses") - .then() - .statusCode(200) - .body("period.from", Matchers.equalTo("2018-02-01")) - .body("income", Matchers.equalTo(200.2F)) - .body("expenses[1].name", Matchers.equalTo("Grocery")) - .body("expenses[1].expected", Matchers.equalTo(40F)) - .body("expenses[0].name", Matchers.equalTo("Car")) - .body("expenses[0].expected", Matchers.equalTo(10F)); - // @formatter:on - } - - @Test - @DisplayName("Update expense forcing new budget period") - void updateExistingExpense(RequestSpecification spec) { - var now = LocalDate.now(); - knownBudgets.add(createBudget()); - - // @formatter:off - spec.when() - .body(new ExpensePatchRequest(1L, "Grocery", 40)) - .patch("/api/budgets/expenses") - .then() - .statusCode(200) - .body("period.from", Matchers.equalTo(now.withDayOfMonth(1).toString())) - .body("income", Matchers.equalTo(200.2F)) - .body("expenses[0].name", Matchers.equalTo("Grocery")) - .body("expenses[0].expected", Matchers.equalTo(40F)); - // @formatter:on - } - - @Test - @DisplayName("Compute the daily expenses for a budget") - void computeExpense(RequestSpecification spec) { - knownBudgets.add(createBudget()); - Mockito.when(transactionProvider.balance(Mockito.any())) - .thenReturn(Control.Option(BigDecimal.valueOf(200))); - - // @formatter:off - spec.when() - .get("/api/budgets/expenses/{id}/{year}/{month}", 1, 2019, 1) - .then() - .statusCode(200) - .body("$", Matchers.hasSize(1)) - .body("[0].dailySpent", Matchers.equalTo(6.45F)) - .body("[0].left", Matchers.equalTo(-160F)) - .body("[0].dailyLeft", Matchers.equalTo(-5.16F)); - // @formatter:on - } - - private Budget createBudget() { - var budget = Mockito.spy(Budget.builder() - .id(1L) - .expectedIncome(200.20D) - .start(LocalDate.of(2018, 2, 1)) - .build()); - budget.new Expense(1, "Grocery", 40); - - var mutableId = new MutableLong(10); - Mockito.doAnswer(invocation -> { - invocation.callRealMethod(); - var found = budget.getExpenses() - .first(expense -> expense.getId() == null) - .get(); - ReflectionUtils.setField(Budget.Expense.class, "id", found, mutableId.incrementAndGet()); - return null; - }).when(budget).createExpense(Mockito.any(), Mockito.anyDouble(), Mockito.anyDouble()); - - Mockito.doAnswer(invocationOnMock -> { - var updatedBudget = (Budget) invocationOnMock.callRealMethod(); - knownBudgets.add(updatedBudget); - return updatedBudget; - }).when(budget).indexBudget(Mockito.any(LocalDate.class), Mockito.anyDouble()); - - return budget; - } -} \ No newline at end of file diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/budget/ExpenseTransactionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/budget/ExpenseTransactionResourceTest.java deleted file mode 100644 index a4a6e19c..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/budget/ExpenseTransactionResourceTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.jongsoft.finance.rest.budget; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -@DisplayName("Expenses for budget resource") -class ExpenseTransactionResourceTest extends TestSetup { - - @Inject - private TransactionProvider transactionProvider; - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Test - @DisplayName("Fetch transactions for expense") - void transactions(RequestSpecification spec) { - Mockito.when(transactionProvider.lookup(Mockito.any())).thenReturn(ResultPage.empty()); - - // @formatter:off - spec - .when() - .get("/api/budgets/expenses/{expenseId}/{year}/{month}/transactions", 1L, 2016, 1) - .then() - .statusCode(200); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - Mockito.verify(transactionProvider).lookup(Mockito.any()); - Mockito.verify(mockFilter).onlyIncome(false); - Mockito.verify(mockFilter).ownAccounts(); - Mockito.verify(mockFilter).expenses(Collections.List(new EntityRef(1L))); - } -} \ No newline at end of file diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/category/CategoryResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/category/CategoryResourceTest.java deleted file mode 100644 index 35463b22..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/category/CategoryResourceTest.java +++ /dev/null @@ -1,249 +0,0 @@ -package com.jongsoft.finance.rest.category; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.user.Category; -import com.jongsoft.finance.providers.CategoryProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("Category Resource") -class CategoryResourceTest extends TestSetup { - - @Inject - private CategoryProvider categoryProvider; - - @Replaces - @MockBean - CategoryProvider categoryProvider() { - return Mockito.mock(CategoryProvider.class); - } - - @Test - @DisplayName("List all categories") - void list(RequestSpecification spec) { - when(categoryProvider.lookup()).thenReturn(Collections.List( - Category.builder() - .id(1L) - .label("grocery") - .description("For groceries") - .lastActivity(LocalDate.of(2019, 1, 2)) - .build())); - - // @formatter:off - spec - .when() - .get("/api/categories") - .then() - .statusCode(200) - .body("$", org.hamcrest.Matchers.hasSize(1)) - .body("[0].id", org.hamcrest.Matchers.equalTo(1)); - // @formatter:on - } - - @Test - @DisplayName("Search categories") - void search(RequestSpecification spec) { - when(categoryProvider.lookup(Mockito.any(CategoryProvider.FilterCommand.class))).thenReturn(ResultPage.of( - Category.builder() - .id(1L) - .label("grocery") - .description("For groceries") - .lastActivity(LocalDate.of(2019, 1, 2)) - .build())); - - // @formatter:off - spec - .given() - .body(""" - { - "page": 1 - }""") - .when() - .post("/api/categories") - .then() - .statusCode(200) - .body("info.records", org.hamcrest.Matchers.equalTo(1)) - .body("content", org.hamcrest.Matchers.hasSize(1)) - .body("content[0].id", org.hamcrest.Matchers.equalTo(1)); - // @formatter:on - } - - @Test - @DisplayName("Autocomplete categories by token") - void autocomplete(RequestSpecification spec) { - when(categoryProvider.lookup(Mockito.any(CategoryProvider.FilterCommand.class))).thenReturn( - ResultPage.of(Category.builder() - .id(1L) - .label("grocery") - .user(ACTIVE_USER) - .description("For groceries") - .lastActivity(LocalDate.of(2019, 1, 2)) - .build())); - - // @formatter:off - spec - .when() - .get("/api/categories/auto-complete?token=gro") - .then() - .statusCode(200) - .body("$", org.hamcrest.Matchers.hasSize(1)) - .body("[0].id", org.hamcrest.Matchers.equalTo(1)); - // @formatter:on - - var mockFilter = filterFactory.category(); - verify(categoryProvider).lookup(Mockito.any(CategoryProvider.FilterCommand.class)); - verify(mockFilter).label("gro", false); - } - - @Test - @DisplayName("Create category") - void create(RequestSpecification spec) { - when(categoryProvider.lookup("grocery")).thenReturn( - Control.Option(Category.builder() - .id(1L) - .build())); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "grocery", - "description": "Sample" - }""") - .when() - .put("/api/categories") - .then() - .statusCode(201) - .body("id", org.hamcrest.Matchers.equalTo(1)); - // @formatter:on - } - - @Test - @DisplayName("Fetch category") - void get(RequestSpecification spec) { - when(categoryProvider.lookup(1L)).thenReturn( - Control.Option(Category.builder() - .id(1L) - .user(ACTIVE_USER) - .label("grocery") - .description("For groceries") - .lastActivity(LocalDate.of(2019, 1, 2)) - .build())); - - // @formatter:off - spec - .when() - .get("/api/categories/1") - .then() - .statusCode(200) - .body("id", org.hamcrest.Matchers.equalTo(1)) - .body("label", org.hamcrest.Matchers.equalTo("grocery")) - .body("description", org.hamcrest.Matchers.equalTo("For groceries")) - .body("lastUsed", org.hamcrest.Matchers.equalTo("2019-01-02")); - // @formatter:on - } - - @Test - @DisplayName("Fetch category not found") - void get_notFound(RequestSpecification spec) { - when(categoryProvider.lookup(1L)).thenReturn(Control.Option()); - - // @formatter:off - spec - .when() - .get("/api/categories/1") - .then() - .statusCode(404) - .body("message", org.hamcrest.Matchers.equalTo("No category found with id 1")); - // @formatter:on - } - - @Test - @DisplayName("Update category") - void update(RequestSpecification spec) { - Category category = Mockito.spy(Category.builder() - .id(1L) - .label("grocery") - .user(ACTIVE_USER) - .description("For groceries") - .lastActivity(LocalDate.of(2019, 1, 2)) - .build()); - - when(categoryProvider.lookup(1L)).thenReturn(Control.Option(category)); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "grocery", - "description": "Sample" - }""") - .when() - .post("/api/categories/1") - .then() - .statusCode(200) - .body("id", org.hamcrest.Matchers.equalTo(1)) - .body("label", org.hamcrest.Matchers.equalTo("grocery")) - .body("description", org.hamcrest.Matchers.equalTo("Sample")) - .body("lastUsed", org.hamcrest.Matchers.equalTo("2019-01-02")); - // @formatter:on - - verify(category).rename("grocery", "Sample"); - } - - @Test - @DisplayName("Update category not found") - void update_notFound(RequestSpecification spec) { - when(categoryProvider.lookup(1L)).thenReturn(Control.Option()); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "grocery", - "description": "Sample" - }""") - .when() - .post("/api/categories/{id}", 1L) - .then() - .statusCode(404) - .body("message", org.hamcrest.Matchers.equalTo("No category found with id 1")); - // @formatter:on - } - - @Test - @DisplayName("Delete category") - void delete(RequestSpecification spec) { - Category category = Mockito.mock(Category.class); - - when(category.getUser()).thenReturn(ACTIVE_USER); - when(categoryProvider.lookup(1L)).thenReturn(Control.Option(category)); - - // @formatter:off - spec - .when() - .delete("/api/categories/1") - .then() - .statusCode(204); - // @formatter:on - - verify(category).remove(); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/contract/ContractResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/contract/ContractResourceTest.java deleted file mode 100644 index 3b9e1432..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/contract/ContractResourceTest.java +++ /dev/null @@ -1,403 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.account.Contract; -import com.jongsoft.finance.domain.transaction.ScheduleValue; -import com.jongsoft.finance.domain.transaction.ScheduledTransaction; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.ContractProvider; -import com.jongsoft.finance.providers.TransactionScheduleProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.finance.schedule.Periodicity; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("Contract Resource") -class ContractResourceTest extends TestSetup { - - @Inject - private AccountProvider accountProvider; - @Inject - private ContractProvider contractProvider; - @Inject - private TransactionScheduleProvider scheduleProvider; - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Replaces - @MockBean - ContractProvider contractProvider() { - return Mockito.mock(ContractProvider.class); - } - - @Replaces - @MockBean - TransactionScheduleProvider scheduleProvider() { - return Mockito.mock(TransactionScheduleProvider.class); - } - - @Test - @DisplayName("List all contracts") - void list(RequestSpecification spec) { - when(contractProvider.lookup()).thenReturn(Collections.List( - Contract.builder() - .id(1L) - .name("Contract 1") - .startDate(LocalDate.of(2019, 2, 1)) - .endDate(LocalDate.of(2019, 2, 1)) - .build(), - Contract.builder() - .id(2L) - .name("Contract 2") - .terminated(true) - .startDate(LocalDate.of(2019, 2, 1)) - .endDate(LocalDate.of(2019, 2, 1)) - .build() - )); - - // @formatter:off - spec - .when() - .get("/api/contracts") - .then() - .statusCode(200) - .body("active", org.hamcrest.Matchers.hasSize(1)) - .body("active[0].id", org.hamcrest.Matchers.equalTo(1)) - .body("terminated", org.hamcrest.Matchers.hasSize(1)) - .body("terminated[0].id", org.hamcrest.Matchers.equalTo(2)); - // @formatter:on - } - - @Test - @DisplayName("Autocomplete contracts") - void autocomplete(RequestSpecification spec) { - when(contractProvider.search("cont")).thenReturn(Collections.List( - Contract.builder() - .id(1L) - .name("Contract 1") - .startDate(LocalDate.of(2019, 2, 1)) - .endDate(LocalDate.of(2019, 2, 1)) - .build(), - Contract.builder() - .id(2L) - .name("Contract 2") - .terminated(true) - .startDate(LocalDate.of(2019, 2, 1)) - .endDate(LocalDate.of(2019, 2, 1)) - .build() - )); - - // @formatter:off - spec - .when() - .get("/api/contracts/auto-complete?token=cont") - .then() - .statusCode(200) - .body("$", org.hamcrest.Matchers.hasSize(2)) - .body("[0].id", org.hamcrest.Matchers.equalTo(1)) - .body("[1].id", org.hamcrest.Matchers.equalTo(2)); - // @formatter:on - } - - @Test - @DisplayName("Create contract") - void create(RequestSpecification spec) { - var account = Account.builder() - .id(1L) - .balance(0D) - .name("Sample account") - .currency("EUR") - .build(); - - when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - when(contractProvider.lookup("Test Contract")) - .thenReturn(Control.Option(Contract.builder() - .id(1L) - .name("Test Contract") - .company(account) - .startDate(LocalDate.of(2019, 2, 1)) - .endDate(LocalDate.of(2020, 2, 1)) - .build())); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "Test Contract", - "company": { - "id": 1 - }, - "start": "2019-02-01", - "end": "2020-02-01" - }""") - .when() - .put("/api/contracts") - .then() - .statusCode(201) - .body("id", org.hamcrest.Matchers.equalTo(1)) - .body("name", org.hamcrest.Matchers.equalTo("Test Contract")) - .body("contractAvailable", org.hamcrest.Matchers.equalTo(false)) - .body("company.id", org.hamcrest.Matchers.equalTo(1)) - .body("company.name", org.hamcrest.Matchers.equalTo("Sample account")) - .body("start", org.hamcrest.Matchers.equalTo("2019-02-01")) - .body("end", org.hamcrest.Matchers.equalTo("2020-02-01")); - // @formatter:on - } - - @Test - @DisplayName("Create contract with no account") - void create_accountNotFound(RequestSpecification spec) { - when(accountProvider.lookup(1L)).thenReturn(Control.Option()); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "Test Contract", - "company": { - "id": 1 - }, - "start": "2019-02-01", - "end": "2020-02-01" - }""") - .when() - .put("/api/contracts") - .then() - .statusCode(404) - .body("message", org.hamcrest.Matchers.equalTo("No account can be found for 1")); - // @formatter:on - } - - @Test - @DisplayName("Update existing contract") - void update(RequestSpecification spec) { - final Contract contract = Mockito.mock(Contract.class); - - when(contractProvider.lookup(1L)).thenReturn(Control.Option(contract)); - when(contract.getCompany()).thenReturn(Account.builder().user(ACTIVE_USER_IDENTIFIER).build()); - when(contract.getCompany()).thenReturn(Account.builder() - .id(1L) - .balance(0D) - .name("Sample account") - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .build()); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "Test Contract", - "company": { - "id": 1 - }, - "start": "2019-02-01", - "end": "2022-02-01" - }""") - .when() - .post("/api/contracts/1") - .then() - .statusCode(200) - .body("contractAvailable", org.hamcrest.Matchers.equalTo(false)) - .body("company.id", org.hamcrest.Matchers.equalTo(1)) - .body("company.name", org.hamcrest.Matchers.equalTo("Sample account")); - // @formatter:on - - verify(contract).change( - "Test Contract", - null, - LocalDate.of(2019, 2, 1), - LocalDate.of(2022, 2, 1)); - } - - @Test - @DisplayName("Update contract not found") - void update_notFound(RequestSpecification spec) { - when(contractProvider.lookup(1L)).thenReturn(Control.Option()); - - // @formatter:off - spec - .given() - .body(""" - { - "name": "Test Contract", - "company": { - "id": 1 - }, - "start": "2019-02-01", - "end": "2022-02-01" - }""") - .when() - .post("/api/contracts/{id}", 1) - .then() - .statusCode(404) - .body("message", org.hamcrest.Matchers.equalTo("No contract can be found for 1")); - // @formatter:on - } - - @Test - @DisplayName("Get contract") - void get(RequestSpecification spec) { - when(contractProvider.lookup(1L)).thenReturn(Control.Option( - Contract.builder() - .id(1L) - .name("Test contract") - .company(Account.builder() - .id(1L) - .balance(0D) - .name("Sample account") - .user(ACTIVE_USER_IDENTIFIER) - .currency("EUR") - .build()) - .description("Sample contract") - .startDate(LocalDate.of(2019, 1, 1)) - .endDate(LocalDate.now().plusMonths(1)) - .build())); - - // @formatter:off - spec - .when() - .get("/api/contracts/{id}", 1) - .then() - .statusCode(200) - .body("id", org.hamcrest.Matchers.equalTo(1)) - .body("name", org.hamcrest.Matchers.equalTo("Test contract")) - .body("description", org.hamcrest.Matchers.equalTo("Sample contract")) - .body("start", org.hamcrest.Matchers.equalTo("2019-01-01")) - .body("end", org.hamcrest.Matchers.equalTo(LocalDate.now().plusMonths(1).toString())) - .body("company.id", org.hamcrest.Matchers.equalTo(1)) - .body("company.name", org.hamcrest.Matchers.equalTo("Sample account")) - .body("company.account.currency", org.hamcrest.Matchers.equalTo("EUR")); - // @formatter:on - } - - @Test - @DisplayName("Schedule transaction for contract") - void schedule(RequestSpecification spec) { - var contract = Mockito.spy(Contract.builder() - .id(1L) - .startDate(LocalDate.of(2020, 1, 1)) - .endDate(LocalDate.of(2022, 1, 1)) - .build()); - var schedule = Mockito.spy(ScheduledTransaction.builder() - .id(2L) - .contract(contract) - .build()); - final Account account = Account.builder().id(1L).build(); - var filterCommand = filterFactory.schedule(); - - when(accountProvider.lookup(1L)).thenReturn(Control.Option(account)); - - Mockito.doReturn(Control.Option(contract)) - .when(contractProvider) - .lookup(1L); - - Mockito.doReturn(ResultPage.of(schedule)) - .when(scheduleProvider) - .lookup(filterCommand); - - // @formatter:off - spec - .when() - .body(""" - { - "amount": 20.2, - "source": { - "id": 1 - }, - "schedule": { - "periodicity": "MONTHS", - "interval": 3 - } - }""") - .put("/api/contracts/1/schedule") - .then() - .statusCode(200); - // @formatter:on - - verify(contract).createSchedule(new ScheduleValue(Periodicity.MONTHS, 3), account, 20.2); - verify(schedule).limitForContract(); - } - - @Test - @DisplayName("Warn before expiry date") - void warnExpiry(RequestSpecification spec) { - final Contract contract = Mockito.mock(Contract.class); - - when(contractProvider.lookup(1L)).thenReturn(Control.Option(contract)); - when(contract.getCompany()).thenReturn(Account.builder().id(1L).user(ACTIVE_USER_IDENTIFIER).build()); - - // @formatter:off - spec - .when() - .get("/api/contracts/{id}/expire-warning", 1) - .then() - .statusCode(200); - // @formatter:on - - verify(contract).warnBeforeExpires(); - } - - @Test - @DisplayName("Attach PDF file for contract") - void attachment(RequestSpecification spec) { - final Contract contract = Mockito.mock(Contract.class); - - when(contractProvider.lookup(1L)).thenReturn(Control.Option(contract)); - when(contract.getCompany()).thenReturn(Account.builder().id(1L).user(ACTIVE_USER_IDENTIFIER).build()); - - // @formatter:off - spec - .when() - .body(""" - { - "fileCode": "file-code-1" - }""") - .post("/api/contracts/{id}/attachment", 1) - .then() - .statusCode(200); - // @formatter:on - - verify(contract).registerUpload("file-code-1"); - } - - @Test - @DisplayName("Delete contract") - void delete(RequestSpecification spec) { - final Contract contract = Mockito.mock(Contract.class); - - when(contractProvider.lookup(1L)).thenReturn(Control.Option(contract)); - when(contract.getCompany()).thenReturn(Account.builder().user(ACTIVE_USER_IDENTIFIER).build()); - - // @formatter:off - spec - .when() - .delete("/api/contracts/{id}", 1) - .then() - .statusCode(200); - // @formatter:on - - verify(contract).terminate(); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/contract/ContractTransactionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/contract/ContractTransactionResourceTest.java deleted file mode 100644 index 170e3bf5..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/contract/ContractTransactionResourceTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.jongsoft.finance.rest.contract; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.core.EntityRef; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -@DisplayName("Contract Transaction Resource") -class ContractTransactionResourceTest extends TestSetup { - - @Inject - private TransactionProvider transactionProvider; - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Test - @DisplayName("should return transactions for contract") - void transactions(RequestSpecification spec) { - Mockito.when(transactionProvider.lookup(Mockito.any())).thenReturn(ResultPage.empty()); - - // @formatter:off - spec - .when() - .get("/api/contracts/{contractId}/transactions", 1) - .then() - .statusCode(200); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - Mockito.verify(transactionProvider).lookup(Mockito.any()); - Mockito.verify(mockFilter).onlyIncome(false); - Mockito.verify(mockFilter).ownAccounts(); - Mockito.verify(mockFilter).contracts(Collections.List(new EntityRef(1L))); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/file/FileResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/file/FileResourceTest.java deleted file mode 100644 index 47267bf8..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/file/FileResourceTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.jongsoft.finance.rest.file; - -import com.jongsoft.finance.StorageService; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.io.File; -import java.io.IOException; - -@DisplayName("Attachment Resource") -class FileResourceTest extends TestSetup { - - @Inject - private StorageService storageService; - - @Replaces - @MockBean - StorageService storageService() { - return Mockito.mock(StorageService.class); - } - - @Test - @DisplayName("Upload file") - void upload(RequestSpecification spec) throws IOException { - Mockito.when(storageService.store("sample-data".getBytes())).thenReturn("sample-token"); - - // @formatter:off - spec - .given() - .contentType("multipart/form-data") - .multiPart("upload", new File(getClass().getResource("/application.yml").getFile())) - .when() - .post("/api/attachment") - .then() - .statusCode(201); - // @formatter:on - - Mockito.verify(storageService).store(Mockito.any()); - } - - @Test - @DisplayName("Download file") - void download(RequestSpecification spec) { - Mockito.when(storageService.read("fasjkdh8nfasd8")).thenReturn(Control.Option("sample-token".getBytes())); - - // @formatter:off - spec.when() - .get("/api/attachment/fasjkdh8nfasd8") - .then() - .statusCode(200) - .body(Matchers.equalTo("sample-token")); - // @formatter:on - - Mockito.verify(storageService).read("fasjkdh8nfasd8"); - } - - @Test - @DisplayName("Delete file") - void delete(RequestSpecification spec) { - - // @formatter:off - spec.when() - .delete("/api/attachment/fasjkdh8nfasd8") - .then() - .statusCode(204); - // @formatter:on - - Mockito.verify(storageService).remove("fasjkdh8nfasd8"); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/importer/BatchImportResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/importer/BatchImportResourceTest.java deleted file mode 100644 index 6ffee29a..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/importer/BatchImportResourceTest.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.core.DateUtils; -import com.jongsoft.finance.domain.importer.BatchImport; -import com.jongsoft.finance.domain.importer.BatchImportConfig; -import com.jongsoft.finance.providers.ImportConfigurationProvider; -import com.jongsoft.finance.providers.ImportProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; -import java.util.Date; -import java.util.Map; - -@DisplayName("Batch importing resource") -class BatchImportResourceTest extends TestSetup { - - @Inject - private ImportProvider importProvider; - @Inject - private ImportConfigurationProvider csvConfigProvider; - - @Replaces - @MockBean - ImportConfigurationProvider configProvider() { - return Mockito.mock(ImportConfigurationProvider.class); - } - - @Replaces - @MockBean - ImportProvider importProvider() { - return Mockito.mock(ImportProvider.class); - } - - @Test - @DisplayName("List all existing import jobs") - void list(RequestSpecification spec) { - var resultPage = ResultPage.of(BatchImport.builder() - .id(1L) - .created(DateUtils.toDate(LocalDate.of(2019, 1, 1))) - .slug("batch-import-slug") - .fileCode("sample big content") - .build()); - Mockito.when(importProvider.lookup(Mockito.any(ImportProvider.FilterCommand.class))).thenReturn(resultPage); - - // @formatter:off - spec - .given() - .body(new ImportSearchRequest(0)) - .when() - .post("/api/import") - .then() - .statusCode(200) - .body("info.records", Matchers.equalTo(1)) - .body("content[0].slug", Matchers.equalTo("batch-import-slug")); - // @formatter:on - } - - @Test - @DisplayName("Create a new import job") - void create(RequestSpecification spec) { - var mockConfig = Mockito.mock(BatchImportConfig.class); - - Mockito.when(csvConfigProvider.lookup("sample-configuration")).thenReturn(Control.Option(mockConfig)); - Mockito.when(mockConfig.createImport("token-sample")).thenReturn( - BatchImport.builder() - .created(DateUtils.toDate(LocalDate.of(2019, 2, 1))) - .fileCode("token-sample") - .slug("xd2rsd-2fasd-q2ff-asd") - .build()); - - // @formatter:off - spec - .given() - .body(Map.of("configuration", "sample-configuration", "uploadToken", "token-sample")) - .when() - .put("/api/import") - .then() - .statusCode(200) - .body("slug", Matchers.equalTo("xd2rsd-2fasd-q2ff-asd")); - // @formatter:on - - Mockito.verify(mockConfig).createImport("token-sample"); - } - - @Test - @DisplayName("Get an existing import job") - void get(RequestSpecification spec) { - Mockito.when(importProvider.lookup("xd2rsd-2fasd-q2ff-asd")).thenReturn( - Control.Option(BatchImport.builder() - .created(DateUtils.toDate(LocalDate.of(2019, 2, 1))) - .fileCode("token-sample") - .slug("xd2rsd-2fasd-q2ff-asd") - .config(BatchImportConfig.builder() - .id(1L) - .fileCode("xd2rsd-2fasd-33dfd-ddfa") - .name("sample-config.json") - .build()) - .finished(DateUtils.toDate(LocalDate.of(2019, 2, 2))) - .totalExpense(200.2D) - .totalIncome(303.40D) - .build())); - - // @formatter:off - spec - .when() - .get("/api/import/xd2rsd-2fasd-q2ff-asd") - .then() - .statusCode(200) - .body("slug", Matchers.equalTo("xd2rsd-2fasd-q2ff-asd")) - .body("config.file", Matchers.equalTo("xd2rsd-2fasd-33dfd-ddfa")) - .body("config.name", Matchers.equalTo("sample-config.json")) - .body("balance.totalExpense", Matchers.equalTo(200.2F)) - .body("balance.totalIncome", Matchers.equalTo(303.4F)); - // @formatter:on - } - - @Test - @DisplayName("Delete an existing import job") - void delete_success(RequestSpecification spec) { - Mockito.when(importProvider.lookup("xd2rsd-2fasd-q2ff-asd")).thenReturn( - Control.Option(BatchImport.builder() - .id(1L) - .created(DateUtils.toDate(LocalDate.of(2019, 2, 1))) - .fileCode("token-sample") - .slug("xd2rsd-2fasd-q2ff-asd") - .config(BatchImportConfig.builder() - .id(1L) - .fileCode("xd2rsd-2fasd-33dfd-ddfa") - .name("sample-config.json") - .build()) - .totalExpense(200.2D) - .totalIncome(303.40D) - .build())); - - // @formatter:off - spec - .when() - .delete("/api/import/xd2rsd-2fasd-q2ff-asd") - .then() - .statusCode(204); - // @formatter:on - } - - @Test - @DisplayName("Delete an existing import job that has already finished") - void delete_alreadyFinished(RequestSpecification spec) { - Mockito.when(importProvider.lookup("xd2rsd-2fasd-q2ff-asd")).thenReturn( - Control.Option(BatchImport.builder() - .created(DateUtils.toDate(LocalDate.of(2019, 2, 1))) - .fileCode("token-sample") - .slug("xd2rsd-2fasd-q2ff-asd") - .config(BatchImportConfig.builder() - .id(1L) - .fileCode("xd2rsd-2fasd-33dfd-ddfa") - .name("sample-config.json") - .build()) - .finished(new Date()) - .totalExpense(200.2D) - .totalIncome(303.40D) - .build())); - - // @formatter:off - spec - .when() - .delete("/api/import/xd2rsd-2fasd-q2ff-asd") - .then() - .statusCode(400) - .body("message", Matchers.equalTo("Cannot archive an import job that has finished running.")); - // @formatter:on - } - - @Test - @DisplayName("List all existing import configurations") - void config(RequestSpecification spec) { - Mockito.when(csvConfigProvider.lookup()).thenReturn(Collections.List( - BatchImportConfig.builder() - .id(1L) - .name("Import config test") - .build())); - - // @formatter:off - spec - .when() - .get("/api/import/config") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(1)) - .body("[0].name", Matchers.equalTo("Import config test")); - // @formatter:on - } - - @Test - @DisplayName("Create a new import configuration") - void createConfig(RequestSpecification spec) { - Mockito.when(csvConfigProvider.lookup("sample-configuration")).thenReturn(Control.Option()); - - // @formatter:off - spec - .given() - .body(Map.of( - "type", "csv", - "name", "sample-configuration", - "fileCode", "token-sample")) - .when() - .put("/api/import/config") - .then() - .statusCode(200) - .body("name", Matchers.equalTo("sample-configuration")) - .body("file", Matchers.equalTo("token-sample")); - // @formatter:on - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/importer/ImporterTransactionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/importer/ImporterTransactionResourceTest.java deleted file mode 100644 index 0c77f422..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/importer/ImporterTransactionResourceTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.jongsoft.finance.rest.importer; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.transaction.Transaction; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.providers.TransactionRuleProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.finance.rest.process.RuntimeResource; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -@DisplayName("Import transactions resource") -class ImporterTransactionResourceTest extends TestSetup { - - @Inject - private TransactionProvider transactionProvider; - - @Inject - private TransactionRuleProvider transactionRuleProvider; - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Replaces - @MockBean - TransactionRuleProvider transactionRuleProvider() { - return Mockito.mock(TransactionRuleProvider.class); - } - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Replaces - @MockBean - RuntimeResource runtimeResource() { - return Mockito.mock(RuntimeResource.class); - } - - @Test - @DisplayName("Search transactions by batch slug") - void search(RequestSpecification spec) { - prepareTransactionsIntoMock(); - - // @formatter:off - spec - .given() - .body(new TransactionSearchRequest(0)) - .when() - .post("/api/import/{batchSlug}/transactions", "ads-fasdfa-fasd") - .then() - .statusCode(200) - .body("content[0].description", Matchers.equalTo("Sample transaction")); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - - Mockito.verify(mockFilter).importSlug("ads-fasdfa-fasd"); - Mockito.verify(mockFilter).page(0, 0); - Mockito.verify(transactionProvider).lookup(Mockito.any()); - } - - @Test - @DisplayName("Run transaction rules") - void runTransactionRules(RequestSpecification spec) { - prepareTransactionsIntoMock(); - - // @formatter:off - spec - .when() - .post("/api/import/{batchSlug}/transactions/run-rule-automation", "ads-fasdfa-fasd") - .then() - .statusCode(204); - // @formatter:on - - Mockito.verify(transactionRuleProvider).lookup(); - } - - @Test - @DisplayName("Delete transaction attached to batch job") - void delete(RequestSpecification spec) { - Transaction transaction = Mockito.mock(Transaction.class); - - Mockito.when(transactionProvider.lookup(123L)).thenReturn(Control.Option(transaction)); - - // @formatter:off - spec - .when() - .delete("/api/import/{batchSlug}/transactions/{transactionId}", "ads-fasdfa-fasd", 123L) - .then() - .statusCode(204); - // @formatter:on - - Mockito.verify(transactionProvider).lookup(123L); - Mockito.verify(transaction).delete(); - } - - private void prepareTransactionsIntoMock() { - Mockito.when(transactionProvider.lookup(Mockito.any())) - .thenReturn(ResultPage.of( - Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(Account.builder() - .id(1L) - .name("To account") - .type("checking") - .currency("EUR") - .build()) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder().id(2L).currency("EUR").type("debtor").name("From account").build()) - .amount(-20.00D) - .build() - )) - .build())); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/ProcessTaskResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/ProcessTaskResourceTest.java deleted file mode 100644 index 32a368f3..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/ProcessTaskResourceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import com.jongsoft.finance.rest.TestSetup; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import org.camunda.bpm.engine.HistoryService; -import org.camunda.bpm.engine.ProcessEngine; -import org.camunda.bpm.engine.RuntimeService; -import org.camunda.bpm.engine.TaskService; -import org.camunda.bpm.engine.task.TaskQuery; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.List; - -@DisplayName("Process task resource") -class ProcessTaskResourceTest extends TestSetup { - - private final TaskService taskService = Mockito.mock(TaskService.class); - - @Replaces - @MockBean - TaskService taskService() { - return taskService; - } - - @Replaces - @MockBean - ProcessEngine processEngine() { - var engine = Mockito.mock(ProcessEngine.class); - Mockito.when(engine.getTaskService()).thenReturn(taskService); - Mockito.when(engine.getHistoryService()).thenReturn(Mockito.mock(HistoryService.class)); - Mockito.when(engine.getRuntimeService()).thenReturn(Mockito.mock(RuntimeService.class)); - return engine; - } - - @Test - @DisplayName("should return tasks") - void tasks(RequestSpecification spec) { - var taskMock = Mockito.mock(TaskQuery.class); - - Mockito.when(taskService.createTaskQuery()).thenReturn(taskMock); - Mockito.when(taskMock.processInstanceId("1")).thenReturn(taskMock); - Mockito.when(taskMock.processDefinitionKey("reconcileAccount")).thenReturn(taskMock); - Mockito.when(taskMock.initializeFormKeys()).thenReturn(taskMock); - Mockito.when(taskMock.list()).thenReturn(List.of()); - - // @formatter:off - spec.when() - .get("/api/runtime-process/{task}/1/{id}/tasks", "reconcileAccount", "1") - .then() - .statusCode(200); - // @formatter:on - } - - @Test - @DisplayName("should complete task") - void complete(RequestSpecification spec) { - - // @formatter:off - spec.when() - .delete("/api/runtime-process/reconcileAccount/1/1/tasks/{task}", "L") - .then() - .statusCode(200); - // @formatter:on - - Mockito.verify(taskService).complete("L"); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/ProcessVariableResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/ProcessVariableResourceTest.java deleted file mode 100644 index 1680d808..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/ProcessVariableResourceTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import org.camunda.bpm.engine.ProcessEngine; -import org.camunda.bpm.engine.RuntimeService; -import org.camunda.bpm.engine.runtime.VariableInstanceQuery; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ProcessVariableResourceTest { - - private ProcessVariableResource subject; - - @Mock - private ProcessEngine processEngine; - - @Mock - private RuntimeService runtimeService; - - @Mock - private VariableInstanceQuery variableInstanceQuery; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - - when(processEngine.getRuntimeService()).thenReturn(runtimeService); - when(runtimeService.createVariableInstanceQuery()).thenReturn(variableInstanceQuery); - when(variableInstanceQuery.processInstanceIdIn(Mockito.anyString())).thenReturn(variableInstanceQuery); - when(variableInstanceQuery.variableName(Mockito.anyString())).thenReturn(variableInstanceQuery); - - subject = new ProcessVariableResource(runtimeService); - } - - @Test - void variables() { - subject.variables("procDefKey", "InstanceId"); - - verify(variableInstanceQuery).processInstanceIdIn("InstanceId"); - } - - @Test - void variable() { - subject.variable("procDefKey", "InstanceId", "variable"); - - verify(variableInstanceQuery).processInstanceIdIn("InstanceId"); - verify(variableInstanceQuery).variableName("variable"); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/RuntimeResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/RuntimeResourceTest.java deleted file mode 100644 index 7e8ef4f9..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/process/RuntimeResourceTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.jongsoft.finance.rest.process; - -import com.jongsoft.finance.security.AuthenticationFacade; -import org.assertj.core.api.Assertions; -import org.camunda.bpm.engine.HistoryService; -import org.camunda.bpm.engine.ProcessEngine; -import org.camunda.bpm.engine.RuntimeService; -import org.camunda.bpm.engine.runtime.ProcessInstance; -import org.camunda.bpm.engine.runtime.ProcessInstanceQuery; -import org.camunda.bpm.engine.runtime.ProcessInstantiationBuilder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import java.util.List; -import java.util.Map; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class RuntimeResourceTest { - - private RuntimeResource subject; - - @Mock - private ProcessEngine processEngine; - @Mock - private HistoryService historyService; - @Mock - private RuntimeService runtimeService; - @Mock - private AuthenticationFacade authenticationFacade; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - - when(processEngine.getRuntimeService()).thenReturn(runtimeService); - when(processEngine.getHistoryService()).thenReturn(historyService); - when(authenticationFacade.authenticated()).thenReturn("test-user"); - - subject = new RuntimeResource(historyService, runtimeService, authenticationFacade); - } - - @Test - void startProcess() { - var mockInstance = Mockito.mock(ProcessInstance.class); - var historyMock = Mockito.mock(ProcessInstance.class); - var instanceBuilder = Mockito.mock(ProcessInstantiationBuilder.class); - var historyBuilder = Mockito.mock(ProcessInstanceQuery.class); - - when(runtimeService.createProcessInstanceByKey("testProcess")).thenReturn(instanceBuilder); - when(instanceBuilder.execute()).thenReturn(mockInstance); - when(mockInstance.getProcessInstanceId()).thenReturn("MockProcessInstance"); - when(runtimeService.createProcessInstanceQuery()).thenReturn(historyBuilder); - when(historyBuilder.processInstanceId("MockProcessInstance")).thenReturn(historyBuilder); - when(historyBuilder.singleResult()).thenReturn(historyMock); - - when(historyMock.getId()).thenReturn("MockProcessInstance"); - when(historyMock.getBusinessKey()).thenReturn("sample-key"); - - Assertions.assertThat(subject.startProcess("testProcess", Map.of("businessKey", "sample-key"))) - .satisfies(instance -> { - Assertions.assertThat(instance.getId()).isEqualTo("MockProcessInstance"); - Assertions.assertThat(instance.getBusinessKey()).isEqualTo("sample-key"); - Assertions.assertThat(instance.getProcess()).isNull(); - Assertions.assertThat(instance.getState()).isEqualTo("ACTIVE"); - }); - - verify(runtimeService).createProcessInstanceByKey("testProcess"); - verify(instanceBuilder).businessKey("sample-key"); - } - - @Test - void history() { - final var instanceBuilder = Mockito.mock(ProcessInstanceQuery.class); - - when(runtimeService.createProcessInstanceQuery()).thenReturn(instanceBuilder); - when(instanceBuilder.processDefinitionKey("testProcess")).thenReturn(instanceBuilder); - when(instanceBuilder.variableValueEquals("username", "test-user")).thenReturn(instanceBuilder); - when(instanceBuilder.orderByProcessInstanceId()).thenReturn(instanceBuilder); - when(instanceBuilder.desc()).thenReturn(instanceBuilder); - when(instanceBuilder.list()).thenReturn(List.of()); - - subject.history("testProcess"); - - verify(runtimeService).createProcessInstanceQuery(); - verify(instanceBuilder).processDefinitionKey("testProcess"); - } - - @Test - void historyBusinessKey() { - final var instanceBuilder = Mockito.mock(ProcessInstanceQuery.class); - - when(runtimeService.createProcessInstanceQuery()).thenReturn(instanceBuilder); - when(instanceBuilder.processDefinitionKey("testProcess")).thenReturn(instanceBuilder); - when(instanceBuilder.processInstanceBusinessKey("key1")).thenReturn(instanceBuilder); - when(instanceBuilder.variableValueEquals("username", "test-user")).thenReturn(instanceBuilder); - when(instanceBuilder.orderByProcessInstanceId()).thenReturn(instanceBuilder); - when(instanceBuilder.desc()).thenReturn(instanceBuilder); - when(instanceBuilder.list()).thenReturn(List.of()); - - subject.history("testProcess","key1"); - - verify(runtimeService).createProcessInstanceQuery(); - verify(instanceBuilder).processDefinitionKey("testProcess"); - } - - @Test - void deleteProcess() { - subject.deleteProcess("procId", "BusKey", "InstanceId"); - - verify(runtimeService).deleteProcessInstance("InstanceId", "User termination"); - } - - @Test - void cleanHistory() { - subject.cleanHistory(); - - verify(historyService).cleanUpHistoryAsync(true); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/profile/ProfileExportResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/profile/ProfileExportResourceTest.java deleted file mode 100644 index 1d443ba8..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/profile/ProfileExportResourceTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.http.HttpHeaders; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -@DisplayName("Profile export resource") -class ProfileExportResourceTest extends TestSetup { - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Inject - private TransactionProvider transactionProvider; - - @Test - @DisplayName("should export profile") - void export(RequestSpecification spec) { - Mockito.when(transactionProvider.lookup(Mockito.any(TransactionProvider.FilterCommand.class))) - .thenReturn(ResultPage.of()); - - // @formatter:off - spec.when() - .get("/api/profile/export") - .then() - .statusCode(200) - .contentType("application/json") - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"test-user-profile.json\""); - // @formatter:on - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/profile/ProfileResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/profile/ProfileResourceTest.java deleted file mode 100644 index d241c1fb..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/profile/ProfileResourceTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.jongsoft.finance.rest.profile; - -import com.jongsoft.finance.domain.user.SessionToken; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Dates; -import dev.samstevens.totp.code.DefaultCodeGenerator; -import dev.samstevens.totp.exceptions.CodeGenerationException; -import dev.samstevens.totp.time.SystemTimeProvider; -import io.restassured.specification.RequestSpecification; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; - -import static org.mockito.Mockito.verify; - -@DisplayName("Profile resource") -class ProfileResourceTest extends TestSetup { - - @Test - @DisplayName("Get the current profile") - public void get(RequestSpecification spec) { - // @formatter:off - spec.when() - .get("/api/profile") - .then() - .statusCode(200) - .body("currency", Matchers.equalTo("EUR")) - .body("profilePicture", Matchers.nullValue()) - .body("theme", Matchers.equalTo("dark")) - .body("mfa", Matchers.equalTo(false)); - // @formatter:on - } - - @Test - @DisplayName("Patch the current profile") - public void patch(RequestSpecification spec) { - var request = new PatchProfileRequest("light", "USD", "updated-password"); - - // @formatter:off - spec.given() - .body(request) - .when() - .patch("/api/profile") - .then() - .statusCode(200) - .body("currency", Matchers.equalTo("USD")) - .body("profilePicture", Matchers.nullValue()) - .body("theme", Matchers.equalTo("light")) - .body("mfa", Matchers.equalTo(false)); - // @formatter:on - } - - @Test - @DisplayName("Get active sessions for the user") - public void sessions(RequestSpecification spec) { - Mockito.when(userProvider.tokens(ACTIVE_USER.getUsername())) - .thenReturn(Collections.List( - SessionToken.builder() - .id(1L) - .description("Sample session token") - .validity(Dates.range(LocalDateTime.now(), ChronoUnit.DAYS)) - .build())); - - // @formatter:off - spec.when() - .get("/api/profile/sessions") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(1)) - .body("[0].description", Matchers.equalTo("Sample session token")); - // @formatter:on - } - - @Test - @DisplayName("Create a new session token") - public void createSession(RequestSpecification spec) { - Mockito.when(userProvider.tokens(ACTIVE_USER.getUsername())) - .thenReturn(Collections.List( - SessionToken.builder() - .id(1L) - .description("Sample session token") - .validity(Dates.range(LocalDateTime.now(), ChronoUnit.DAYS)) - .build())); - - // @formatter:off - spec.given() - .body(new TokenCreateRequest("sample description", LocalDate.now().plusDays(1))) - .when() - .put("/api/profile/sessions") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(1)) - .body("[0].description", Matchers.equalTo("Sample session token")); - // @formatter:on - } - - @Test - @DisplayName("Revoke a session token") - public void revokeSession(RequestSpecification spec) { - var token = Mockito.spy(SessionToken.builder() - .id(1L) - .description("Sample session token") - .validity(Dates.range(LocalDateTime.now(), ChronoUnit.DAYS)) - .build()); - - Mockito.when(userProvider.tokens(ACTIVE_USER.getUsername())).thenReturn(Collections.List(token)); - - // @formatter:off - spec.when() - .delete("/api/profile/sessions/1") - .then() - .statusCode(204); - // @formatter:on - - verify(token).revoke(); - } - - @Test - @DisplayName("Enable MFA") - public void enableMfa(RequestSpecification spec) throws CodeGenerationException { - var generated = new DefaultCodeGenerator() - .generate(ACTIVE_USER.getSecret(), Math.floorDiv(new SystemTimeProvider().getTime(), 30)); - var request = new MultiFactorRequest(generated); - - // @formatter:off - spec.given() - .body(request) - .when() - .post("/api/profile/multi-factor/enable") - .then() - .statusCode(204); - // @formatter:on - } - -// @Test -// public void disableMfa() { -// subject.disableMfa(); -// -// var captor = ArgumentCaptor.forClass(UserAccountMultiFactorEvent.class); -// Mockito.verify(eventPublisher).publishEvent(captor.capture()); -// -// Assertions.assertThat(captor.getValue().getUsername()).isEqualTo(ACTIVE_USER.getUsername()); -// Assertions.assertThat(captor.getValue().isEnabled()).isEqualTo(false); -// } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionResourceTest.java deleted file mode 100644 index 527020d7..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/scheduler/ScheduledTransactionResourceTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.jongsoft.finance.rest.scheduler; - -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.transaction.ScheduleValue; -import com.jongsoft.finance.domain.transaction.ScheduledTransaction; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.TransactionScheduleProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.finance.schedule.Periodicity; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -@DisplayName("Scheduled transaction resource") -class ScheduledTransactionResourceTest extends TestSetup { - - @Inject - private AccountProvider accountProvider; - @Inject - private TransactionScheduleProvider transactionScheduleProvider; - private ScheduledTransaction scheduledTransaction; - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Replaces - @MockBean - TransactionScheduleProvider transactionScheduleProvider() { - return Mockito.mock(TransactionScheduleProvider.class); - } - - @BeforeEach - void setup() { - Mockito.reset(accountProvider, transactionScheduleProvider); - scheduledTransaction = Mockito.spy(ScheduledTransaction.builder() - .id(1L) - .name("Monthly gym membership") - .amount(22.66) - .schedule(new ScheduleValue(Periodicity.WEEKS, 4)) - .description("Gym membership") - .start(LocalDate.of(2019, 1, 1)) - .end(LocalDate.of(2021, 1, 1)) - .source(Account.builder() - .id(1L) - .type("checking") - .name("My account") - .currency("EUR") - .build()) - .destination(Account.builder().id(2L).type("creditor").currency("EUR").name("Gym").build()) - .build()); - - Mockito.when(accountProvider.lookup(Mockito.anyLong())).thenReturn(Control.Option()); - Mockito.when(transactionScheduleProvider.lookup()).thenReturn(Collections.List(scheduledTransaction)); - } - - @Test - @DisplayName("List all available transaction schedules") - void list(RequestSpecification spec) { - // @formatter:off - spec.when() - .get("/api/schedule/transaction") - .then() - .statusCode(200) - .body("$.size()", Matchers.equalTo(1)) - .body("[0].name", Matchers.equalTo("Monthly gym membership")) - .body("[0].description", Matchers.equalTo("Gym membership")) - .body("[0].range.start", Matchers.equalTo("2019-01-01")) - .body("[0].range.end", Matchers.equalTo("2021-01-01")); - // @formatter:on - } - - @Test - @DisplayName("Create a new transaction schedule") - void create(RequestSpecification spec) { - var destinationAccount = Account.builder().id(1L).build(); - var sourceAccount = Mockito.spy(Account.builder().id(2L).build()); - - Mockito.when(accountProvider.lookup(1L)).thenReturn(Control.Option(Account.builder().id(1L).build())); - Mockito.when(accountProvider.lookup(2L)).thenReturn(Control.Option(sourceAccount)); - Mockito.when(transactionScheduleProvider.lookup()) - .thenReturn(Collections.List(scheduledTransaction, ScheduledTransaction.builder() - .name("Sample schedule") - .build())); - Mockito.when(transactionScheduleProvider.lookup()) - .thenReturn(Collections.List( - scheduledTransaction, - ScheduledTransaction.builder() - .id(2L) - .amount(22.2) - .schedule(new ScheduleValue(Periodicity.WEEKS, 1)) - .destination(destinationAccount) - .source(sourceAccount) - .name("Sample schedule") - .build())); - - // @formatter:off - spec.given() - .body(""" - { - "amount": 22.2, - "name": "Sample schedule", - "schedule": { - "periodicity": "WEEKS", - "interval": 1 - }, - "destination": { - "id": 1 - }, - "source": { - "id": 2 - } - } - """) - .when() - .put("/api/schedule/transaction") - .then() - .statusCode(201) - .body("name", Matchers.equalTo("Sample schedule")); - // @formatter:on - Mockito.verify(sourceAccount).createSchedule( - "Sample schedule", - new ScheduleValue(Periodicity.WEEKS, 1), - destinationAccount, - 22.2); - } - - @Test - @DisplayName("Get a schedule by id") - void get(RequestSpecification spec) { - // @formatter:off - spec.when() - .get("/api/schedule/transaction/1") - .then() - .statusCode(200) - .body("name", Matchers.equalTo("Monthly gym membership")) - .body("description", Matchers.equalTo("Gym membership")) - .body("range.start", Matchers.equalTo("2019-01-01")) - .body("range.end", Matchers.equalTo("2021-01-01")); - // @formatter:on - } - - @Test - @DisplayName("Patch a schedule") - void patch(RequestSpecification spec) { - // @formatter:off - spec.given() - .body(""" - { - "description": "Updated description", - "name": "New name", - "range": { - "start": "2021-01-01", - "end": "2022-01-01" - } - } - """) - .when() - .patch("/api/schedule/transaction/1") - .then() - .statusCode(200) - .body("name", Matchers.equalTo("New name")) - .body("description", Matchers.equalTo("Updated description")) - .body("range.start", Matchers.equalTo("2021-01-01")) - .body("range.end", Matchers.equalTo("2022-01-01")); - // @formatter:on - - Mockito.verify(scheduledTransaction).describe("New name", "Updated description"); - Mockito.verify(scheduledTransaction).limit(LocalDate.of(2021, 1, 1), LocalDate.of(2022, 1, 1)); - } - - @Test - @DisplayName("Remove a schedule by id") - void remove(RequestSpecification spec) { - // @formatter:off - spec.when() - .delete("/api/schedule/transaction/1") - .then() - .statusCode(204); - // @formatter:on - - Mockito.verify(scheduledTransaction).terminate(); - } - - @Test - @DisplayName("Remove a schedule by id - not found") - void remove_notFound(RequestSpecification spec) { - // @formatter:off - spec.when() - .delete("/api/schedule/transaction/2") - .then() - .statusCode(404) - .body("message", Matchers.equalTo("No scheduled transaction found with id 2")); - // @formatter:on - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/security/PasswordEncoderTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/security/PasswordEncoderTest.java deleted file mode 100644 index 71d7e094..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/security/PasswordEncoderTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.jongsoft.finance.rest.security; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import com.jongsoft.finance.security.PasswordEncoder; - -class PasswordEncoderTest { - - @Test - void matches() { - var encoder = new PasswordEncoder(); - - var encoded = encoder.encrypt("MySamplePasswordIsLong"); - - Assertions.assertTrue(encoder.matches(encoded, "MySamplePasswordIsLong")); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/setting/CurrencyResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/setting/CurrencyResourceTest.java deleted file mode 100644 index 0aa2eefb..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/setting/CurrencyResourceTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import com.jongsoft.finance.domain.core.Currency; -import com.jongsoft.finance.providers.CurrencyProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Map; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("Currency resource") -class CurrencyResourceTest extends TestSetup { - - @Inject - private CurrencyProvider currencyProvider; - - @Replaces - @MockBean - private CurrencyProvider currencyProvider() { - return Mockito.mock(CurrencyProvider.class); - } - - @Test - @DisplayName("List the available currencies") - void available(RequestSpecification spec) { - when(currencyProvider.lookup()).thenReturn( - Collections.List( - Currency.builder() - .id(1L) - .name("Euro") - .code("EUR") - .symbol('E') - .build(), - Currency.builder() - .id(2L) - .name("Dollar") - .code("USD") - .symbol('D') - .build(), - Currency.builder() - .id(3L) - .name("Kwatsch") - .code("KWS") - .symbol('K') - .build() - )); - - // @formatter:off - spec - .when() - .get("/api/settings/currencies") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(3)) - .body("code", Matchers.hasItems("EUR", "USD", "KWS")) - .body("name", Matchers.hasItems("Euro", "Dollar", "Kwatsch")); - // @formatter:on - } - - @Test - @DisplayName("Create a new currency") - void create(RequestSpecification spec) { - when(currencyProvider.lookup("TCC")).thenReturn(Control.Option()); - - // @formatter:off - spec - .given() - .contentType("application/json") - .body(Map.of( - "code", "TCC", - "symbol", "S", - "name", "Test currency")) - .when() - .put("/api/settings/currencies") - .then() - .statusCode(201) - .body("code", Matchers.equalTo("TCC")) - .body("name", Matchers.equalTo("Test currency")) - .body("symbol", Matchers.equalTo("S")); - // @formatter:on - } - - @Test - @DisplayName("Get a currency") - void get(RequestSpecification spec) { - var currency = Mockito.spy(Currency.builder() - .id(1L) - .name("Euro") - .code("EUR") - .build()); - - when(currencyProvider.lookup("EUR")).thenReturn(Control.Option(currency)); - - // @formatter:off - spec - .when() - .get("/api/settings/currencies/EUR") - .then() - .statusCode(200) - .body("code", Matchers.equalTo("EUR")) - .body("name", Matchers.equalTo("Euro")); - // @formatter:on - } - - @Test - @DisplayName("Update a currency") - void update(RequestSpecification spec) { - var currency = Mockito.spy(Currency.builder() - .id(1L) - .name("Euro") - .code("EUR") - .build()); - - when(currencyProvider.lookup("EUR")).thenReturn(Control.Option(currency)); - - // @formatter:off - spec - .given() - .contentType("application/json") - .body(Map.of( - "code", "TCC", - "symbol", "S", - "name", "Test currency")) - .when() - .post("/api/settings/currencies/EUR") - .then() - .statusCode(200) - .body("code", Matchers.equalTo("TCC")) - .body("name", Matchers.equalTo("Test currency")) - .body("symbol", Matchers.equalTo("S")); - // @formatter:on - - verify(currency).rename("Test currency", "TCC", 'S'); - } - - @Test - @DisplayName("Patch a currency") - void patch(RequestSpecification spec) { - var currency = Mockito.spy(Currency.builder() - .id(1L) - .enabled(true) - .decimalPlaces(3) - .name("Euro") - .code("EUR") - .build()); - - when(currencyProvider.lookup("EUR")).thenReturn(Control.Option(currency)); - - // @formatter:off - spec - .given() - .contentType("application/json") - .body(Map.of( - "enabled", false, - "decimalPlaces", 2)) - .when() - .patch("/api/settings/currencies/EUR") - .then() - .statusCode(200) - .body("code", Matchers.equalTo("EUR")) - .body("name", Matchers.equalTo("Euro")); - // @formatter:on - - verify(currency).disable(); - verify(currency).accuracy(2); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/setting/SettingResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/setting/SettingResourceTest.java deleted file mode 100644 index b12c7d00..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/setting/SettingResourceTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.jongsoft.finance.rest.setting; - -import com.jongsoft.finance.core.SettingType; -import com.jongsoft.finance.domain.core.Setting; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -@DisplayName("Setting resource") -class SettingResourceTest extends TestSetup { - - @Inject - private SettingProvider settingProvider; - - @Replaces - @MockBean - SettingProvider settingProvider() { - return Mockito.mock(SettingProvider.class); - } - - @Test - @DisplayName("Should return setting by name") - void list(RequestSpecification spec) { - Mockito.when(settingProvider.lookup()).thenReturn(Collections.List( - Setting.builder() - .name("RecordSetPageSize") - .type(SettingType.NUMBER) - .value("20") - .build(), - Setting.builder() - .name("AutocompleteLimit") - .type(SettingType.NUMBER) - .value("5") - .build() - )); - - // @formatter:off - spec - .when() - .get("/api/settings") - .then() - .statusCode(200) - .body("name", Matchers.hasItems("RecordSetPageSize", "AutocompleteLimit")); - // @formatter:on - } - - @Test - @DisplayName("Update setting by name") - void update(RequestSpecification spec) { - var setting = Mockito.spy(Setting.builder() - .name("RecordSetPageSize") - .value("20") - .type(SettingType.NUMBER) - .build()); - - Mockito.when(settingProvider.lookup("RecordSetPageSize")).thenReturn( - Control.Option(setting)); - - // @formatter:off - spec - .given() - .body(new SettingUpdateRequest("30")) - .when() - .post("/api/settings/RecordSetPageSize") - .then() - .statusCode(200); - // @formatter:on - - Mockito.verify(setting).update("30"); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/statistic/BalanceResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/statistic/BalanceResourceTest.java deleted file mode 100644 index fc9cb7ca..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/statistic/BalanceResourceTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import com.jongsoft.finance.core.DateUtils; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; - -@DisplayName("Statistic: Balance") -class BalanceResourceTest extends TestSetup { - - private BalanceResource subject; - - @Inject - private TransactionProvider transactionProvider; - @Inject - private AccountProvider accountProvider; - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @BeforeEach - void setup() { - Mockito.when(transactionProvider.balance(Mockito.any())).thenReturn(Control.Option()); - Mockito.when(transactionProvider.daily(Mockito.any())).thenReturn(Collections.List()); - Mockito.when(transactionProvider.monthly(Mockito.any())).thenReturn(Collections.List()); - } - - @Test - @DisplayName("Calculate balance") - void calculate(RequestSpecification spec) { - // @formatter:off - spec - .given() - .body(""" - { - "onlyIncome": false, - "dateRange": { - "start": "2019-01-01", - "end": "2019-02-01" - } - }""") - .when() - .post("/api/statistics/balance") - .then() - .statusCode(200) - .body("balance", Matchers.equalTo(0.0F)); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - Mockito.verify(transactionProvider).balance(Mockito.any()); - Mockito.verify(mockFilter).onlyIncome(false); - Mockito.verify(mockFilter).range(DateUtils.forMonth(2019, 1)); - } - - @Test - @DisplayName("Calculate daily balance") - void daily(RequestSpecification spec) { - // @formatter:off - spec - .given() - .body(""" - { - "onlyIncome": false, - "dateRange": { - "start": "2019-01-01", - "end": "2019-02-01" - } - }""") - .when() - .post("/api/statistics/balance/daily") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(0)); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - Mockito.verify(transactionProvider).daily(Mockito.any()); - Mockito.verify(mockFilter).onlyIncome(false); - Mockito.verify(mockFilter).range(DateUtils.forMonth(2019, 1)); - } - - @Test - @DisplayName("Calculate monthly balance") - void monthly(RequestSpecification spec) { - // @formatter:off - spec - .given() - .body(""" - { - "onlyIncome": false, - "dateRange": { - "start": "2019-01-01", - "end": "2019-02-01" - } - }""") - .when() - .post("/api/statistics/balance/monthly") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(0)); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - Mockito.verify(transactionProvider).monthly(Mockito.any()); - Mockito.verify(mockFilter).onlyIncome(false); - Mockito.verify(mockFilter).range(DateUtils.forMonth(2019, 1)); - } - - @Test - @DisplayName("Calculate partitioned balance") - void calculatePartitioned(RequestSpecification spec) { - Mockito.when(accountProvider.lookup()).thenReturn(Collections.List()); - - // @formatter:off - spec - .given() - .body(""" - { - "onlyIncome": false, - "dateRange": { - "start": "2019-01-01", - "end": "2019-02-01" - } - }""") - .when() - .post("/api/statistics/balance/partitioned/{partitionKey}", "account") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(1)) - .body("[0].balance", Matchers.equalTo(0.0f)); - // @formatter:on - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/statistic/SpendingInsightResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/statistic/SpendingInsightResourceTest.java deleted file mode 100644 index 300c1aed..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/statistic/SpendingInsightResourceTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.jongsoft.finance.rest.statistic; - -import com.jongsoft.finance.domain.insight.*; -import com.jongsoft.finance.providers.SpendingInsightProvider; -import com.jongsoft.finance.providers.SpendingPatternProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.LocalDate; -import java.time.YearMonth; -import java.util.HashMap; -import java.util.Map; - -@DisplayName("Statistic: Spending Insights and Patterns") -class SpendingInsightResourceTest extends TestSetup { - - @Inject - private SpendingInsightProvider spendingInsightProvider; - - @Inject - private SpendingPatternProvider spendingPatternProvider; - - @Replaces - @MockBean - SpendingInsightProvider spendingInsightProvider() { - return Mockito.mock(SpendingInsightProvider.class); - } - - @Replaces - @MockBean - SpendingPatternProvider spendingPatternProvider() { - return Mockito.mock(SpendingPatternProvider.class); - } - - @BeforeEach - void setup() { - Mockito.when(spendingInsightProvider.lookup(Mockito.any(YearMonth.class))) - .thenReturn(Collections.List()); - Mockito.when(spendingPatternProvider.lookup(Mockito.any(YearMonth.class))) - .thenReturn(Collections.List()); - } - - @Test - @DisplayName("Get spending insights") - void getInsights(RequestSpecification spec) { - // Create a mock insight - Map metadata = new HashMap<>(); - metadata.put("testKey", "testValue"); - - SpendingInsight mockInsight = SpendingInsight.builder() - .type(InsightType.UNUSUAL_AMOUNT) - .category("Groceries") - .severity(Severity.WARNING) - .score(0.85) - .transactionId(123L) - .detectedDate(LocalDate.of(2023, 1, 15)) - .message("Unusual spending amount detected") - .metadata(metadata) - .build(); - - // Setup the mock to return our test insight - Mockito.when(spendingInsightProvider.lookup(YearMonth.of(2023, 1))) - .thenReturn(Collections.List(mockInsight)); - - // @formatter:off - spec - .given() - .when() - .get("/api/statistics/spending/insights?year=2023&month=1") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(1)) - .body("[0].type", Matchers.equalTo("UNUSUAL_AMOUNT")) - .body("[0].category", Matchers.equalTo("Groceries")) - .body("[0].severity", Matchers.equalTo("WARNING")) - .body("[0].score", Matchers.equalTo(0.85f)) - .body("[0].transactionId", Matchers.equalTo(123)) - .body("[0].message", Matchers.equalTo("Unusual spending amount detected")); - // @formatter:on - - Mockito.verify(spendingInsightProvider).lookup(YearMonth.of(2023, 1)); - } - - @Test - @DisplayName("Get spending patterns") - void getPatterns(RequestSpecification spec) { - // Create a mock pattern - Map metadata = new HashMap<>(); - metadata.put("testKey", "testValue"); - - SpendingPattern mockPattern = SpendingPattern.builder() - .type(PatternType.RECURRING_MONTHLY) - .category("Utilities") - .confidence(0.95) - .detectedDate(LocalDate.of(2023, 1, 15)) - .metadata(metadata) - .build(); - - // Setup the mock to return our test pattern - Mockito.when(spendingPatternProvider.lookup(YearMonth.of(2023, 1))) - .thenReturn(Collections.List(mockPattern)); - - // @formatter:off - spec - .given() - .when() - .get("/api/statistics/spending/patterns?year=2023&month=1") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(1)) - .body("[0].type", Matchers.equalTo("RECURRING_MONTHLY")) - .body("[0].category", Matchers.equalTo("Utilities")) - .body("[0].confidence", Matchers.equalTo(0.95f)) - .body("[0].detectedDate", Matchers.equalTo("2023-01-15")); - // @formatter:on - - Mockito.verify(spendingPatternProvider).lookup(YearMonth.of(2023, 1)); - } - - @Test - @DisplayName("Get empty insights") - void getEmptyInsights(RequestSpecification spec) { - // @formatter:off - spec - .given() - .when() - .get("/api/statistics/spending/insights?year=2023&month=2") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(0)); - // @formatter:on - - Mockito.verify(spendingInsightProvider).lookup(YearMonth.of(2023, 2)); - } - - @Test - @DisplayName("Get empty patterns") - void getEmptyPatterns(RequestSpecification spec) { - // @formatter:off - spec - .given() - .when() - .get("/api/statistics/spending/patterns?year=2023&month=2") - .then() - .statusCode(200) - .body("size()", Matchers.equalTo(0)); - // @formatter:on - - Mockito.verify(spendingPatternProvider).lookup(YearMonth.of(2023, 2)); - } -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionResourceTest.java deleted file mode 100644 index f374844b..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionResourceTest.java +++ /dev/null @@ -1,262 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.core.DateUtils; -import com.jongsoft.finance.domain.account.Account; -import com.jongsoft.finance.domain.transaction.Transaction; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.AccountTypeProvider; -import com.jongsoft.finance.providers.TransactionProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.finance.rest.process.RuntimeResource; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.io.IOException; -import java.time.LocalDate; -import java.util.Map; - -@DisplayName("Transaction resource") -class TransactionResourceTest extends TestSetup { - - @Inject - private TransactionProvider transactionProvider; - @Inject - private AccountProvider accountProvider; - @Inject - private AccountTypeProvider accountTypeProvider; - - @Replaces - @MockBean - TransactionProvider transactionProvider() { - return Mockito.mock(TransactionProvider.class); - } - - @Replaces - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Replaces - @MockBean - AccountTypeProvider accountTypeProvider() { - return Mockito.mock(AccountTypeProvider.class); - } - - @Replaces - @MockBean - RuntimeResource runtimeResource() { - return Mockito.mock(RuntimeResource.class); - } - - @Test - @DisplayName("should return the search results") - void search(RequestSpecification spec) { - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .currency("EUR") - .build(); - - Mockito.when(transactionProvider.lookup(Mockito.any())) - .thenReturn(ResultPage.of( - Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder() - .id(2L) - .currency("EUR") - .type("debtor") - .name("From account") - .build()) - .amount(-20.00D) - .build() - )) - .build() - )); - - // @formatter:off - spec.given() - .body(""" - { - "page": 0, - "dateRange": { - "start": "2019-01-01", - "end": "2019-02-01" - }, - "onlyIncome": false, - "onlyExpense": false, - "description": "samp" - }""") - .when() - .post("/api/transactions") - .then() - .statusCode(200) - .body("info.records", Matchers.equalTo(1)) - .body("content[0].id", Matchers.equalTo(1)) - .body("content[0].description", Matchers.equalTo("Sample transaction")) - .body("content[0].currency", Matchers.equalTo("EUR")) - .body("content[0].metadata.category", Matchers.equalTo("Grocery")) - .body("content[0].metadata.budget", Matchers.equalTo("Household")) - .body("content[0].dates.transaction", Matchers.equalTo("2019-01-15")); - // @formatter:on - - var mockFilter = filterFactory.transaction(); - Mockito.verify(transactionProvider).lookup(Mockito.any()); - Mockito.verify(mockFilter).description("samp", false); - Mockito.verify(mockFilter).ownAccounts(); - Mockito.verify(mockFilter).range(DateUtils.forMonth(2019, 1)); - } - - @Test - @DisplayName("should update part of the transactions") - void patch(RequestSpecification spec) { - var transaction = Mockito.spy(Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(Account.builder() - .id(1L) - .name("To account") - .type("checking") - .currency("EUR") - .build()) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder().id(2L).currency("EUR").type("debtor").name("From account").build()) - .amount(-20.00D) - .build() - )) - .build()); - - Mockito.when(transactionProvider.lookup(Mockito.anyLong())).thenReturn(Control.Option()); - Mockito.when(transactionProvider.lookup(1L)).thenReturn(Control.Option(transaction)); - - // @formatter:off - spec.given() - .body(""" - { - "transactions": [1, 2], - "budget": { - "id": -1, - "name": "Groceries" - }, - "category": { - "id": -1, - "name": "Category" - }, - "contract": { - "id": -1, - "name": "Wallmart" - }, - "tags": ["sample"] - }""") - .when() - .patch("/api/transactions") - .then() - .statusCode(204); - // @formatter:on - - Mockito.verify(transaction).linkToCategory("Category"); - Mockito.verify(transaction).linkToBudget("Groceries"); - Mockito.verify(transaction).linkToContract("Wallmart"); - Mockito.verify(transaction).tag(Collections.List("sample")); - } - - @Test - @DisplayName("should return the date of the first transaction") - void firstTransaction(RequestSpecification spec) { - Mockito.when(transactionProvider.first(Mockito.any())).thenReturn(Control.Option( - Transaction.builder() - .date(LocalDate.of(2019, 1, 1)) - .build())); - - // @formatter:off - spec.given() - .body(Map.of("description", "test")) - .when() - .post("/api/transactions/locate-first") - .then() - .statusCode(200) - .body(Matchers.containsString("2019-01-01")); - // @formatter:on - } - - @Test - @DisplayName("should export all known transactions") - void export(RequestSpecification spec) throws IOException { - Account account = Account.builder() - .id(1L) - .name("To account") - .type("checking") - .currency("EUR") - .build(); - - Mockito.when(accountTypeProvider.lookup(false)).thenReturn(Collections.List()); - Mockito.when(accountProvider.lookup(Mockito.any(AccountProvider.FilterCommand.class))) - .thenReturn(ResultPage.empty()); - Mockito.when(transactionProvider.lookup(Mockito.any())) - .thenReturn(ResultPage.of( - Transaction.builder() - .id(1L) - .description("Sample transaction") - .category("Grocery") - .currency("EUR") - .budget("Household") - .date(LocalDate.of(2019, 1, 15)) - .transactions(Collections.List( - Transaction.Part.builder() - .id(1L) - .account(account) - .amount(20.00D) - .build(), - Transaction.Part.builder() - .id(2L) - .account(Account.builder().id(2L).currency("EUR").type("debtor").name("From account").build()) - .amount(-20.00D) - .build() - )) - .build() - )) - .thenReturn(ResultPage.empty()); - - // @formatter:off - spec.when() - .get("/api/transactions/export") - .then() - .statusCode(200) - .body(Matchers.containsString("Sample transaction")); - // @formatter:on - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionRuleResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionRuleResourceTest.java deleted file mode 100644 index bff86cfa..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionRuleResourceTest.java +++ /dev/null @@ -1,308 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.core.RuleOperation; -import com.jongsoft.finance.domain.transaction.TransactionRule; -import com.jongsoft.finance.domain.transaction.TransactionRuleGroup; -import com.jongsoft.finance.messaging.EventBus; -import com.jongsoft.finance.messaging.commands.rule.CreateRuleGroupCommand; -import com.jongsoft.finance.providers.TransactionRuleGroupProvider; -import com.jongsoft.finance.providers.TransactionRuleProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.finance.security.CurrentUserProvider; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.context.event.ApplicationEventPublisher; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class TransactionRuleResourceTest extends TestSetup { - - private TransactionRuleResource subject; - - @Mock - private TransactionRuleGroupProvider ruleGroupProvider; - @Mock - private TransactionRuleProvider ruleProvider; - @Mock - private CurrentUserProvider currentUserProvider; - @Mock - private ApplicationEventPublisher eventPublisher; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - - subject = new TransactionRuleResource( - ruleGroupProvider, - ruleProvider, - currentUserProvider); - - Mockito.when(currentUserProvider.currentUser()).thenReturn(ACTIVE_USER); - - new EventBus(eventPublisher); - } - - @Test - void groups() { - Mockito.when(ruleGroupProvider.lookup()).thenReturn(Collections.List( - TransactionRuleGroup.builder() - .id(1L) - .name("Grocery stores") - .sort(1) - .build(), - TransactionRuleGroup.builder() - .id(2L) - .name("Savings transactions") - .sort(2) - .build())); - - Assertions.assertThat(subject.groups()) - .isNotNull() - .hasSize(2) - .extracting("name") - .containsExactly("Grocery stores", "Savings transactions"); - } - - @Test - void createGroup() { - Mockito.when(ruleGroupProvider.lookup("Group setting")).thenReturn(Control.Option()); - - subject.createGroup(new GroupRenameRequest("Group setting")); - - Mockito.verify(eventPublisher).publishEvent(Mockito.any(CreateRuleGroupCommand.class)); - } - - @Test - void rules() { - final TransactionRule transactionRule = TransactionRule.builder() - .id(1L) - .name("Grocery Store 1") - .active(true) - .restrictive(false) - .group("Grocery") - .conditions(Collections.List()) - .changes(Collections.List()) - .user(ACTIVE_USER) - .build(); - transactionRule.new Condition(1L, RuleColumn.TO_ACCOUNT, RuleOperation.CONTAINS, "Store"); - transactionRule.new Condition(2L, RuleColumn.AMOUNT, RuleOperation.LESS_THAN, "100.00"); - transactionRule.new Change(1L, RuleColumn.TO_ACCOUNT, "2"); - transactionRule.new Change(2L, RuleColumn.CATEGORY, "3"); - - Mockito.when(ruleProvider.lookup("Grocery")).thenReturn(Collections.List(transactionRule)); - - Assertions.assertThat(subject.rules("Grocery")) - .isNotNull() - .hasSize(1) - .extracting("name") - .containsExactly("Grocery Store 1"); - } - - @Test - void groupUp() { - var ruleGroup = Mockito.spy(TransactionRuleGroup.builder() - .id(1L) - .name("Grocery stores") - .sort(1) - .build()); - - Mockito.when(ruleGroupProvider.lookup("Grocery stores")).thenReturn(Control.Option(ruleGroup)); - - subject.groupUp("Grocery stores"); - - Mockito.verify(ruleGroup).changeOrder(0); - } - - @Test - void groupDown() { - var ruleGroup = Mockito.spy(TransactionRuleGroup.builder() - .id(1L) - .name("Grocery stores") - .sort(1) - .build()); - - Mockito.when(ruleGroupProvider.lookup("Grocery stores")).thenReturn(Control.Option(ruleGroup)); - - subject.groupDown("Grocery stores"); - - Mockito.verify(ruleGroup).changeOrder(2); - } - - @Test - void rename() { - var ruleGroup = Mockito.spy(TransactionRuleGroup.builder() - .id(1L) - .name("Grocery stores") - .sort(1) - .build()); - - Mockito.when(ruleGroupProvider.lookup("Grocery stores")).thenReturn(Control.Option(ruleGroup)); - - subject.rename("Grocery stores", new GroupRenameRequest("updated")); - - Mockito.verify(ruleGroup).rename("updated"); - } - -// @Test -// void create() { -// var request = CreateRuleRequest.builder() -// .name("Grocery Matcher") -// .description("My sample rule") -// .restrictive(true) -// .active(true) -// .conditions(List.of( -// new CreateRuleRequest.Condition( -// null, -// RuleColumn.TO_ACCOUNT, -// RuleOperation.EQUALS, -// "sample account"))) -// .changes(List.of( -// new CreateRuleRequest.Change( -// null, -// RuleColumn.TO_ACCOUNT, -// "2"))) -// .build(); -// -// subject.create("Group 1", request); -// -// Mockito.verify(ruleProvider).save(Mockito.any()); -// } - - @Test - void getRule() { - final TransactionRule transactionRule = TransactionRule.builder() - .id(1L) - .name("Grocery Store 1") - .active(true) - .restrictive(false) - .group("Grocery") - .conditions(Collections.List()) - .changes(Collections.List()) - .user(ACTIVE_USER) - .build(); - transactionRule.new Condition(1L, RuleColumn.TO_ACCOUNT, RuleOperation.CONTAINS, "Store"); - transactionRule.new Condition(2L, RuleColumn.AMOUNT, RuleOperation.LESS_THAN, "100.00"); - transactionRule.new Change(1L, RuleColumn.TO_ACCOUNT, "2"); - transactionRule.new Change(2L, RuleColumn.CATEGORY, "3"); - - Mockito.when(ruleProvider.lookup(1L)).thenReturn(Control.Option(transactionRule)); - - Assertions.assertThat(subject.getRule("Grocery", 1L)) - .hasFieldOrPropertyWithValue("id", 1L) - .hasFieldOrPropertyWithValue("name", "Grocery Store 1") - .hasFieldOrPropertyWithValue("active", true) - .hasFieldOrPropertyWithValue("restrictive", false) - .satisfies(rule -> assertThat(rule.getConditions()) - .isNotNull() - .hasSize(2) - .extracting("id") - .containsExactly(1L, 2L)); - } - - @Test - void ruleUp() { - final TransactionRule transactionRule = Mockito.spy(TransactionRule.builder() - .id(1L) - .user(ACTIVE_USER) - .sort(1) - .build()); - - Mockito.when(ruleProvider.lookup(1L)).thenReturn(Control.Option(transactionRule)); - - subject.ruleUp("Group", 1L); - Mockito.verify(transactionRule).changeOrder(0); - } - - @Test - void ruleDown() { - final TransactionRule transactionRule = Mockito.spy(TransactionRule.builder() - .id(1L) - .user(ACTIVE_USER) - .sort(1) - .build()); - - Mockito.when(ruleProvider.lookup(1L)).thenReturn(Control.Option(transactionRule)); - - subject.ruleDown("Group", 1L); - Mockito.verify(transactionRule).changeOrder(2); - } - -// @Test -// void updateRule() { -// final TransactionRule transactionRule = Mockito.spy(TransactionRule.builder() -// .id(1L) -// .name("Grocery Store 1") -// .active(true) -// .restrictive(false) -// .group("Grocery") -// .conditions(Collections.List()) -// .changes(Collections.List()) -// .user(ACTIVE_USER) -// .build()); -// transactionRule.new Condition(1L, RuleColumn.TO_ACCOUNT, RuleOperation.CONTAINS, "Store"); -// transactionRule.new Condition(2L, RuleColumn.AMOUNT, RuleOperation.LESS_THAN, "100.00"); -// transactionRule.new Change(1L, RuleColumn.TO_ACCOUNT, "2"); -// transactionRule.new Change(2L, RuleColumn.CATEGORY, "3"); -// -// var request = CreateRuleRequest.builder() -// .name("Grocery Matcher") -// .description("My sample rule") -// .restrictive(true) -// .active(true) -// .conditions(List.of( -// new CreateRuleRequest.Condition( -// null, -// RuleColumn.TO_ACCOUNT, -// RuleOperation.EQUALS, -// "sample account"))) -// .changes(List.of( -// new CreateRuleRequest.Change( -// null, -// RuleColumn.TO_ACCOUNT, -// "2"))) -// .build(); -// -// Mockito.when(ruleProvider.lookup(1L)).thenReturn(Control.Option(transactionRule)); -// -// Assertions.assertThat(subject.updateRule("Group 1", 1L, request)) -// .isNotNull() -// .hasFieldOrPropertyWithValue("id", 1L) -// .hasFieldOrPropertyWithValue("name", "Grocery Matcher") -// .hasFieldOrPropertyWithValue("active", true) -// .hasFieldOrPropertyWithValue("restrictive", true); -// -// Mockito.verify(transactionRule).change("Grocery Matcher", "My sample rule", true, true); -// Mockito.verify(ruleProvider).save(transactionRule); -// } - - @Test - void deleteRule() { - final TransactionRule transactionRule = TransactionRule.builder() - .id(1L) - .name("Grocery Store 1") - .user(ACTIVE_USER) - .active(true) - .restrictive(false) - .group("Grocery") - .conditions(Collections.List()) - .changes(Collections.List()) - .build(); - - Mockito.when(ruleProvider.lookup(1L)).thenReturn(Control.Option(transactionRule)); - - subject.deleteRule("Group", 1L); - - Mockito.verify(ruleProvider).save(transactionRule); - assertThat(transactionRule.isDeleted()).isTrue(); - } - -} diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionSuggestionResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionSuggestionResourceTest.java deleted file mode 100644 index 6277c5db..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionSuggestionResourceTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import com.jongsoft.finance.core.RuleColumn; -import com.jongsoft.finance.core.RuleOperation; -import com.jongsoft.finance.domain.transaction.TransactionRule; -import com.jongsoft.finance.domain.user.Category; -import com.jongsoft.finance.providers.AccountProvider; -import com.jongsoft.finance.providers.CategoryProvider; -import com.jongsoft.finance.providers.TransactionRuleProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import static org.mockito.Mockito.when; - -class TransactionSuggestionResourceTest extends TestSetup { - - @Inject - private TransactionRuleProvider ruleProvider; - @Inject - private CategoryProvider categoryProvider; - - @MockBean - TransactionRuleProvider ruleProvider() { - return Mockito.mock(TransactionRuleProvider.class); - } - - @MockBean - CategoryProvider categoryProvider() { - var mock = Mockito.mock(CategoryProvider.class); - when(mock.supports(Category.class)).thenReturn(true); - return mock; - } - - @MockBean - AccountProvider accountProvider() { - return Mockito.mock(AccountProvider.class); - } - - @Test - void suggest(RequestSpecification requestSpecification) { - final TransactionRule transactionRule = TransactionRule.builder() - .id(1L) - .name("Grocery Store 1") - .active(true) - .restrictive(false) - .group("Grocery") - .conditions(Collections.List()) - .changes(Collections.List()) - .user(ACTIVE_USER) - .build(); - transactionRule.new Condition(1L, RuleColumn.TO_ACCOUNT, RuleOperation.CONTAINS, "Store"); - transactionRule.new Condition(2L, RuleColumn.AMOUNT, RuleOperation.LESS_THAN, "100.00"); - transactionRule.new Change(1L, RuleColumn.CATEGORY, "2"); - - when(categoryProvider.lookup(2)).thenReturn(Control.Option(Category.builder().label("Shopping").id(1L).build())); - when(ruleProvider.lookup()).thenReturn(Collections.List(transactionRule)); - - requestSpecification.given() - .body(""" - { - "amount": 82.10, - "destination": "Walmart Store" - }""") - .when() - .post("/api/transactions/suggestions") - .then() - .statusCode(200) - .body("CATEGORY", Matchers.equalTo("Shopping")); - } -} \ No newline at end of file diff --git a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionTagResourceTest.java b/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionTagResourceTest.java deleted file mode 100644 index d54022cc..00000000 --- a/fintrack-api/src/test/java/com/jongsoft/finance/rest/transaction/TransactionTagResourceTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.jongsoft.finance.rest.transaction; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.finance.domain.transaction.Tag; -import com.jongsoft.finance.providers.SettingProvider; -import com.jongsoft.finance.providers.TagProvider; -import com.jongsoft.finance.rest.TestSetup; -import com.jongsoft.lang.Collections; -import io.micronaut.context.annotation.Replaces; -import io.micronaut.test.annotation.MockBean; -import io.restassured.specification.RequestSpecification; -import jakarta.inject.Inject; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -class TransactionTagResourceTest extends TestSetup { - - @Inject - private TagProvider tagProvider; - - @Replaces - @MockBean - TagProvider mockTagProvider() { - return Mockito.mock(TagProvider.class); - } - - @Replaces - @MockBean - SettingProvider mockSettingProvider() { - return Mockito.mock(SettingProvider.class); - } - - @Test - void create(RequestSpecification spec) { - // @formatter:off - spec - .given() - .body(new TagCreateRequest("Sample tag")) - .when() - .post("/api/transactions/tags") - .then() - .statusCode(200) - .body("name", Matchers.equalTo("Sample tag")); - // @formatter:on - } - - @Test - @DisplayName("List available tags") - void list(RequestSpecification spec) { - Mockito.when(tagProvider.lookup()) - .thenReturn(Collections.List( - new Tag("Sample"), - new Tag("Description"))); - - // @formatter:off - spec - .when() - .get("/api/transactions/tags") - .then() - .statusCode(200) - .body("name", Matchers.hasItems("Sample", "Description")); - // @formatter:on - } - - @Test - @DisplayName("should return a tag on autocomplete") - void autoCompleteTag(RequestSpecification spec) { - Mockito.when(tagProvider.lookup(Mockito.any(TagProvider.FilterCommand.class))).thenReturn( - ResultPage.of(new Tag("Sample"))); - - // @formatter:off - spec - .given() - .queryParam("token", "samp") - .when() - .get("/api/transactions/tags/auto-complete") - .then() - .statusCode(200) - .body("name", Matchers.hasItems("Sample")); - // @formatter:on - - var mockFilter = filterFactory.tag(); - Mockito.verify(tagProvider).lookup(Mockito.any(TagProvider.FilterCommand.class)); - Mockito.verify(mockFilter).name("samp", false); - } -} diff --git a/fintrack-api/src/test/java/org/mockito/configuration/MockitoConfiguration.java b/fintrack-api/src/test/java/org/mockito/configuration/MockitoConfiguration.java deleted file mode 100644 index d8d25e0e..00000000 --- a/fintrack-api/src/test/java/org/mockito/configuration/MockitoConfiguration.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.mockito.configuration; - -import com.jongsoft.finance.ResultPage; -import com.jongsoft.lang.Collections; -import com.jongsoft.lang.Control; -import com.jongsoft.lang.collection.Sequence; -import com.jongsoft.lang.control.Optional; -import org.mockito.internal.stubbing.defaultanswers.ReturnsEmptyValues; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; - -/** - * This class is used to configure Mockito's default behavior. - * It implements the IMockitoConfiguration interface. - */ -@SuppressWarnings("unused") -public class MockitoConfiguration implements IMockitoConfiguration { - - /** - * This method is used to provide a default answer for all methods of a mock that are not stubbed. - * It overrides the getDefaultAnswer method from the IMockitoConfiguration interface. - * If the return type of the method is assignable from Optional, it returns Control.Option(). - * Otherwise, it uses the super class's answer method to return default values. - * - * @return An Answer that provides the default answer for unstubbed methods. - */ - @Override - public Answer getDefaultAnswer() { - return new ReturnsEmptyValues() { - @Override - public Object answer(InvocationOnMock invocation) { - if (Optional.class.isAssignableFrom(invocation.getMethod().getReturnType())) { - return Control.Option(); - } - if (Sequence.class.isAssignableFrom(invocation.getMethod().getReturnType())) { - return Collections.List(); - } - if (ResultPage.class.isAssignableFrom(invocation.getMethod().getReturnType())) { - return ResultPage.empty(); - } - return super.answer(invocation); - } - - }; - } - - /** - * This method is used to determine whether Mockito should clean the stack trace. - * It overrides the cleansStackTrace method from the IMockitoConfiguration interface. - * - * @return A boolean value indicating whether Mockito should clean the stack trace. It always returns false. - */ - @Override - public boolean cleansStackTrace() { - return false; - } - - /** - * This method is used to determine whether Mockito should enable the class cache. - * It overrides the enableClassCache method from the IMockitoConfiguration interface. - * - * @return A boolean value indicating whether Mockito should enable the class cache. It always returns false. - */ - @Override - public boolean enableClassCache() { - return false; - } -} \ No newline at end of file diff --git a/fintrack-api/src/test/resources/application-test.yml b/fintrack-api/src/test/resources/application-test.yml deleted file mode 100644 index 6d0d23f1..00000000 --- a/fintrack-api/src/test/resources/application-test.yml +++ /dev/null @@ -1,24 +0,0 @@ -datasources: - default: - url: jdbc:h2:mem:FinTrack;DB_CLOSE_DELAY=50;MODE=MariaDB - driverClassName: org.h2.Driver - username: ${DATABASE_USER:fintrack} - password: ${DATABASE_PASSWORD:fintrack} - migration-locations: ["classpath:db/camunda/h2", "classpath:db/migration"] - dialect: mysql - - -micronaut: - application: - storage: - location: ./build/resources/test - server: - log-handled-exceptions: true - security: - basic-auth: - enabled: true - token: - jwt: - enabled: false - bearer: - enabled: false diff --git a/fintrack-api/src/test/resources/public/css/style.css b/fintrack-api/src/test/resources/public/css/style.css deleted file mode 100644 index 7ca4861b..00000000 --- a/fintrack-api/src/test/resources/public/css/style.css +++ /dev/null @@ -1 +0,0 @@ -Style works \ No newline at end of file diff --git a/fintrack-api/src/test/resources/public/index.html b/fintrack-api/src/test/resources/public/index.html deleted file mode 100644 index 90f1f15d..00000000 --- a/fintrack-api/src/test/resources/public/index.html +++ /dev/null @@ -1 +0,0 @@ -It works!!!! \ No newline at end of file diff --git a/fintrack-api/src/test/rsa-2048bit-key-pair.pem b/fintrack-api/src/test/rsa-2048bit-key-pair.pem deleted file mode 100644 index a73b74df..00000000 --- a/fintrack-api/src/test/rsa-2048bit-key-pair.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA2yRhS1015p6W4Z6WrUCf3mBQ0WuAfqFc0j/fYiEajgXk7E+L -uIcAg60Ct40sJw/+gX5iooqU6R8LMVOll9ELfWShPzposJa8tDByvw1ul0oXdr5K -cjljZ1zvcjdiFDAqRS/xlsFprG73kdUaQJSJJ7MPKoxfSmeRaMGwzpR73iwbuHPU -nz4Tx75RNQR9aozUM4+IF5ulKKSctCMx503cvaTH7GF/ZH3Mdo3CAIi0i0p2lvtq -CnWYqyaWhRw0jyqMQNf92Lnpg7LLVspvtfGxM3Sr75QMri1eEAM3NLbnvfTx37Uf -J13h54CdiubFY1IslOlGQlxjgUugh2sIurIf0wIDAQABAoIBAQDZMVNk2HslmSS9 -dOqtyBEq25b+GGgAGXnfwAJsRZcGD/114NkACH2QBBdfSIHsLUP3oAWuR1+TNXto -ZhkHQN78ZpISEpfT/XIFvMbdaDilpX9f5vXuA8brmQaQ0ydYDuy2KfBtlEBh8JFa -bjVYsF7HhAaSCxIesktB4kaUWEH0TThbAmn56g/XVKcj/NJFr84UW0m0vdCzPr2A -mjtpLP8vnSIeqlvlqG1bgHRsiqdvyt+3Gi9rY0F4vTQt2mt0poT0UIEP7OJUZd8Y -hBWBlQDXdSQ9jYoUGEJHGAxFc4p9drFA0MCb5FWg65HpOgEjP63hnW5jnDOPBLwl -A3lPuecRAoGBAP8FMUVmkT+DCm5qRke4JOsSz+CA6g+uOkeDVvAYbCgKLHHZO5XN -06Ud+Zjd2d2tmrDYcb3VMan/G91QTi0PErCH15JEZLavDX73XLEjw0oA+sRcGLzQ -zGEKMIRRy77hzRdnbkunFa3UuRqqYgkmfAIZIHTgwqWUyhuOj5rv8cS/AoGBANv7 -5u/wOhWl8niasQFrC5l/jdI7nP87yICH/L4F3k6cxGdGdBK4J/hd2FIq+JqWMvBv -nlq0Zknrfk0L2lQnSf4YT6RO31agvxuODpItlzUJ+91VgKBQpLKVPGnbZDwtEJzq -1UHilsEiN2hvnCEOyqs3J3qVOoZIywjxIkB6iMXtAoGAVTzWEB1NNQ5GoUsyPGyH -Im9CPga4tQ8F+bsjhtKS6/siidcS/Go0cH8JWxfj5x1MlAl0Uv/8Ppa/KITb7GGa -XJi66++iPhFakHJ7b9XFQ2n6Z0FlH08m0NSIDOIOGLn+Q/FVQ0IQk+6DBC+o3ugX -ENh3KbmqNY/60aUfyKikhZ8CgYEAmoQgnS4+jlAmtSHq7JUU67eVlTK8PubuGaHr -HEog8VTZ+7SX+UITCThZprV6M5MGqq2sLAgExS09ZL7Ll0qVhX3sCvw/kaiNM7yf -bXvKdr3RhJD3LSQX2zxJ2Az7Je19esrUClgvDe+LvbaPkwTBxGuUNl01Y3cj7d75 -8RJgma0CgYAYKPDcL4J25gNTK1xA7betdspuceh+aaGYCjgTlHdp8wJ6tcdwZCT0 -w7ay53A25H6KWR3yvBLBWA8GzChyzockZgZDH6E3LX+bty+OH9NBYXDOLH4swkwR -1m5ElDjxA4HgL/G5AoyVJueMgbCjfWzkjjCysFescghMTrMcoAkgwA== ------END RSA PRIVATE KEY----- diff --git a/gradle.properties b/gradle.properties index 27c38495..5594a271 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1 @@ -micronautVersion=4.9.3 -version=3.3.0-SNAPSHOT +version=4.0.0-SNAPSHOT diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/DefaultNamingStrategy.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/DefaultNamingStrategy.java index da620a73..c1a11801 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/DefaultNamingStrategy.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/DefaultNamingStrategy.java @@ -1,79 +1,82 @@ package com.jongsoft.finance.jpa; -import java.util.Locale; import org.hibernate.boot.model.naming.Identifier; import org.hibernate.boot.model.naming.PhysicalNamingStrategy; import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; -public class DefaultNamingStrategy implements PhysicalNamingStrategy { +import java.util.Locale; - @Override - public Identifier toPhysicalCatalogName(Identifier name, JdbcEnvironment jdbcEnvironment) { - return apply(name, jdbcEnvironment); - } +public class DefaultNamingStrategy implements PhysicalNamingStrategy { - @Override - public Identifier toPhysicalSchemaName(Identifier name, JdbcEnvironment jdbcEnvironment) { - return apply(name, jdbcEnvironment); - } + @Override + public Identifier toPhysicalCatalogName(Identifier name, JdbcEnvironment jdbcEnvironment) { + return apply(name, jdbcEnvironment); + } - @Override - public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment jdbcEnvironment) { - return apply(name, jdbcEnvironment); - } + @Override + public Identifier toPhysicalSchemaName(Identifier name, JdbcEnvironment jdbcEnvironment) { + return apply(name, jdbcEnvironment); + } - @Override - public Identifier toPhysicalSequenceName(Identifier name, JdbcEnvironment jdbcEnvironment) { - return apply(name, jdbcEnvironment); - } + @Override + public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment jdbcEnvironment) { + return apply(name, jdbcEnvironment); + } - @Override - public Identifier toPhysicalColumnName(Identifier name, JdbcEnvironment jdbcEnvironment) { - return apply(name, jdbcEnvironment); - } + @Override + public Identifier toPhysicalSequenceName(Identifier name, JdbcEnvironment jdbcEnvironment) { + return apply(name, jdbcEnvironment); + } - private Identifier apply(Identifier name, JdbcEnvironment jdbcEnvironment) { - if (name == null) { - return null; + @Override + public Identifier toPhysicalColumnName(Identifier name, JdbcEnvironment jdbcEnvironment) { + return apply(name, jdbcEnvironment); } - StringBuilder builder = new StringBuilder(name.getText().replace('.', '_')); - for (int i = 1; i < builder.length() - 1; i++) { - if (isUnderscoreRequired(builder.charAt(i - 1), builder.charAt(i), builder.charAt(i + 1))) { - builder.insert(i++, '_'); - } + + private Identifier apply(Identifier name, JdbcEnvironment jdbcEnvironment) { + if (name == null) { + return null; + } + StringBuilder builder = new StringBuilder(name.getText().replace('.', '_')); + for (int i = 1; i < builder.length() - 1; i++) { + if (isUnderscoreRequired( + builder.charAt(i - 1), builder.charAt(i), builder.charAt(i + 1))) { + builder.insert(i++, '_'); + } + } + return getIdentifier(builder.toString(), name.isQuoted(), jdbcEnvironment); } - return getIdentifier(builder.toString(), name.isQuoted(), jdbcEnvironment); - } - /** - * Get an identifier for the specified details. By default this method will return an identifier - * with the name adapted based on the result of {@link #isCaseInsensitive(JdbcEnvironment)} - * - * @param name the name of the identifier - * @param quoted if the identifier is quoted - * @param jdbcEnvironment the JDBC environment - * @return an identifier instance - */ - protected Identifier getIdentifier(String name, boolean quoted, JdbcEnvironment jdbcEnvironment) { - if (isCaseInsensitive(jdbcEnvironment)) { - name = name.toLowerCase(Locale.ROOT); + /** + * Get an identifier for the specified details. By default this method will return an identifier + * with the name adapted based on the result of {@link #isCaseInsensitive(JdbcEnvironment)} + * + * @param name the name of the identifier + * @param quoted if the identifier is quoted + * @param jdbcEnvironment the JDBC environment + * @return an identifier instance + */ + protected Identifier getIdentifier( + String name, boolean quoted, JdbcEnvironment jdbcEnvironment) { + if (isCaseInsensitive(jdbcEnvironment)) { + name = name.toLowerCase(Locale.ROOT); + } + return new Identifier(name, quoted); } - return new Identifier(name, quoted); - } - /** - * Specify whether the database is case sensitive. - * - * @param jdbcEnvironment the JDBC environment which can be used to determine case - * @return true if the database is case insensitive sensitivity - */ - protected boolean isCaseInsensitive(JdbcEnvironment jdbcEnvironment) { - return true; - } + /** + * Specify whether the database is case sensitive. + * + * @param jdbcEnvironment the JDBC environment which can be used to determine case + * @return true if the database is case insensitive sensitivity + */ + protected boolean isCaseInsensitive(JdbcEnvironment jdbcEnvironment) { + return true; + } - private boolean isUnderscoreRequired(char before, char current, char after) { - return Character.isLowerCase(before) - && Character.isUpperCase(current) - && Character.isLowerCase(after); - } + private boolean isUnderscoreRequired(char before, char current, char after) { + return Character.isLowerCase(before) + && Character.isUpperCase(current) + && Character.isLowerCase(after); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterDelegate.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterDelegate.java index c3e1dd35..9130bc6d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterDelegate.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterDelegate.java @@ -4,32 +4,32 @@ public interface FilterDelegate> { - record Sort(String field, boolean ascending) {} - - /** - * Generates the HQL query that belongs to the command supported by the delegate. - * - * @return string with HQL query, starting with the 'from' part - */ - String generateHql(); - - /** - * Create the sorting for the query. - * - * @return the sort - */ - Sort sort(); - - int page(); - - int pageSize(); - - Map getParameters(); - - /** - * Append a filtering for user. - * - * @param username - */ - T user(String username); + record Sort(String field, boolean ascending) {} + + /** + * Generates the HQL query that belongs to the command supported by the delegate. + * + * @return string with HQL query, starting with the 'from' part + */ + String generateHql(); + + /** + * Create the sorting for the query. + * + * @return the sort + */ + Sort sort(); + + int page(); + + int pageSize(); + + Map getParameters(); + + /** + * Append a filtering for user. + * + * @param username + */ + T user(String username); } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterFactoryJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterFactoryJpa.java index 34fb62b8..98e7e1c9 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterFactoryJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/FilterFactoryJpa.java @@ -11,49 +11,50 @@ import com.jongsoft.finance.jpa.tag.TagFilterCommand; import com.jongsoft.finance.jpa.transaction.TransactionFilterCommand; import com.jongsoft.finance.providers.*; + import jakarta.inject.Singleton; @Singleton @RequiresJpa public class FilterFactoryJpa implements FilterFactory { - @Override - public AccountFilterCommand account() { - return new AccountFilterCommand(); - } - - @Override - public TagProvider.FilterCommand tag() { - return new TagFilterCommand(); - } - - @Override - public TransactionProvider.FilterCommand transaction() { - return new TransactionFilterCommand(); - } - - @Override - public ExpenseProvider.FilterCommand expense() { - return new ExpenseFilterCommand(); - } - - @Override - public CategoryProvider.FilterCommand category() { - return new CategoryFilterCommand(); - } - - @Override - public TransactionScheduleProvider.FilterCommand schedule() { - return new ScheduleFilterCommand(); - } - - @Override - public SpendingInsightProvider.FilterCommand insight() { - return new SpendingInsightFilterCommand(); - } - - @Override - public SpendingPatternProvider.FilterCommand pattern() { - return new SpendingPatternFilterCommand(); - } + @Override + public AccountFilterCommand account() { + return new AccountFilterCommand(); + } + + @Override + public TagProvider.FilterCommand tag() { + return new TagFilterCommand(); + } + + @Override + public TransactionProvider.FilterCommand transaction() { + return new TransactionFilterCommand(); + } + + @Override + public ExpenseProvider.FilterCommand expense() { + return new ExpenseFilterCommand(); + } + + @Override + public CategoryProvider.FilterCommand category() { + return new CategoryFilterCommand(); + } + + @Override + public TransactionScheduleProvider.FilterCommand schedule() { + return new ScheduleFilterCommand(); + } + + @Override + public SpendingInsightProvider.FilterCommand insight() { + return new SpendingInsightFilterCommand(); + } + + @Override + public SpendingPatternProvider.FilterCommand pattern() { + return new SpendingPatternFilterCommand(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/ResultPageImpl.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/ResultPageImpl.java index 1cd1ecfe..cdae5efd 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/ResultPageImpl.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/ResultPageImpl.java @@ -2,55 +2,56 @@ import com.jongsoft.finance.ResultPage; import com.jongsoft.lang.collection.Sequence; + import java.util.function.Function; public class ResultPageImpl implements ResultPage { - private final int limit; - private final long totalRecords; - private final Sequence elements; - - public ResultPageImpl(Sequence elements, int limit, long totalRecords) { - this.elements = elements; - this.limit = limit; - this.totalRecords = totalRecords; - } - - @Override - public int pages() { - if (limit < totalRecords) { - return (int) (totalRecords / limit); - } else { - return 1; + private final int limit; + private final long totalRecords; + private final Sequence elements; + + public ResultPageImpl(Sequence elements, int limit, long totalRecords) { + this.elements = elements; + this.limit = limit; + this.totalRecords = totalRecords; + } + + @Override + public int pages() { + if (limit < totalRecords) { + return (int) (totalRecords / limit); + } else { + return 1; + } } - } - - @Override - public int pageSize() { - return limit; - } - - @Override - public long total() { - return totalRecords; - } - - @Override - public boolean hasNext() { - if (pages() > 1) { - return limit == elements.size(); - } else { - return false; + + @Override + public int pageSize() { + return limit; } - } - @Override - public Sequence content() { - return elements; - } + @Override + public long total() { + return totalRecords; + } - @Override - public ResultPage map(Function mapper) { - return new ResultPageImpl<>(elements.map(mapper), limit, totalRecords); - } + @Override + public boolean hasNext() { + if (pages() > 1) { + return limit == elements.size(); + } else { + return false; + } + } + + @Override + public Sequence content() { + return elements; + } + + @Override + public ResultPage map(Function mapper) { + return new ResultPageImpl<>(elements.map(mapper), limit, totalRecords); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountFilterCommand.java index 12f3d333..4067f258 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountFilterCommand.java @@ -5,72 +5,72 @@ import com.jongsoft.lang.collection.Sequence; public class AccountFilterCommand extends JpaFilterBuilder - implements AccountProvider.FilterCommand { + implements AccountProvider.FilterCommand { - private static final String FIELD_NUMBER = "number"; - private static final String FIELD_NAME = "name"; - private static final String FIELD_IBAN = "iban"; + private static final String FIELD_NUMBER = "number"; + private static final String FIELD_NAME = "name"; + private static final String FIELD_IBAN = "iban"; - public AccountFilterCommand() { - orderAscending = true; - orderBy = FIELD_NAME; - query().fieldEq("archived", false); - } - - @Override - public AccountFilterCommand name(String value, boolean exact) { - if (exact) { - query().fieldEq(FIELD_NAME, value.toLowerCase()); - } else { - query().fieldLike(FIELD_NAME, value.toLowerCase()); + public AccountFilterCommand() { + orderAscending = true; + orderBy = FIELD_NAME; + query().fieldEq("archived", false); } - return this; - } + @Override + public AccountFilterCommand name(String value, boolean exact) { + if (exact) { + query().fieldEq(FIELD_NAME, value.toLowerCase()); + } else { + query().fieldLike(FIELD_NAME, value.toLowerCase()); + } - @Override - public AccountFilterCommand iban(String value, boolean exact) { - if (exact) { - query().fieldEq(FIELD_IBAN, value.toLowerCase()); - } else { - query().fieldLike(FIELD_IBAN, value.toLowerCase()); + return this; } - return this; - } + @Override + public AccountFilterCommand iban(String value, boolean exact) { + if (exact) { + query().fieldEq(FIELD_IBAN, value.toLowerCase()); + } else { + query().fieldLike(FIELD_IBAN, value.toLowerCase()); + } - @Override - public AccountFilterCommand number(String value, boolean exact) { - if (exact) { - query().fieldEq(FIELD_NUMBER, value.toLowerCase()); - } else { - query().fieldLike(FIELD_NUMBER, value.toLowerCase()); + return this; } - return this; - } + @Override + public AccountFilterCommand number(String value, boolean exact) { + if (exact) { + query().fieldEq(FIELD_NUMBER, value.toLowerCase()); + } else { + query().fieldLike(FIELD_NUMBER, value.toLowerCase()); + } + + return this; + } - @Override - public AccountFilterCommand types(Sequence types) { - if (!types.isEmpty()) { - query().fieldEqOneOf("type.label", types.stream().toArray()); + @Override + public AccountFilterCommand types(Sequence types) { + if (!types.isEmpty()) { + query().fieldEqOneOf("type.label", types.stream().toArray()); + } + return this; } - return this; - } - @Override - public AccountFilterCommand page(int page, int pageSize) { - skipRows = page * pageSize; - limitRows = pageSize; - return this; - } + @Override + public AccountFilterCommand page(int page, int pageSize) { + skipRows = page * pageSize; + limitRows = pageSize; + return this; + } - public void user(String username) { - query().fieldEq("user.username", username); - } + public void user(String username) { + query().fieldEq("user.username", username); + } - @Override - public Class entityType() { - return AccountJpa.class; - } + @Override + public Class entityType() { + return AccountJpa.class; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountJpa.java index cc644faa..69b1049c 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountJpa.java @@ -5,94 +5,99 @@ import com.jongsoft.finance.jpa.savings.SavingGoalJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.schedule.Periodicity; + import jakarta.persistence.*; -import java.time.LocalDate; -import java.util.HashSet; -import java.util.Set; + import lombok.Builder; import lombok.Getter; + import org.hibernate.annotations.Formula; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + @Getter @Entity @Table(name = "account") public class AccountJpa extends EntityJpa { - private String name; - private String description; - - private String iban; - private String bic; - private String number; - - private String imageFileToken; - - private double interest; - - @Enumerated(value = EnumType.STRING) - private Periodicity interestPeriodicity; - - @ManyToOne - private AccountTypeJpa type; - - @ManyToOne - private UserAccountJpa user; - - @ManyToOne - private CurrencyJpa currency; - - @Basic(fetch = FetchType.LAZY) - @Formula("(select max(tj.t_date) from transaction_part t join transaction_journal tj on tj.id =" - + " t.journal_id where t.account_id = id and t.deleted is null)") - private LocalDate lastTransaction; - - @Basic(fetch = FetchType.LAZY) - @Formula("(select min(tj.t_date) from transaction_part t join transaction_journal tj on tj.id =" - + " t.journal_id where t.account_id = id and t.deleted is null)") - private LocalDate firstTransaction; - - @Basic(fetch = FetchType.LAZY) - @Formula("(select sum(t.amount) from transaction_part t where t.account_id = id and t.deleted is" - + " null)") - private Double balance; - - private boolean archived; - - @OneToMany(mappedBy = "account", fetch = FetchType.EAGER) - private Set savingGoals = Set.of(); - - public AccountJpa() {} - - @Builder - protected AccountJpa( - Long id, - String name, - String description, - String iban, - String bic, - String number, - String imageFileToken, - double interest, - Periodicity interestPeriodicity, - AccountTypeJpa type, - UserAccountJpa user, - CurrencyJpa currency, - boolean archived, - Set savingGoals) { - super(id); - - this.name = name; - this.description = description; - this.iban = iban; - this.bic = bic; - this.number = number; - this.imageFileToken = imageFileToken; - this.interest = interest; - this.interestPeriodicity = interestPeriodicity; - this.type = type; - this.user = user; - this.currency = currency; - this.archived = archived; - this.savingGoals = savingGoals != null ? new HashSet<>(savingGoals) : new HashSet<>(); - } + private String name; + private String description; + + private String iban; + private String bic; + private String number; + + private String imageFileToken; + + private double interest; + + @Enumerated(value = EnumType.STRING) + private Periodicity interestPeriodicity; + + @ManyToOne + private AccountTypeJpa type; + + @ManyToOne + private UserAccountJpa user; + + @ManyToOne + private CurrencyJpa currency; + + @Basic(fetch = FetchType.LAZY) + @Formula("(select max(tj.t_date) from transaction_part t join transaction_journal tj on tj.id =" + + " t.journal_id where t.account_id = id and t.deleted is null)") + private LocalDate lastTransaction; + + @Basic(fetch = FetchType.LAZY) + @Formula("(select min(tj.t_date) from transaction_part t join transaction_journal tj on tj.id =" + + " t.journal_id where t.account_id = id and t.deleted is null)") + private LocalDate firstTransaction; + + @Basic(fetch = FetchType.LAZY) + @Formula( + "(select sum(t.amount) from transaction_part t where t.account_id = id and t.deleted is" + + " null)") + private Double balance; + + private boolean archived; + + @OneToMany(mappedBy = "account", fetch = FetchType.EAGER) + private Set savingGoals = Set.of(); + + public AccountJpa() {} + + @Builder + protected AccountJpa( + Long id, + String name, + String description, + String iban, + String bic, + String number, + String imageFileToken, + double interest, + Periodicity interestPeriodicity, + AccountTypeJpa type, + UserAccountJpa user, + CurrencyJpa currency, + boolean archived, + Set savingGoals) { + super(id); + + this.name = name; + this.description = description; + this.iban = iban; + this.bic = bic; + this.number = number; + this.imageFileToken = imageFileToken; + this.interest = interest; + this.interestPeriodicity = interestPeriodicity; + this.type = type; + this.user = user; + this.currency = currency; + this.archived = archived; + this.savingGoals = savingGoals != null ? new HashSet<>(savingGoals) : new HashSet<>(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountProviderJpa.java index 324a0c30..d60f629f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountProviderJpa.java @@ -17,193 +17,201 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; import com.jongsoft.lang.time.Range; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Named; import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @ReadOnly @Singleton @RequiresJpa @Named("accountProvider") public class AccountProviderJpa implements AccountProvider { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - public AccountProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Optional synonymOf(String synonym) { - log.trace("Account synonym lookup with {}.", synonym); - - return entityManager - .from(AccountSynonymJpa.class) - .fieldEq("synonym", synonym) - .fieldEq("account.user.username", authenticationFacade.authenticated()) - .fieldEq("account.archived", false) - .projectSingleValue(AccountJpa.class, "account") - .map(this::convert); - } - - @Override - public Sequence lookup() { - log.trace("Listing all accounts for user."); - - return entityManager - .from(AccountJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public Optional lookup(long id) { - log.trace("Looking up account by id {}.", id); - return entityManager - .from(AccountJpa.class) - .fieldEq("id", id) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public Optional lookup(String name) { - log.trace("Account name lookup: {} for {}", name, authenticationFacade.authenticated()); - - return entityManager - .from(AccountJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .fieldEq("name", name) - .singleResult() - .map(this::convert); - } - - @Override - public Optional lookup(SystemAccountTypes accountType) { - log.trace("Account type lookup: {}", accountType); - - return entityManager - .from(AccountJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .fieldEq("type.label", accountType.label()) - .singleResult() - .map(this::convert); - } - - @Override - public ResultPage lookup(FilterCommand filter) { - log.trace("Accounts by filter: {}", filter); - - if (filter instanceof AccountFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager.from(delegate).paged().map(this::convert); + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + public AccountProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; } - throw new IllegalStateException("Cannot use non JPA filter on AccountProviderJpa"); - } - - @Override - public Sequence top(FilterCommand filter, Range range, boolean asc) { - log.trace("Account top listing by filter: {}", filter); - - if (filter instanceof AccountFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - var query = entityManager - .from(TransactionJpa.class) - .fieldIn("account.id", AccountJpa.class, subQuery -> { - delegate.applyTo(subQuery); - subQuery.project("id"); - }) - .fieldBetween("journal.date", range.from(), range.until()) - .fieldEq("journal.user.username", authenticationFacade.authenticated()) - .fieldNull("deleted") - .groupBy("account"); - - // delegate.applyPagingOnly(query); - return query - .project( - TripleProjection.class, - "new com.jongsoft.finance.jpa.projections.TripleProjection(e.account," - + " sum(e.amount), avg(e.amount))") - .map(triplet -> (TripleProjection) triplet) - .map(projection -> (AccountSpending) new AccountSpendingImpl( - convert(projection.getFirst()), projection.getSecond(), projection.getThird())) - .collect(ReactiveEntityManager.sequenceCollector()); + @Override + public Optional synonymOf(String synonym) { + log.trace("Account synonym lookup with {}.", synonym); + + return entityManager + .from(AccountSynonymJpa.class) + .fieldEq("synonym", synonym) + .fieldEq("account.user.username", authenticationFacade.authenticated()) + .fieldEq("account.archived", false) + .projectSingleValue(AccountJpa.class, "account") + .map(this::convert); } - throw new IllegalStateException("Cannot use non JPA filter on AccountProviderJpa"); - } + @Override + public Sequence lookup() { + log.trace("Listing all accounts for user."); + + return entityManager + .from(AccountJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } - protected Account convert(AccountJpa source) { - if (source == null - || !Objects.equals( - authenticationFacade.authenticated(), source.getUser().getUsername())) { - return null; + @Override + public Optional lookup(long id) { + log.trace("Looking up account by id {}.", id); + return entityManager + .from(AccountJpa.class) + .fieldEq("id", id) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); } - return Account.builder() - .id(source.getId()) - .name(source.getName()) - .description(source.getDescription()) - .type(source.getType().getLabel()) - .currency(source.getCurrency().getCode()) - .iban(source.getIban()) - .bic(source.getBic()) - .balance(java.util.Optional.ofNullable(source.getBalance()).orElse(0D)) - .imageFileToken(source.getImageFileToken()) - .firstTransaction(source.getFirstTransaction()) - .lastTransaction(source.getLastTransaction()) - .number(source.getNumber()) - .interest(source.getInterest()) - .interestPeriodicity(source.getInterestPeriodicity()) - .savingGoals(Collections.Set(this.convertSavingGoals(source.getSavingGoals()))) - .user(new UserIdentifier(source.getUser().getUsername())) - .build(); - } - - private Set convertSavingGoals(Set savingGoals) { - if (savingGoals == null) { - return Set.of(); + @Override + public Optional lookup(String name) { + log.trace("Account name lookup: {} for {}", name, authenticationFacade.authenticated()); + + return entityManager + .from(AccountJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .fieldEq("name", name) + .singleResult() + .map(this::convert); } - return savingGoals.stream() - .filter(Predicate.not(SavingGoalJpa::isArchived)) - .map(source -> SavingGoal.builder() - .id(source.getId()) - .allocated(source.getAllocated()) - .goal(source.getGoal()) - .targetDate(source.getTargetDate()) - .name(source.getName()) - .description(source.getDescription()) - .schedule( - source.getPeriodicity() != null - ? new ScheduleValue(source.getPeriodicity(), source.getInterval()) - : null) - .account(Account.builder() - .id(source.getAccount().getId()) - .name(source.getAccount().getName()) - .build()) - .build()) - .collect(Collectors.toSet()); - } + @Override + public Optional lookup(SystemAccountTypes accountType) { + log.trace("Account type lookup: {}", accountType); + + return entityManager + .from(AccountJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .fieldEq("type.label", accountType.label()) + .singleResult() + .map(this::convert); + } + + @Override + public ResultPage lookup(FilterCommand filter) { + log.trace("Accounts by filter: {}", filter); + + if (filter instanceof AccountFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + return entityManager.from(delegate).paged().map(this::convert); + } + + throw new IllegalStateException("Cannot use non JPA filter on AccountProviderJpa"); + } + + @Override + public Sequence top( + FilterCommand filter, Range range, boolean asc) { + log.trace("Account top listing by filter: {}", filter); + + if (filter instanceof AccountFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + var query = entityManager + .from(TransactionJpa.class) + .fieldIn("account.id", AccountJpa.class, subQuery -> { + delegate.applyTo(subQuery); + subQuery.project("id"); + }) + .fieldBetween("journal.date", range.from(), range.until()) + .fieldEq("journal.user.username", authenticationFacade.authenticated()) + .fieldNull("deleted") + .groupBy("account"); + + // delegate.applyPagingOnly(query); + return query.project( + TripleProjection.class, + "new com.jongsoft.finance.jpa.projections.TripleProjection(e.account," + + " sum(e.amount), avg(e.amount))") + .map(triplet -> (TripleProjection) triplet) + .map(projection -> (AccountSpending) new AccountSpendingImpl( + convert(projection.getFirst()), + projection.getSecond(), + projection.getThird())) + .collect(ReactiveEntityManager.sequenceCollector()); + } + + throw new IllegalStateException("Cannot use non JPA filter on AccountProviderJpa"); + } + + protected Account convert(AccountJpa source) { + if (source == null + || !Objects.equals( + authenticationFacade.authenticated(), source.getUser().getUsername())) { + return null; + } + + return Account.builder() + .id(source.getId()) + .name(source.getName()) + .description(source.getDescription()) + .type(source.getType().getLabel()) + .currency(source.getCurrency().getCode()) + .iban(source.getIban()) + .bic(source.getBic()) + .balance(java.util.Optional.ofNullable(source.getBalance()).orElse(0D)) + .imageFileToken(source.getImageFileToken()) + .firstTransaction(source.getFirstTransaction()) + .lastTransaction(source.getLastTransaction()) + .number(source.getNumber()) + .interest(source.getInterest()) + .interestPeriodicity(source.getInterestPeriodicity()) + .savingGoals(Collections.Set(this.convertSavingGoals(source.getSavingGoals()))) + .user(new UserIdentifier(source.getUser().getUsername())) + .remove(source.isArchived()) + .build(); + } + + private Set convertSavingGoals(Set savingGoals) { + if (savingGoals == null) { + return Set.of(); + } + + return savingGoals.stream() + .filter(Predicate.not(SavingGoalJpa::isArchived)) + .map(source -> SavingGoal.builder() + .id(source.getId()) + .allocated(source.getAllocated()) + .goal(source.getGoal()) + .targetDate(source.getTargetDate()) + .name(source.getName()) + .description(source.getDescription()) + .schedule( + source.getPeriodicity() != null + ? new ScheduleValue( + source.getPeriodicity(), source.getInterval()) + : null) + .account(Account.builder() + .id(source.getAccount().getId()) + .name(source.getAccount().getName()) + .build()) + .build()) + .collect(Collectors.toSet()); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSpendingImpl.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSpendingImpl.java index 946d6edf..0f8e54a2 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSpendingImpl.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSpendingImpl.java @@ -2,32 +2,33 @@ import com.jongsoft.finance.domain.account.Account; import com.jongsoft.finance.providers.AccountProvider; + import java.math.BigDecimal; public class AccountSpendingImpl implements AccountProvider.AccountSpending { - private final Account account; - private final BigDecimal total; - private final double average; - - public AccountSpendingImpl(Account account, BigDecimal total, double average) { - this.account = account; - this.total = total; - this.average = average; - } - - @Override - public Account account() { - return account; - } - - @Override - public double total() { - return total.doubleValue(); - } - - @Override - public double average() { - return average; - } + private final Account account; + private final BigDecimal total; + private final double average; + + public AccountSpendingImpl(Account account, BigDecimal total, double average) { + this.account = account; + this.total = total; + this.average = average; + } + + @Override + public Account account() { + return account; + } + + @Override + public double total() { + return total.doubleValue(); + } + + @Override + public double average() { + return average; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSynonymJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSynonymJpa.java index d0f3382e..28f4c0f6 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSynonymJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountSynonymJpa.java @@ -1,9 +1,11 @@ package com.jongsoft.finance.jpa.account; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.Entity; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -12,16 +14,16 @@ @Table(name = "account_synonym") public class AccountSynonymJpa extends EntityJpa { - private String synonym; + private String synonym; - @ManyToOne - private AccountJpa account; + @ManyToOne + private AccountJpa account; - @Builder - public AccountSynonymJpa(String synonym, AccountJpa account) { - this.synonym = synonym; - this.account = account; - } + @Builder + public AccountSynonymJpa(String synonym, AccountJpa account) { + this.synonym = synonym; + this.account = account; + } - public AccountSynonymJpa() {} + public AccountSynonymJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeJpa.java index a83d8377..1c022664 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeJpa.java @@ -1,9 +1,11 @@ package com.jongsoft.finance.jpa.account; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -12,21 +14,21 @@ @Table(name = "account_type") public class AccountTypeJpa extends EntityJpa { - @Column(name = "label", length = 150, unique = true) - private String label; + @Column(name = "label", length = 150, unique = true) + private String label; - private boolean hidden; + private boolean hidden; - public AccountTypeJpa() {} + public AccountTypeJpa() {} - @Builder - protected AccountTypeJpa(Long id, String label, boolean hidden) { - super(id); - this.label = label; - this.hidden = hidden; - } + @Builder + protected AccountTypeJpa(Long id, String label, boolean hidden) { + super(id); + this.label = label; + this.hidden = hidden; + } - public String getDisplayKey() { - return "AccountType." + getLabel(); - } + public String getDisplayKey() { + return "AccountType." + getLabel(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeProviderJpa.java index 1aebfebb..8b098e07 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/AccountTypeProviderJpa.java @@ -4,34 +4,38 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.providers.AccountTypeProvider; import com.jongsoft.lang.collection.Sequence; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Named; import jakarta.inject.Singleton; -import java.util.Comparator; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Comparator; + @ReadOnly @Singleton @RequiresJpa @Named("accountTypeProvider") public class AccountTypeProviderJpa implements AccountTypeProvider { - public static final Logger LOGGER = LoggerFactory.getLogger(AccountTypeProviderJpa.class); + public static final Logger LOGGER = LoggerFactory.getLogger(AccountTypeProviderJpa.class); - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - AccountTypeProviderJpa(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + AccountTypeProviderJpa(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - public Sequence lookup(boolean hidden) { - LOGGER.debug("Locating account types with hidden: {}", hidden); + @Override + public Sequence lookup(boolean hidden) { + LOGGER.debug("Locating account types with hidden: {}", hidden); - return entityManager.from(AccountTypeJpa.class).fieldEq("hidden", hidden).stream() - .sorted(Comparator.comparing(AccountTypeJpa::getLabel)) - .map(AccountTypeJpa::getLabel) - .collect(ReactiveEntityManager.sequenceCollector()); - } + return entityManager.from(AccountTypeJpa.class).fieldEq("hidden", hidden).stream() + .sorted(Comparator.comparing(AccountTypeJpa::getLabel)) + .map(AccountTypeJpa::getLabel) + .collect(ReactiveEntityManager.sequenceCollector()); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountHandler.java index ca74294b..b33dc0b2 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.ChangeAccountCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,26 +18,26 @@ @RequiresJpa @Transactional public class ChangeAccountHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - - @Inject - ChangeAccountHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeAccountCommand command) { - log.info("[{}] - Processing account change event", command.id()); - - entityManager - .update(AccountJpa.class) - .set("iban", command.iban()) - .set("bic", command.bic()) - .set("number", command.number()) - .fieldEq("id", command.id()) - .execute(); - } + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + + @Inject + ChangeAccountHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeAccountCommand command) { + log.info("[{}] - Processing account change event", command.id()); + + entityManager + .update(AccountJpa.class) + .set("iban", command.iban()) + .set("bic", command.bic()) + .set("number", command.number()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountInterestHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountInterestHandler.java index f4929765..2fa67174 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountInterestHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/ChangeAccountInterestHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.ChangeInterestCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,25 +18,25 @@ @RequiresJpa @Transactional public class ChangeAccountInterestHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - - @Inject - ChangeAccountInterestHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeInterestCommand command) { - log.info("[{}] - Processing account interest event", command.id()); - - entityManager - .update(AccountJpa.class) - .set("interest", command.interest()) - .set("interestPeriodicity", command.periodicity()) - .fieldEq("id", command.id()) - .execute(); - } + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + + @Inject + ChangeAccountInterestHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeInterestCommand command) { + log.info("[{}] - Processing account interest event", command.id()); + + entityManager + .update(AccountJpa.class) + .set("interest", command.interest()) + .set("interestPeriodicity", command.periodicity()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/CreateAccountHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/CreateAccountHandler.java index 1169069f..44b0ba37 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/CreateAccountHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/CreateAccountHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.CreateAccountCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,35 +19,35 @@ @RequiresJpa @Transactional public class CreateAccountHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - - @Inject - CreateAccountHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(CreateAccountCommand event) { - log.info("[{}] - Processing account create event", event.name()); - - var toCreate = AccountJpa.builder() - .name(event.name()) - .currency(entityManager - .from(CurrencyJpa.class) - .fieldEq("code", event.currency()) - .singleResult() - .get()) - .type(entityManager - .from(AccountTypeJpa.class) - .fieldEq("label", event.type()) - .singleResult() - .get()) - .user(entityManager.currentUser()) - .build(); - - entityManager.persist(toCreate); - } + private final Logger log = LoggerFactory.getLogger(CreateAccountHandler.class); + + private final ReactiveEntityManager entityManager; + + @Inject + CreateAccountHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(CreateAccountCommand event) { + log.info("[{}] - Processing account create event", event.name()); + + var toCreate = AccountJpa.builder() + .name(event.name()) + .currency(entityManager + .from(CurrencyJpa.class) + .fieldEq("code", event.currency()) + .singleResult() + .get()) + .type(entityManager + .from(AccountTypeJpa.class) + .fieldEq("label", event.type()) + .singleResult() + .get()) + .user(entityManager.currentUser()) + .build(); + + entityManager.persist(toCreate); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterAccountIconHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterAccountIconHandler.java index 3ad0aee2..75b5a88e 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterAccountIconHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterAccountIconHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.RegisterAccountIconCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,24 +18,24 @@ @RequiresJpa @Transactional public class RegisterAccountIconHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - - @Inject - RegisterAccountIconHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RegisterAccountIconCommand command) { - log.info("[{}] - Processing icon registration event", command.id()); - - entityManager - .update(AccountJpa.class) - .set("imageFileToken", command.fileCode()) - .fieldEq("id", command.id()) - .execute(); - } + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + + @Inject + RegisterAccountIconHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RegisterAccountIconCommand command) { + log.info("[{}] - Processing icon registration event", command.id()); + + entityManager + .update(AccountJpa.class) + .set("imageFileToken", command.fileCode()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterSynonymHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterSynonymHandler.java index 021fc5b9..8936bd85 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterSynonymHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RegisterSynonymHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.RegisterSynonymCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,50 +19,50 @@ @RequiresJpa @Transactional public class RegisterSynonymHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); + private final Logger log = LoggerFactory.getLogger(this.getClass()); - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; - @Inject - RegisterSynonymHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } + @Inject + RegisterSynonymHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } - @Override - @BusinessEventListener - public void handle(RegisterSynonymCommand command) { - log.info("[{}] - Processing register synonym event", command.accountId()); + @Override + @BusinessEventListener + public void handle(RegisterSynonymCommand command) { + log.info("[{}] - Processing register synonym event", command.accountId()); - var existingId = entityManager - .from(AccountSynonymJpa.class) - .fieldEq("synonym", command.synonym()) - .fieldEq("account.user.username", authenticationFacade.authenticated()) - .projectSingleValue(Long.class, "id"); + var existingId = entityManager + .from(AccountSynonymJpa.class) + .fieldEq("synonym", command.synonym()) + .fieldEq("account.user.username", authenticationFacade.authenticated()) + .projectSingleValue(Long.class, "id"); - var account = entityManager - .from(AccountJpa.class) - .joinFetch("currency") - .joinFetch("user") - .fieldEq("id", command.accountId()) - .singleResult() - .get(); + var account = entityManager + .from(AccountJpa.class) + .joinFetch("currency") + .joinFetch("user") + .fieldEq("id", command.accountId()) + .singleResult() + .get(); - if (existingId.isPresent()) { - entityManager - .update(AccountSynonymJpa.class) - .set("account", account) - .fieldEq("id", existingId.get()) - .execute(); - } else { - var entity = AccountSynonymJpa.builder() - .account(account) - .synonym(command.synonym()) - .build(); + if (existingId.isPresent()) { + entityManager + .update(AccountSynonymJpa.class) + .set("account", account) + .fieldEq("id", existingId.get()) + .execute(); + } else { + var entity = AccountSynonymJpa.builder() + .account(account) + .synonym(command.synonym()) + .build(); - entityManager.persist(entity); + entityManager.persist(entity); + } } - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RenameAccountHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RenameAccountHandler.java index d41834e4..1f6407ad 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RenameAccountHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/RenameAccountHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.RenameAccountCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,39 +19,39 @@ @RequiresJpa @Transactional public class RenameAccountHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - - @Inject - RenameAccountHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RenameAccountCommand command) { - log.info("[{}] - Processing account rename event", command.id()); - - entityManager - .update(AccountJpa.class) - .set("name", command.name()) - .set("description", command.description()) - .set( - "type", - entityManager - .from(AccountTypeJpa.class) - .fieldEq("label", command.type()) - .singleResult() - .get()) - .set( - "currency", - entityManager - .from(CurrencyJpa.class) - .fieldEq("code", command.currency()) - .singleResult() - .get()) - .fieldEq("id", command.id()) - .execute(); - } + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + + @Inject + RenameAccountHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RenameAccountCommand command) { + log.info("[{}] - Processing account rename event", command.id()); + + entityManager + .update(AccountJpa.class) + .set("name", command.name()) + .set("description", command.description()) + .set( + "type", + entityManager + .from(AccountTypeJpa.class) + .fieldEq("label", command.type()) + .singleResult() + .get()) + .set( + "currency", + entityManager + .from(CurrencyJpa.class) + .fieldEq("code", command.currency()) + .singleResult() + .get()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/TerminateAccountHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/TerminateAccountHandler.java index 9fc9303e..91987a7f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/TerminateAccountHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/account/TerminateAccountHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.account.TerminateAccountCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,24 +18,24 @@ @RequiresJpa @Transactional public class TerminateAccountHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - - @Inject - TerminateAccountHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(TerminateAccountCommand command) { - log.info("[{}] - Processing account terminate event", command.id()); - - entityManager - .update(AccountJpa.class) - .set("archived", true) - .fieldEq("id", command.id()) - .execute(); - } + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + + @Inject + TerminateAccountHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(TerminateAccountCommand command) { + log.info("[{}] - Processing account terminate event", command.id()); + + entityManager + .update(AccountJpa.class) + .set("archived", true) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetJpa.java index 0ca7de1f..b2bb1937 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetJpa.java @@ -2,45 +2,48 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; -import java.time.LocalDate; -import java.util.Set; + import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; +import java.util.Set; + @Getter @Entity @Table(name = "budget") public class BudgetJpa extends EntityJpa { - private double expectedIncome; + private double expectedIncome; - @Column(name = "b_from") - private LocalDate from; + @Column(name = "b_from") + private LocalDate from; - @Column(name = "b_until") - private LocalDate until; + @Column(name = "b_until") + private LocalDate until; - @ManyToOne - @JoinColumn - private UserAccountJpa user; + @ManyToOne + @JoinColumn + private UserAccountJpa user; - @OneToMany(mappedBy = "budget", fetch = FetchType.EAGER) - private Set expenses; + @OneToMany(mappedBy = "budget", fetch = FetchType.EAGER) + private Set expenses; - @Builder - private BudgetJpa( - double expectedIncome, - LocalDate from, - LocalDate until, - UserAccountJpa user, - Set expenses) { - this.expectedIncome = expectedIncome; - this.from = from; - this.until = until; - this.user = user; - this.expenses = expenses; - } + @Builder + private BudgetJpa( + double expectedIncome, + LocalDate from, + LocalDate until, + UserAccountJpa user, + Set expenses) { + this.expectedIncome = expectedIncome; + this.from = from; + this.until = until; + this.user = user; + this.expenses = expenses; + } - public BudgetJpa() {} + public BudgetJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpa.java index 787ce294..f0b002cc 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpa.java @@ -10,9 +10,12 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Named; import jakarta.inject.Singleton; + import org.slf4j.Logger; @ReadOnly @@ -21,77 +24,78 @@ @Named("budgetProvider") public class BudgetProviderJpa implements BudgetProvider { - private final Logger logger = getLogger(BudgetProviderJpa.class); - - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager reactiveEntityManager; - - public BudgetProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager reactiveEntityManager) { - this.authenticationFacade = authenticationFacade; - this.reactiveEntityManager = reactiveEntityManager; - } - - @Override - public Sequence lookup() { - logger.trace("Fetching all budgets for user."); - - return reactiveEntityManager - .from(BudgetJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .orderBy("from", true) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public Optional lookup(int year, int month) { - logger.trace("Fetching budget for user in {}-{}.", year, month); - var range = DateUtils.forMonth(year, month); - - return reactiveEntityManager - .from(BudgetJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldLtOrEq("from", range.from()) - .fieldGtOrEqNullable("until", range.until()) - .singleResult() - .map(this::convert); - } - - @Override - public Optional first() { - logger.trace("Fetching first budget for user."); - - return reactiveEntityManager - .from(BudgetJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .orderBy("from", true) - .limit(1) - .singleResult() - .map(this::convert); - } - - private Budget convert(BudgetJpa source) { - if (source == null) { - return null; + private final Logger logger = getLogger(BudgetProviderJpa.class); + + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager reactiveEntityManager; + + public BudgetProviderJpa( + AuthenticationFacade authenticationFacade, + ReactiveEntityManager reactiveEntityManager) { + this.authenticationFacade = authenticationFacade; + this.reactiveEntityManager = reactiveEntityManager; } - var budget = Budget.builder() - .id(source.getId()) - .start(source.getFrom()) - .end(source.getUntil()) - .expectedIncome(source.getExpectedIncome()) - .build(); - - for (var expense : source.getExpenses()) { - budget - .new Expense( - expense.getExpense().getId(), - expense.getExpense().getName(), - expense.getUpperBound().doubleValue()); + @Override + public Sequence lookup() { + logger.trace("Fetching all budgets for user."); + + return reactiveEntityManager + .from(BudgetJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .orderBy("from", true) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); } - return budget; - } + @Override + public Optional lookup(int year, int month) { + logger.trace("Fetching budget for user in {}-{}.", year, month); + var range = DateUtils.forMonth(year, month); + + return reactiveEntityManager + .from(BudgetJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldLtOrEq("from", range.from()) + .fieldGtOrEqNullable("until", range.until()) + .singleResult() + .map(this::convert); + } + + @Override + public Optional first() { + logger.trace("Fetching first budget for user."); + + return reactiveEntityManager + .from(BudgetJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .orderBy("from", true) + .limit(1) + .singleResult() + .map(this::convert); + } + + private Budget convert(BudgetJpa source) { + if (source == null) { + return null; + } + + var budget = Budget.builder() + .id(source.getId()) + .start(source.getFrom()) + .end(source.getUntil()) + .expectedIncome(source.getExpectedIncome()) + .build(); + + for (var expense : source.getExpenses()) { + budget + .new Expense( + expense.getExpense().getId(), + expense.getExpense().getName(), + expense.getUpperBound().doubleValue()); + } + + return budget; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CloseBudgetHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CloseBudgetHandler.java index 33320011..f7c15d1d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CloseBudgetHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CloseBudgetHandler.java @@ -4,9 +4,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.budget.CloseBudgetCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -14,22 +17,22 @@ @Transactional public class CloseBudgetHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - CloseBudgetHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + CloseBudgetHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CloseBudgetCommand command) { - log.info("[{}] - Processing budget closing event", command.id()); + @Override + @BusinessEventListener + public void handle(CloseBudgetCommand command) { + log.info("[{}] - Processing budget closing event", command.id()); - entityManager - .update(BudgetJpa.class) - .set("until", command.end()) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(BudgetJpa.class) + .set("until", command.end()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateBudgetHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateBudgetHandler.java index 4a2149fd..0aff619f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateBudgetHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateBudgetHandler.java @@ -8,61 +8,64 @@ import com.jongsoft.finance.messaging.commands.budget.CreateBudgetCommand; import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.Sequence; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.math.BigDecimal; import java.util.Collection; import java.util.HashSet; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @Transactional public class CreateBudgetHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CreateBudgetHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CreateBudgetHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateBudgetCommand command) { - log.info("[{}] - Processing budget create event", command.budget().getStart()); + @Override + @BusinessEventListener + public void handle(CreateBudgetCommand command) { + log.info("[{}] - Processing budget create event", command.budget().getStart()); - var budget = BudgetJpa.builder() - .from(command.budget().getStart()) - .expectedIncome(command.budget().getExpectedIncome()) - .expenses(new HashSet<>()) - .user(entityManager.currentUser()) - .build(); + var budget = BudgetJpa.builder() + .from(command.budget().getStart()) + .expectedIncome(command.budget().getExpectedIncome()) + .expenses(new HashSet<>()) + .user(entityManager.currentUser()) + .build(); - entityManager.persist(budget); + entityManager.persist(budget); - Control.Option(command.budget().getExpenses()) - .ifPresent(expenses -> budget.getExpenses().addAll(createExpenses(budget, expenses))); - } + Control.Option(command.budget().getExpenses()).ifPresent(expenses -> budget.getExpenses() + .addAll(createExpenses(budget, expenses))); + } - private Collection createExpenses( - BudgetJpa budget, Sequence expenses) { - log.debug("Creating {} expenses for budget period {}", expenses.size(), budget.getFrom()); - return expenses - .map(expense -> ExpensePeriodJpa.builder() - .budget(budget) - .expense(entityManager.getById(ExpenseJpa.class, expense.getId())) - .lowerBound( - BigDecimal.valueOf(expense.getLowerBound()).subtract(new BigDecimal("0.001"))) - .upperBound(BigDecimal.valueOf(expense.getUpperBound())) - .build()) - .map(this::persist) - .toJava(); - } + private Collection createExpenses( + BudgetJpa budget, Sequence expenses) { + log.debug("Creating {} expenses for budget period {}", expenses.size(), budget.getFrom()); + return expenses.map(expense -> ExpensePeriodJpa.builder() + .budget(budget) + .expense(entityManager.getById(ExpenseJpa.class, expense.getId())) + .lowerBound(BigDecimal.valueOf(expense.getLowerBound()) + .subtract(new BigDecimal("0.001"))) + .upperBound(BigDecimal.valueOf(expense.getUpperBound())) + .build()) + .map(this::persist) + .toJava(); + } - private U persist(U entity) { - entityManager.persist(entity); - return entity; - } + private U persist(U entity) { + entityManager.persist(entity); + return entity; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateExpenseHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateExpenseHandler.java index f9f6163f..70043382 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateExpenseHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/CreateExpenseHandler.java @@ -5,53 +5,58 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.budget.CreateExpenseCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.transaction.Transactional; -import java.math.BigDecimal; + import lombok.extern.slf4j.Slf4j; +import java.math.BigDecimal; + @Slf4j @Singleton @Transactional public class CreateExpenseHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public CreateExpenseHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(CreateExpenseCommand command) { - log.info("[{}] - Processing expense create event", command.name()); - - var activeBudget = entityManager - .from(BudgetJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("from", command.start()) - .singleResult() - .get(); - - var expenseJpa = - ExpenseJpa.builder().name(command.name()).user(activeBudget.getUser()).build(); - entityManager.persist(expenseJpa); - - var expensePeriodJpa = ExpensePeriodJpa.builder() - .lowerBound(command.budget().subtract(new BigDecimal("0.01"))) - .upperBound(command.budget()) - .expense(expenseJpa) - .budget(activeBudget) - .build(); - entityManager.persist(expensePeriodJpa); - - // fix for when budget is created in same transaction (otherwise the list remains empty in - // hibernate session) - activeBudget.getExpenses().add(expensePeriodJpa); - } + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public CreateExpenseHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(CreateExpenseCommand command) { + log.info("[{}] - Processing expense create event", command.name()); + + var activeBudget = entityManager + .from(BudgetJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("from", command.start()) + .singleResult() + .get(); + + var expenseJpa = ExpenseJpa.builder() + .name(command.name()) + .user(activeBudget.getUser()) + .build(); + entityManager.persist(expenseJpa); + + var expensePeriodJpa = ExpensePeriodJpa.builder() + .lowerBound(command.budget().subtract(new BigDecimal("0.01"))) + .upperBound(command.budget()) + .expense(expenseJpa) + .budget(activeBudget) + .build(); + entityManager.persist(expensePeriodJpa); + + // fix for when budget is created in same transaction (otherwise the list remains empty in + // hibernate session) + activeBudget.getExpenses().add(expensePeriodJpa); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseFilterCommand.java index 7d4614d0..83ee24aa 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseFilterCommand.java @@ -2,35 +2,36 @@ import com.jongsoft.finance.jpa.query.JpaFilterBuilder; import com.jongsoft.finance.providers.ExpenseProvider; + import jakarta.inject.Singleton; @Singleton public class ExpenseFilterCommand extends JpaFilterBuilder - implements ExpenseProvider.FilterCommand { - - public ExpenseFilterCommand() { - query().fieldEq("archived", false); - orderBy = "name"; - orderAscending = true; - } - - public void user(String username) { - query().fieldEq("user.username", username); - } - - @Override - public ExpenseFilterCommand name(String value, boolean exact) { - if (exact) { - query().fieldEq("name", value); - } else { - query().fieldLike("name", value); + implements ExpenseProvider.FilterCommand { + + public ExpenseFilterCommand() { + query().fieldEq("archived", false); + orderBy = "name"; + orderAscending = true; + } + + public void user(String username) { + query().fieldEq("user.username", username); } - return this; - } + @Override + public ExpenseFilterCommand name(String value, boolean exact) { + if (exact) { + query().fieldEq("name", value); + } else { + query().fieldLike("name", value); + } - @Override - public Class entityType() { - return ExpenseJpa.class; - } + return this; + } + + @Override + public Class entityType() { + return ExpenseJpa.class; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseJpa.java index 432b0290..935c8112 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseJpa.java @@ -2,10 +2,12 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -14,19 +16,19 @@ @Table(name = "budget_expense") public class ExpenseJpa extends EntityJpa { - private String name; - private boolean archived; + private String name; + private boolean archived; - @ManyToOne - @JoinColumn - private UserAccountJpa user; + @ManyToOne + @JoinColumn + private UserAccountJpa user; - @Builder - private ExpenseJpa(String name, boolean archived, UserAccountJpa user) { - this.name = name; - this.archived = archived; - this.user = user; - } + @Builder + private ExpenseJpa(String name, boolean archived, UserAccountJpa user) { + this.name = name; + this.archived = archived; + this.user = user; + } - public ExpenseJpa() {} + public ExpenseJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpensePeriodJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpensePeriodJpa.java index 8f9c3f3e..d845488a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpensePeriodJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpensePeriodJpa.java @@ -1,44 +1,47 @@ package com.jongsoft.finance.jpa.budget; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.math.BigDecimal; + import lombok.Builder; import lombok.Getter; import lombok.Setter; +import java.math.BigDecimal; + @Getter @Setter @Entity @Table(name = "budget_period") public class ExpensePeriodJpa extends EntityJpa { - @Column(name = "bp_lower_bound") - private BigDecimal lowerBound; + @Column(name = "bp_lower_bound") + private BigDecimal lowerBound; - @Column(name = "bp_upper_bound") - private BigDecimal upperBound; + @Column(name = "bp_upper_bound") + private BigDecimal upperBound; - @ManyToOne - @JoinColumn - private ExpenseJpa expense; + @ManyToOne + @JoinColumn + private ExpenseJpa expense; - @ManyToOne - @JoinColumn - private BudgetJpa budget; + @ManyToOne + @JoinColumn + private BudgetJpa budget; - @Builder - private ExpensePeriodJpa( - BigDecimal lowerBound, BigDecimal upperBound, ExpenseJpa expense, BudgetJpa budget) { - this.lowerBound = lowerBound; - this.upperBound = upperBound; - this.expense = expense; - this.budget = budget; - } + @Builder + private ExpensePeriodJpa( + BigDecimal lowerBound, BigDecimal upperBound, ExpenseJpa expense, BudgetJpa budget) { + this.lowerBound = lowerBound; + this.upperBound = upperBound; + this.expense = expense; + this.budget = budget; + } - public ExpensePeriodJpa() {} + public ExpensePeriodJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpa.java index 6bee310b..143c4c72 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpa.java @@ -6,7 +6,9 @@ import com.jongsoft.finance.providers.ExpenseProvider; import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; @@ -16,42 +18,42 @@ @Named("expenseProvider") public class ExpenseProviderJpa implements ExpenseProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public ExpenseProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Optional lookup(long id) { - return entityManager - .from(ExpenseJpa.class) - .fieldEq("id", id) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public ResultPage lookup(FilterCommand filter) { - if (filter instanceof ExpenseFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager.from(delegate).paged().map(this::convert); + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + @Inject + public ExpenseProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Optional lookup(long id) { + return entityManager + .from(ExpenseJpa.class) + .fieldEq("id", id) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); } - throw new IllegalStateException("Cannot use non JPA filter on ExpenseProviderJpa"); - } + @Override + public ResultPage lookup(FilterCommand filter) { + if (filter instanceof ExpenseFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + return entityManager.from(delegate).paged().map(this::convert); + } - protected EntityRef.NamedEntity convert(ExpenseJpa source) { - if (source == null) { - return null; + throw new IllegalStateException("Cannot use non JPA filter on ExpenseProviderJpa"); } - return new EntityRef.NamedEntity(source.getId(), source.getName()); - } + protected EntityRef.NamedEntity convert(ExpenseJpa source) { + if (source == null) { + return null; + } + + return new EntityRef.NamedEntity(source.getId(), source.getName()); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandler.java index 605a164e..fcf2799a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandler.java @@ -5,41 +5,44 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.budget.UpdateExpenseCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.transaction.Transactional; -import java.math.BigDecimal; + import lombok.extern.slf4j.Slf4j; +import java.math.BigDecimal; + @Slf4j @Singleton @Transactional public class UpdateExpenseHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public UpdateExpenseHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(UpdateExpenseCommand command) { - var existing = entityManager - .from(ExpensePeriodJpa.class) - .fieldEq("expense.id", command.id()) - .fieldEq("budget.user.username", authenticationFacade.authenticated()) - .fieldNull("budget.until") - .singleResult() - .getOrThrow(() -> new RuntimeException("Unable to find expense")); - - log.info("[{}] - Processing expense update event", existing.getId()); - - existing.setLowerBound(command.amount().subtract(BigDecimal.valueOf(.01))); - existing.setUpperBound(command.amount()); - entityManager.persist(existing); - } + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public UpdateExpenseHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(UpdateExpenseCommand command) { + var existing = entityManager + .from(ExpensePeriodJpa.class) + .fieldEq("expense.id", command.id()) + .fieldEq("budget.user.username", authenticationFacade.authenticated()) + .fieldNull("budget.until") + .singleResult() + .getOrThrow(() -> new RuntimeException("Unable to find expense")); + + log.info("[{}] - Processing expense update event", existing.getId()); + + existing.setLowerBound(command.amount().subtract(BigDecimal.valueOf(.01))); + existing.setUpperBound(command.amount()); + entityManager.persist(existing); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryFilterCommand.java index 004ba85f..1075fa91 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryFilterCommand.java @@ -3,42 +3,43 @@ import com.jongsoft.finance.RequiresJpa; import com.jongsoft.finance.jpa.query.JpaFilterBuilder; import com.jongsoft.finance.providers.CategoryProvider; + import jakarta.inject.Singleton; @Singleton @RequiresJpa public class CategoryFilterCommand extends JpaFilterBuilder - implements CategoryProvider.FilterCommand { - - public CategoryFilterCommand() { - query().fieldEq("archived", false); - orderAscending = true; - orderBy = "label"; - } - - @Override - public CategoryProvider.FilterCommand label(String label, boolean exact) { - if (exact) { - query().fieldEq("label", label); - } else { - query().fieldLike("label", label); + implements CategoryProvider.FilterCommand { + + public CategoryFilterCommand() { + query().fieldEq("archived", false); + orderAscending = true; + orderBy = "label"; + } + + @Override + public CategoryProvider.FilterCommand label(String label, boolean exact) { + if (exact) { + query().fieldEq("label", label); + } else { + query().fieldLike("label", label); + } + return this; + } + + @Override + public CategoryProvider.FilterCommand page(int page, int pageSize) { + limitRows = pageSize; + skipRows = pageSize * page; + return this; + } + + public void user(String username) { + query().fieldEq("user.username", username); + } + + @Override + public Class entityType() { + return CategoryJpa.class; } - return this; - } - - @Override - public CategoryProvider.FilterCommand page(int page, int pageSize) { - limitRows = pageSize; - skipRows = pageSize * page; - return this; - } - - public void user(String username) { - query().fieldEq("user.username", username); - } - - @Override - public Class entityType() { - return CategoryJpa.class; - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryJpa.java index eb0fc188..06833039 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryJpa.java @@ -2,45 +2,49 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.time.LocalDate; + import lombok.Builder; import lombok.Getter; + import org.hibernate.annotations.Formula; +import java.time.LocalDate; + @Entity @Getter @Table(name = "category") public class CategoryJpa extends EntityJpa { - private String label; - private String description; - private boolean archived; - - @ManyToOne - @JoinColumn(nullable = false, updatable = false) - private UserAccountJpa user; - - @Formula("(select max(tj.t_date) from transaction_journal tj where tj.category_id = id and" - + " tj.deleted is null)") - private LocalDate lastTransaction; - - @Builder - private CategoryJpa( - String label, - String description, - boolean archived, - UserAccountJpa user, - LocalDate lastTransaction) { - this.label = label; - this.description = description; - this.archived = archived; - this.user = user; - this.lastTransaction = lastTransaction; - } - - protected CategoryJpa() {} + private String label; + private String description; + private boolean archived; + + @ManyToOne + @JoinColumn(nullable = false, updatable = false) + private UserAccountJpa user; + + @Formula("(select max(tj.t_date) from transaction_journal tj where tj.category_id = id and" + + " tj.deleted is null)") + private LocalDate lastTransaction; + + @Builder + private CategoryJpa( + String label, + String description, + boolean archived, + UserAccountJpa user, + LocalDate lastTransaction) { + this.label = label; + this.description = description; + this.archived = archived; + this.user = user; + this.lastTransaction = lastTransaction; + } + + protected CategoryJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryProviderJpa.java index 7f7e9786..2fa8093e 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CategoryProviderJpa.java @@ -10,10 +10,13 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,80 +26,81 @@ @Named("categoryProvider") public class CategoryProviderJpa implements CategoryProvider { - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public CategoryProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Optional lookup(long id) { - logger.trace("Looking up category with id: {}", id); - - return entityManager - .from(CategoryJpa.class) - .fieldEq("id", id) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public Optional lookup(String label) { - logger.trace("Looking up category with label: {}", label); - - return entityManager - .from(CategoryJpa.class) - .fieldEq("label", label) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public ResultPage lookup(FilterCommand filterCommand) { - if (filterCommand instanceof CategoryFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager.from(delegate).paged().map(this::convert); + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + @Inject + public CategoryProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Optional lookup(long id) { + logger.trace("Looking up category with id: {}", id); + + return entityManager + .from(CategoryJpa.class) + .fieldEq("id", id) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); } - throw new IllegalStateException("Cannot execute non JPA filter command on CategoryProviderJpa"); - } - - @Override - public Sequence lookup() { - logger.trace("Looking up all categories."); - - return entityManager - .from(CategoryJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - protected Category convert(CategoryJpa source) { - if (source == null) { - return null; + @Override + public Optional lookup(String label) { + logger.trace("Looking up category with label: {}", label); + + return entityManager + .from(CategoryJpa.class) + .fieldEq("label", label) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); + } + + @Override + public ResultPage lookup(FilterCommand filterCommand) { + if (filterCommand instanceof CategoryFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + return entityManager.from(delegate).paged().map(this::convert); + } + + throw new IllegalStateException( + "Cannot execute non JPA filter command on CategoryProviderJpa"); + } + + @Override + public Sequence lookup() { + logger.trace("Looking up all categories."); + + return entityManager + .from(CategoryJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); } - return Category.builder() - .id(source.getId()) - .description(source.getDescription()) - .label(source.getLabel()) - .lastActivity(source.getLastTransaction()) - .delete(source.isArchived()) - .user(UserAccount.builder() - .username(new UserIdentifier(source.getUser().getUsername())) - .build()) - .build(); - } + protected Category convert(CategoryJpa source) { + if (source == null) { + return null; + } + + return Category.builder() + .id(source.getId()) + .description(source.getDescription()) + .label(source.getLabel()) + .lastActivity(source.getLastTransaction()) + .delete(source.isArchived()) + .user(UserAccount.builder() + .username(new UserIdentifier(source.getUser().getUsername())) + .build()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CreateCategoryHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CreateCategoryHandler.java index 3100d29e..27e1aea9 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CreateCategoryHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/CreateCategoryHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.category.CreateCategoryCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,24 +19,24 @@ @Transactional public class CreateCategoryHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CreateCategoryHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CreateCategoryHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateCategoryCommand command) { - log.info("[{}] - Processing create event for category", command.name()); + @Override + @BusinessEventListener + public void handle(CreateCategoryCommand command) { + log.info("[{}] - Processing create event for category", command.name()); - var entity = CategoryJpa.builder() - .label(command.name()) - .description(command.description()) - .user(entityManager.currentUser()) - .build(); + var entity = CategoryJpa.builder() + .label(command.name()) + .description(command.description()) + .user(entityManager.currentUser()) + .build(); - entityManager.persist(entity); - } + entityManager.persist(entity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/DeleteCategoryHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/DeleteCategoryHandler.java index 970837ce..f81bc233 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/DeleteCategoryHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/DeleteCategoryHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.category.DeleteCategoryCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,22 +19,22 @@ @Transactional public class DeleteCategoryHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public DeleteCategoryHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public DeleteCategoryHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(DeleteCategoryCommand command) { - log.info("[{}] - Processing remove event for category", command.id()); + @Override + @BusinessEventListener + public void handle(DeleteCategoryCommand command) { + log.info("[{}] - Processing remove event for category", command.id()); - entityManager - .update(CategoryJpa.class) - .set("archived", true) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(CategoryJpa.class) + .set("archived", true) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/RenameCategoryHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/RenameCategoryHandler.java index ea522fbc..f4162f2f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/RenameCategoryHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/category/RenameCategoryHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.category.RenameCategoryCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,23 +19,23 @@ @Transactional public class RenameCategoryHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public RenameCategoryHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RenameCategoryCommand command) { - log.info("[{}] - Processing rename event for category", command.id()); - - entityManager - .update(CategoryJpa.class) - .set("label", command.name()) - .set("description", command.description()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public RenameCategoryHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RenameCategoryCommand command) { + log.info("[{}] - Processing rename event for category", command.id()); + + entityManager + .update(CategoryJpa.class) + .set("label", command.name()) + .set("description", command.description()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/AttacheFileToContractHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/AttacheFileToContractHandler.java index 7b9fa24e..500a95c0 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/AttacheFileToContractHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/AttacheFileToContractHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.AttachFileToContractCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,22 +19,22 @@ @Transactional public class AttacheFileToContractHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public AttacheFileToContractHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public AttacheFileToContractHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(AttachFileToContractCommand command) { - log.info("[{}] - Processing contract upload event", command.id()); + @Override + @BusinessEventListener + public void handle(AttachFileToContractCommand command) { + log.info("[{}] - Processing contract upload event", command.id()); - entityManager - .update(ContractJpa.class) - .set("fileToken", command.fileCode()) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(ContractJpa.class) + .set("fileToken", command.fileCode()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ChangeContractHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ChangeContractHandler.java index 2b3b56d1..2c171da0 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ChangeContractHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ChangeContractHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.ChangeContractCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,25 +19,25 @@ @Transactional public class ChangeContractHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public ChangeContractHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeContractCommand command) { - log.info("[{}] - Processing contract changed event", command.id()); - - entityManager - .update(ContractJpa.class) - .set("name", command.name()) - .set("startDate", command.start()) - .set("endDate", command.end()) - .set("description", command.description()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public ChangeContractHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeContractCommand command) { + log.info("[{}] - Processing contract changed event", command.id()); + + entityManager + .update(ContractJpa.class) + .set("name", command.name()) + .set("startDate", command.start()) + .set("endDate", command.end()) + .set("description", command.description()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractJpa.java index 567aeffd..142f736d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractJpa.java @@ -3,58 +3,61 @@ import com.jongsoft.finance.jpa.account.AccountJpa; import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.Entity; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.time.LocalDate; + import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; + @Getter @Entity @Table(name = "contract") public class ContractJpa extends EntityJpa { - private String name; - private String description; - - private LocalDate startDate; - private LocalDate endDate; - - private String fileToken; - - @ManyToOne - private AccountJpa company; - - @ManyToOne - private UserAccountJpa user; - - private boolean warningActive; - private boolean archived; - - public ContractJpa() {} - - @Builder - protected ContractJpa( - Long id, - String name, - String description, - LocalDate startDate, - LocalDate endDate, - String fileToken, - AccountJpa company, - UserAccountJpa user, - boolean warningActive, - boolean archived) { - super(id); - this.name = name; - this.description = description; - this.startDate = startDate; - this.endDate = endDate; - this.fileToken = fileToken; - this.company = company; - this.user = user; - this.warningActive = warningActive; - this.archived = archived; - } + private String name; + private String description; + + private LocalDate startDate; + private LocalDate endDate; + + private String fileToken; + + @ManyToOne + private AccountJpa company; + + @ManyToOne + private UserAccountJpa user; + + private boolean warningActive; + private boolean archived; + + public ContractJpa() {} + + @Builder + protected ContractJpa( + Long id, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + String fileToken, + AccountJpa company, + UserAccountJpa user, + boolean warningActive, + boolean archived) { + super(id); + this.name = name; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + this.fileToken = fileToken; + this.company = company; + this.user = user; + this.warningActive = warningActive; + this.archived = archived; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractProviderJpa.java index d5bad97b..deb8db72 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/ContractProviderJpa.java @@ -10,9 +10,12 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.collection.support.Collections; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -21,89 +24,89 @@ @Singleton public class ContractProviderJpa implements ContractProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public ContractProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Sequence lookup() { - log.trace("Listing all contracts for user."); - - return entityManager - .from(ContractJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .stream() - .map(this::convert) - .collect(Collections.collector(com.jongsoft.lang.Collections::List)); - } - - @Override - public Optional lookup(long id) { - log.trace("Contract lookup by id {}.", id); - - return entityManager - .from(ContractJpa.class) - .fieldEq("id", id) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public Optional lookup(String name) { - log.trace("Contract lookup by name {}.", name); - - return entityManager - .from(ContractJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .singleResult() - .map(this::convert); - } - - @Override - public Sequence search(String partialName) { - log.trace("Contract lookup by partial name '{}'.", partialName); - - return entityManager - .from(ContractJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .fieldLike("name", partialName) - .stream() - .map(this::convert) - .collect(Collections.collector(com.jongsoft.lang.Collections::List)); - } - - protected Contract convert(ContractJpa source) { - if (source == null) { - return null; + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + @Inject + public ContractProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Sequence lookup() { + log.trace("Listing all contracts for user."); + + return entityManager + .from(ContractJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .stream() + .map(this::convert) + .collect(Collections.collector(com.jongsoft.lang.Collections::List)); + } + + @Override + public Optional lookup(long id) { + log.trace("Contract lookup by id {}.", id); + + return entityManager + .from(ContractJpa.class) + .fieldEq("id", id) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); } - return Contract.builder() - .id(source.getId()) - .name(source.getName()) - .uploaded(source.getFileToken() != null) - .startDate(source.getStartDate()) - .endDate(source.getEndDate()) - .company(Account.builder() - .id(source.getCompany().getId()) - .user(new UserIdentifier(source.getUser().getUsername())) - .name(source.getCompany().getName()) - .type(source.getCompany().getType().getLabel()) - .imageFileToken(source.getCompany().getImageFileToken()) - .build()) - .notifyBeforeEnd(source.isWarningActive()) - .fileToken(source.getFileToken()) - .description(source.getDescription()) - .terminated(source.isArchived()) - .build(); - } + @Override + public Optional lookup(String name) { + log.trace("Contract lookup by name {}.", name); + + return entityManager + .from(ContractJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .singleResult() + .map(this::convert); + } + + @Override + public Sequence search(String partialName) { + log.trace("Contract lookup by partial name '{}'.", partialName); + + return entityManager + .from(ContractJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .fieldLike("name", partialName) + .stream() + .map(this::convert) + .collect(Collections.collector(com.jongsoft.lang.Collections::List)); + } + + protected Contract convert(ContractJpa source) { + if (source == null) { + return null; + } + + return Contract.builder() + .id(source.getId()) + .name(source.getName()) + .uploaded(source.getFileToken() != null) + .startDate(source.getStartDate()) + .endDate(source.getEndDate()) + .company(Account.builder() + .id(source.getCompany().getId()) + .user(new UserIdentifier(source.getUser().getUsername())) + .name(source.getCompany().getName()) + .type(source.getCompany().getType().getLabel()) + .imageFileToken(source.getCompany().getImageFileToken()) + .build()) + .notifyBeforeEnd(source.isWarningActive()) + .fileToken(source.getFileToken()) + .description(source.getDescription()) + .terminated(source.isArchived()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/CreateContractHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/CreateContractHandler.java index df7693c9..c7ea9461 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/CreateContractHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/CreateContractHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.CreateContractCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,29 +20,29 @@ @Transactional public class CreateContractHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CreateContractHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CreateContractHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateContractCommand command) { - log.info("[{}] - Processing contract create event", command.name()); + @Override + @BusinessEventListener + public void handle(CreateContractCommand command) { + log.info("[{}] - Processing contract create event", command.name()); - var company = entityManager.getById(AccountJpa.class, command.companyId()); + var company = entityManager.getById(AccountJpa.class, command.companyId()); - var contract = ContractJpa.builder() - .name(command.name()) - .startDate(command.start()) - .endDate(command.end()) - .description(command.description()) - .company(company) - .user(company.getUser()) - .build(); + var contract = ContractJpa.builder() + .name(command.name()) + .startDate(command.start()) + .endDate(command.end()) + .description(command.description()) + .company(company) + .user(company.getUser()) + .build(); - entityManager.persist(contract); - } + entityManager.persist(contract); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/TerminateContractHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/TerminateContractHandler.java index 25ea1c78..30f0e39a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/TerminateContractHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/TerminateContractHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.TerminateContractCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,22 +19,22 @@ @Transactional public class TerminateContractHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public TerminateContractHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public TerminateContractHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(TerminateContractCommand command) { - log.info("[{}] - Processing contract terminate event", command.id()); + @Override + @BusinessEventListener + public void handle(TerminateContractCommand command) { + log.info("[{}] - Processing contract terminate event", command.id()); - entityManager - .update(ContractJpa.class) - .set("archived", true) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(ContractJpa.class) + .set("archived", true) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/WarnBeforeExpiryHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/WarnBeforeExpiryHandler.java index cf55ec7f..f1d1bd79 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/WarnBeforeExpiryHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/contract/WarnBeforeExpiryHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.contract.WarnBeforeExpiryCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,23 +19,23 @@ @Transactional public class WarnBeforeExpiryHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public WarnBeforeExpiryHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(WarnBeforeExpiryCommand command) { - log.info("[{}] - Processing contract warning event", command.id()); - - entityManager - .update(ContractJpa.class) - .set("warningActive", true) - .set("endDate", command.endDate()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public WarnBeforeExpiryHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(WarnBeforeExpiryCommand command) { + log.info("[{}] - Processing contract warning event", command.id()); + + entityManager + .update(ContractJpa.class) + .set("warningActive", true) + .set("endDate", command.endDate()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/FilterCommandJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/FilterCommandJpa.java index e4c2cb71..4304b47a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/FilterCommandJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/FilterCommandJpa.java @@ -1,57 +1,57 @@ package com.jongsoft.finance.jpa.core; import com.jongsoft.finance.jpa.FilterDelegate; + import java.util.HashMap; import java.util.Map; import java.util.Objects; public abstract class FilterCommandJpa implements FilterDelegate { - private final Map parameters; - private final Map filters; - - protected FilterCommandJpa() { - this.parameters = new HashMap<>(); - this.filters = new HashMap<>(); - } + private final Map parameters; + private final Map filters; - @Override - public String generateHql() { - var hqlBuilder = new StringBuilder(); + protected FilterCommandJpa() { + this.parameters = new HashMap<>(); + this.filters = new HashMap<>(); + } - filters - .values() - .forEach(hqlFilter -> hqlBuilder.append(System.lineSeparator()).append(hqlFilter)); + @Override + public String generateHql() { + var hqlBuilder = new StringBuilder(); - return hqlBuilder.toString(); - } + filters.values() + .forEach(hqlFilter -> hqlBuilder.append(System.lineSeparator()).append(hqlFilter)); - public Map getParameters() { - return parameters; - } + return hqlBuilder.toString(); + } - @Deprecated - protected abstract String fromHql(); + public Map getParameters() { + return parameters; + } - protected void hql(String key, String hql) { - filters.put(key, hql); - } + @Deprecated + protected abstract String fromHql(); - protected void parameter(String key, Object value) { - parameters.put(key, value); - } + protected void hql(String key, String hql) { + filters.put(key, hql); + } - @Override - public boolean equals(Object o) { - if (o instanceof FilterCommandJpa other) { - return parameters.equals(other.parameters) && filters.equals(other.filters); + protected void parameter(String key, Object value) { + parameters.put(key, value); } - return false; - } + @Override + public boolean equals(Object o) { + if (o instanceof FilterCommandJpa other) { + return parameters.equals(other.parameters) && filters.equals(other.filters); + } + + return false; + } - @Override - public int hashCode() { - return Objects.hash(parameters, filters); - } + @Override + public int hashCode() { + return Objects.hash(parameters, filters); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/SettingProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/SettingProviderJpa.java index cf265b07..6451296d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/SettingProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/SettingProviderJpa.java @@ -9,9 +9,12 @@ import com.jongsoft.finance.providers.SettingProvider; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Singleton; import jakarta.transaction.Transactional; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -20,47 +23,47 @@ @Singleton public class SettingProviderJpa implements SettingProvider { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - public SettingProviderJpa(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + public SettingProviderJpa(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - public Sequence lookup() { - log.trace("Setting listing"); + @Override + public Sequence lookup() { + log.trace("Setting listing"); - return entityManager.from(SettingJpa.class).stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } + return entityManager.from(SettingJpa.class).stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } - @Override - public Optional lookup(String name) { - log.trace("Setting lookup by name {}", name); + @Override + public Optional lookup(String name) { + log.trace("Setting lookup by name {}", name); - return entityManager - .from(SettingJpa.class) - .fieldEq("name", name) - .singleResult() - .map(this::convert); - } + return entityManager + .from(SettingJpa.class) + .fieldEq("name", name) + .singleResult() + .map(this::convert); + } - @Transactional - @BusinessEventListener - public void handleSettingUpdated(SettingUpdatedEvent event) { - entityManager - .update(SettingJpa.class) - .set("value", event.value()) - .fieldEq("name", event.setting()) - .execute(); - } + @Transactional + @BusinessEventListener + public void handleSettingUpdated(SettingUpdatedEvent event) { + entityManager + .update(SettingJpa.class) + .set("value", event.value()) + .fieldEq("name", event.setting()) + .execute(); + } - private Setting convert(SettingJpa source) { - return Setting.builder() - .name(source.getName()) - .type(source.getType()) - .value(source.getValue()) - .build(); - } + private Setting convert(SettingJpa source) { + return Setting.builder() + .name(source.getName()) + .type(source.getType()) + .value(source.getValue()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/AuditedJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/AuditedJpa.java index 7355c7c8..b9447d1d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/AuditedJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/AuditedJpa.java @@ -4,36 +4,38 @@ import jakarta.persistence.MappedSuperclass; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; -import java.util.Date; + import lombok.Getter; +import java.util.Date; + @Getter @MappedSuperclass public abstract class AuditedJpa extends EntityJpa { - @Column(updatable = false) - private Date created; + @Column(updatable = false) + private Date created; - private Date updated; - private Date deleted; + private Date updated; + private Date deleted; - public AuditedJpa() {} + public AuditedJpa() {} - protected AuditedJpa(Long id, Date created, Date updated, Date deleted) { - super(id); + protected AuditedJpa(Long id, Date created, Date updated, Date deleted) { + super(id); - this.created = created; - this.updated = updated; - this.deleted = deleted; - } - - @PreUpdate - @PrePersist - void initialize() { - if (created == null) { - created = new Date(); + this.created = created; + this.updated = updated; + this.deleted = deleted; } - updated = new Date(); - } + @PreUpdate + @PrePersist + void initialize() { + if (created == null) { + created = new Date(); + } + + updated = new Date(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/EntityJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/EntityJpa.java index c3403860..017099c9 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/EntityJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/EntityJpa.java @@ -5,22 +5,23 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; + import lombok.Getter; @Getter @MappedSuperclass public abstract class EntityJpa { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false, updatable = false) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, updatable = false) + private Long id; - public EntityJpa() { - // left blank intentionally - } + public EntityJpa() { + // left blank intentionally + } - protected EntityJpa(Long id) { - this.id = id; - } + protected EntityJpa(Long id) { + this.id = id; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/SettingJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/SettingJpa.java index 99166b84..0babacdc 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/SettingJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/core/entity/SettingJpa.java @@ -1,7 +1,9 @@ package com.jongsoft.finance.jpa.core.entity; import com.jongsoft.finance.core.SettingType; + import jakarta.persistence.*; + import lombok.Builder; import lombok.Getter; @@ -10,24 +12,24 @@ @Table(name = "setting") public class SettingJpa extends EntityJpa { - private String name; + private String name; - @Enumerated(EnumType.STRING) - private SettingType type; + @Enumerated(EnumType.STRING) + private SettingType type; - @Column(name = "setting_val") - private String value; + @Column(name = "setting_val") + private String value; - public SettingJpa() { - super(); - } + public SettingJpa() { + super(); + } - @Builder - protected SettingJpa(Long id, String name, SettingType type, String value) { - super(id); + @Builder + protected SettingJpa(Long id, String name, SettingType type, String value) { + super(id); - this.name = name; - this.type = type; - this.value = value; - } + this.name = name; + this.type = type; + this.value = value; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/ChangeCurrencyPropertyHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/ChangeCurrencyPropertyHandler.java index d8b4ffe8..cc9d1f9a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/ChangeCurrencyPropertyHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/ChangeCurrencyPropertyHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.currency.ChangeCurrencyPropertyCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,26 +18,26 @@ @RequiresJpa @Transactional public class ChangeCurrencyPropertyHandler - implements CommandHandler> { + implements CommandHandler> { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public ChangeCurrencyPropertyHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public ChangeCurrencyPropertyHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(ChangeCurrencyPropertyCommand command) { - log.trace("[{}] - Processing currency property {} event", command.code(), command.type()); + @Override + @BusinessEventListener + public void handle(ChangeCurrencyPropertyCommand command) { + log.trace("[{}] - Processing currency property {} event", command.code(), command.type()); - var currency = entityManager.update(CurrencyJpa.class); - switch (command.type()) { - case ENABLED -> currency.set("enabled", command.value()); - case DECIMAL_PLACES -> currency.set("decimalPlaces", command.value()); + var currency = entityManager.update(CurrencyJpa.class); + switch (command.type()) { + case ENABLED -> currency.set("enabled", command.value()); + case DECIMAL_PLACES -> currency.set("decimalPlaces", command.value()); + } + ; + currency.fieldEq("code", command.code()).execute(); } - ; - currency.fieldEq("code", command.code()).execute(); - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CreateCurrencyHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CreateCurrencyHandler.java index 3934912f..8fff99f3 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CreateCurrencyHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CreateCurrencyHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.currency.CreateCurrencyCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,26 +19,26 @@ @Transactional public class CreateCurrencyHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CreateCurrencyHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CreateCurrencyHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateCurrencyCommand command) { - log.info("[{}] - Processing currency create event", command.isoCode()); + @Override + @BusinessEventListener + public void handle(CreateCurrencyCommand command) { + log.info("[{}] - Processing currency create event", command.isoCode()); - var entity = CurrencyJpa.builder() - .name(command.name()) - .code(command.isoCode()) - .symbol(command.symbol()) - .enabled(true) - .decimalPlaces(2) - .build(); + var entity = CurrencyJpa.builder() + .name(command.name()) + .code(command.isoCode()) + .symbol(command.symbol()) + .enabled(true) + .decimalPlaces(2) + .build(); - entityManager.persist(entity); - } + entityManager.persist(entity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyJpa.java index b531b1e9..6be5c015 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyJpa.java @@ -1,8 +1,10 @@ package com.jongsoft.finance.jpa.currency; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.Entity; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -11,35 +13,35 @@ @Table(name = "currency") public class CurrencyJpa extends EntityJpa { - private String name; - private char symbol; - private String code; - - private int decimalPlaces; - private boolean enabled; - - private boolean archived; - - public CurrencyJpa() { - super(); - } - - @Builder - protected CurrencyJpa( - Long id, - String name, - char symbol, - String code, - int decimalPlaces, - boolean enabled, - boolean archived) { - super(id); - - this.name = name; - this.symbol = symbol; - this.code = code; - this.decimalPlaces = decimalPlaces; - this.enabled = enabled; - this.archived = archived; - } + private String name; + private char symbol; + private String code; + + private int decimalPlaces; + private boolean enabled; + + private boolean archived; + + public CurrencyJpa() { + super(); + } + + @Builder + protected CurrencyJpa( + Long id, + String name, + char symbol, + String code, + int decimalPlaces, + boolean enabled, + boolean archived) { + super(id); + + this.name = name; + this.symbol = symbol; + this.code = code; + this.decimalPlaces = decimalPlaces; + this.enabled = enabled; + this.archived = archived; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyProviderJpa.java index eb5298a5..a301e55a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/CurrencyProviderJpa.java @@ -7,9 +7,12 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.collection.support.Collections; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -18,56 +21,56 @@ @RequiresJpa public class CurrencyProviderJpa implements CurrencyProvider { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CurrencyProviderJpa(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - public Optional lookup(long id) { - log.trace("Currency lookup by id {}.", id); + @Inject + public CurrencyProviderJpa(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - return entityManager - .from(CurrencyJpa.class) - .fieldEq("id", id) - .singleResult() - .map(this::convert); - } + public Optional lookup(long id) { + log.trace("Currency lookup by id {}.", id); - @Override - public Optional lookup(String code) { - log.trace("Currency lookup by code {}.", code); + return entityManager + .from(CurrencyJpa.class) + .fieldEq("id", id) + .singleResult() + .map(this::convert); + } - return entityManager - .from(CurrencyJpa.class) - .fieldEq("code", code) - .fieldEq("archived", false) - .singleResult() - .map(this::convert); - } + @Override + public Optional lookup(String code) { + log.trace("Currency lookup by code {}.", code); - @Override - public Sequence lookup() { - log.trace("Listing all currencies in the system."); + return entityManager + .from(CurrencyJpa.class) + .fieldEq("code", code) + .fieldEq("archived", false) + .singleResult() + .map(this::convert); + } - return entityManager.from(CurrencyJpa.class).fieldEq("archived", false).stream() - .map(this::convert) - .collect(Collections.collector(com.jongsoft.lang.Collections::List)); - } + @Override + public Sequence lookup() { + log.trace("Listing all currencies in the system."); - protected Currency convert(CurrencyJpa source) { - if (source == null) { - return null; + return entityManager.from(CurrencyJpa.class).fieldEq("archived", false).stream() + .map(this::convert) + .collect(Collections.collector(com.jongsoft.lang.Collections::List)); } - return Currency.builder() - .id(source.getId()) - .name(source.getName()) - .code(source.getCode()) - .symbol(source.getSymbol()) - .decimalPlaces(source.getDecimalPlaces()) - .enabled(source.isEnabled()) - .build(); - } + protected Currency convert(CurrencyJpa source) { + if (source == null) { + return null; + } + + return Currency.builder() + .id(source.getId()) + .name(source.getName()) + .code(source.getCode()) + .symbol(source.getSymbol()) + .decimalPlaces(source.getDecimalPlaces()) + .enabled(source.isEnabled()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/RenameCurrencyHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/RenameCurrencyHandler.java index ed5e7277..026e78a8 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/RenameCurrencyHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/currency/RenameCurrencyHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.currency.RenameCurrencyCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,24 +19,24 @@ @Transactional public class RenameCurrencyHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public RenameCurrencyHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RenameCurrencyCommand command) { - log.info("[{}] - Processing currency rename event", command.id()); - - entityManager - .update(CurrencyJpa.class) - .set("name", command.name()) - .set("code", command.isoCode()) - .set("symbol", command.symbol()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public RenameCurrencyHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RenameCurrencyCommand command) { + log.info("[{}] - Processing currency rename event", command.id()); + + entityManager + .update(CurrencyJpa.class) + .set("name", command.name()) + .set("code", command.isoCode()) + .set("symbol", command.symbol()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CompleteImporterJobHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CompleteImporterJobHandler.java index 5e05f263..533d192e 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CompleteImporterJobHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CompleteImporterJobHandler.java @@ -6,34 +6,38 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.importer.CompleteImportJobCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.util.Date; + import lombok.extern.slf4j.Slf4j; +import java.util.Date; + @Slf4j @Singleton @RequiresJpa @Transactional public class CompleteImporterJobHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CompleteImporterJobHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CompleteImporterJobHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CompleteImportJobCommand command) { - log.info("[{}] - Processing import finished event", command.id()); + @Override + @BusinessEventListener + public void handle(CompleteImportJobCommand command) { + log.info("[{}] - Processing import finished event", command.id()); - entityManager - .update(ImportJpa.class) - .set("finished", new Date()) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(ImportJpa.class) + .set("finished", new Date()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateConfigurationHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateConfigurationHandler.java index 5e42457f..3feb9855 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateConfigurationHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateConfigurationHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.importer.CreateConfigurationCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,25 +20,25 @@ @Transactional public class CreateConfigurationHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CreateConfigurationHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CreateConfigurationHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateConfigurationCommand command) { - log.info("[{}] - Processing CSV configuration create event", command.name()); + @Override + @BusinessEventListener + public void handle(CreateConfigurationCommand command) { + log.info("[{}] - Processing CSV configuration create event", command.name()); - var entity = ImportConfig.builder() - .fileCode(command.fileCode()) - .name(command.name()) - .type(command.type()) - .user(entityManager.currentUser()) - .build(); + var entity = ImportConfig.builder() + .fileCode(command.fileCode()) + .name(command.name()) + .type(command.type()) + .user(entityManager.currentUser()) + .build(); - entityManager.persist(entity); - } + entityManager.persist(entity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateImportJobHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateImportJobHandler.java index c91855d3..ec26dc3a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateImportJobHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/CreateImportJobHandler.java @@ -7,9 +7,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.importer.CreateImportJobCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,32 +20,32 @@ @Transactional public class CreateImportJobHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public CreateImportJobHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(CreateImportJobCommand command) { - log.info("[{}] - Processing import create event", command.slug()); - - var configJpa = entityManager - .from(ImportConfig.class) - .fieldEq("id", command.configId()) - .singleResult() - .getOrThrow(() -> StatusException.notFound( - "Could not find the configuration for id " + command.configId())); - - var importJpa = ImportJpa.builder() - .slug(command.slug()) - .config(configJpa) - .user(configJpa.getUser()) - .fileCode(command.fileCode()) - .build(); - - entityManager.persist(importJpa); - } + private final ReactiveEntityManager entityManager; + + @Inject + public CreateImportJobHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(CreateImportJobCommand command) { + log.info("[{}] - Processing import create event", command.slug()); + + var configJpa = entityManager + .from(ImportConfig.class) + .fieldEq("id", command.configId()) + .singleResult() + .getOrThrow(() -> StatusException.notFound( + "Could not find the configuration for id " + command.configId())); + + var importJpa = ImportJpa.builder() + .slug(command.slug()) + .config(configJpa) + .user(configJpa.getUser()) + .fileCode(command.fileCode()) + .build(); + + entityManager.persist(importJpa); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/DeleteImportJobHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/DeleteImportJobHandler.java index a445ca94..026282ca 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/DeleteImportJobHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/DeleteImportJobHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.importer.DeleteImportJobCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,22 +18,22 @@ @Transactional public class DeleteImportJobHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public DeleteImportJobHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public DeleteImportJobHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(DeleteImportJobCommand command) { - log.info("[{}] - Processing import deleted event", command.id()); + @Override + @BusinessEventListener + public void handle(DeleteImportJobCommand command) { + log.info("[{}] - Processing import deleted event", command.id()); - entityManager - .update(ImportJpa.class) - .set("archived", true) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(ImportJpa.class) + .set("archived", true) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportConfigurationProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportConfigurationProviderJpa.java index 922a8843..e4b0971d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportConfigurationProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportConfigurationProviderJpa.java @@ -10,8 +10,11 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -20,53 +23,53 @@ @Singleton public class ImportConfigurationProviderJpa implements ImportConfigurationProvider { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - public ImportConfigurationProviderJpa( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; - @Override - public Optional lookup(String name) { - log.trace("Import configuration lookup by name {}", name); + public ImportConfigurationProviderJpa( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } - return entityManager - .from(ImportConfig.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } + @Override + public Optional lookup(String name) { + log.trace("Import configuration lookup by name {}", name); - @Override - public Sequence lookup() { - log.trace("CSVConfiguration listing"); + return entityManager + .from(ImportConfig.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); + } - return entityManager - .from(ImportConfig.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } + @Override + public Sequence lookup() { + log.trace("CSVConfiguration listing"); - private BatchImportConfig convert(ImportConfig source) { - if (source == null) { - return null; + return entityManager + .from(ImportConfig.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); } - return BatchImportConfig.builder() - .id(source.getId()) - .name(source.getName()) - .type(source.getType()) - .fileCode(source.getFileCode()) - .user(UserAccount.builder() - .id(source.getUser().getId()) - .username(new UserIdentifier(source.getUser().getUsername())) - .build()) - .build(); - } + private BatchImportConfig convert(ImportConfig source) { + if (source == null) { + return null; + } + + return BatchImportConfig.builder() + .id(source.getId()) + .name(source.getName()) + .type(source.getType()) + .fileCode(source.getFileCode()) + .user(UserAccount.builder() + .id(source.getUser().getId()) + .username(new UserIdentifier(source.getUser().getUsername())) + .build()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportProviderJpa.java index 507b7a43..5d53cb1c 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/ImportProviderJpa.java @@ -9,9 +9,12 @@ import com.jongsoft.finance.providers.ImportProvider; import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -20,60 +23,60 @@ @RequiresJpa public class ImportProviderJpa implements ImportProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public ImportProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; - @Override - public Optional lookup(String slug) { - log.trace("Importer lookup by slug: {}", slug); + @Inject + public ImportProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } - return entityManager - .from(ImportJpa.class) - .fieldEq("slug", slug) - .fieldEq("archived", false) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } + @Override + public Optional lookup(String slug) { + log.trace("Importer lookup by slug: {}", slug); - @Override - public ResultPage lookup(FilterCommand filter) { - log.trace("Importer lookup by filter: {}", filter); + return entityManager + .from(ImportJpa.class) + .fieldEq("slug", slug) + .fieldEq("archived", false) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); + } - return entityManager - .from(ImportJpa.class) - .fieldEq("archived", false) - .fieldEq("user.username", authenticationFacade.authenticated()) - .orderBy("created", false) - .skip(filter.page() * filter.pageSize()) - .limit(filter.pageSize()) - .paged() - .map(this::convert); - } + @Override + public ResultPage lookup(FilterCommand filter) { + log.trace("Importer lookup by filter: {}", filter); - protected BatchImport convert(ImportJpa source) { - if (source == null) { - return null; + return entityManager + .from(ImportJpa.class) + .fieldEq("archived", false) + .fieldEq("user.username", authenticationFacade.authenticated()) + .orderBy("created", false) + .skip(filter.page() * filter.pageSize()) + .limit(filter.pageSize()) + .paged() + .map(this::convert); } - return BatchImport.builder() - .id(source.getId()) - .created(source.getCreated()) - .fileCode(source.getFileCode()) - .slug(source.getSlug()) - .finished(source.getFinished()) - .config(BatchImportConfig.builder() - .type(source.getConfig().getType()) - .name(source.getConfig().getName()) - .fileCode(source.getConfig().getFileCode()) - .build()) - .build(); - } + protected BatchImport convert(ImportJpa source) { + if (source == null) { + return null; + } + + return BatchImport.builder() + .id(source.getId()) + .created(source.getCreated()) + .fileCode(source.getFileCode()) + .slug(source.getSlug()) + .finished(source.getFinished()) + .config(BatchImportConfig.builder() + .type(source.getConfig().getType()) + .name(source.getConfig().getName()) + .fileCode(source.getConfig().getFileCode()) + .build()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportConfig.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportConfig.java index 30766e6b..2ef51d03 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportConfig.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportConfig.java @@ -2,11 +2,13 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -15,25 +17,25 @@ @Table(name = "import_config") public class ImportConfig extends EntityJpa { - private String name; + private String name; - @Column - private String fileCode; + @Column + private String fileCode; - @Column - private String type; + @Column + private String type; - @ManyToOne - @JoinColumn - private UserAccountJpa user; + @ManyToOne + @JoinColumn + private UserAccountJpa user; - @Builder - private ImportConfig(String name, String fileCode, String type, UserAccountJpa user) { - this.name = name; - this.fileCode = fileCode; - this.user = user; - this.type = type; - } + @Builder + private ImportConfig(String name, String fileCode, String type, UserAccountJpa user) { + this.name = name; + this.fileCode = fileCode; + this.user = user; + this.type = type; + } - protected ImportConfig() {} + protected ImportConfig() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportJpa.java index 6b2f0ed5..d5697de5 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/importer/entity/ImportJpa.java @@ -3,62 +3,65 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.transaction.TransactionJournal; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; -import java.util.Date; -import java.util.List; + import lombok.Builder; import lombok.Getter; +import java.util.Date; +import java.util.List; + @Entity @Getter @Table(name = "import") public class ImportJpa extends EntityJpa { - private Date created; - private Date finished; - private String slug; - private boolean archived; + private Date created; + private Date finished; + private String slug; + private boolean archived; - @Column - private String fileCode; + @Column + private String fileCode; - @ManyToOne - @JoinColumn - private ImportConfig config; + @ManyToOne + @JoinColumn + private ImportConfig config; - @ManyToOne - @JoinColumn - private UserAccountJpa user; + @ManyToOne + @JoinColumn + private UserAccountJpa user; - @OneToMany(mappedBy = "batchImport") - private List transactions; + @OneToMany(mappedBy = "batchImport") + private List transactions; - @Builder - private ImportJpa( - Date created, - Date finished, - String slug, - String fileCode, - ImportConfig config, - UserAccountJpa user, - boolean archived, - List transactions) { - this.created = created; - this.finished = finished; - this.slug = slug; - this.fileCode = fileCode; - this.config = config; - this.user = user; - this.archived = archived; - this.transactions = transactions; - } + @Builder + private ImportJpa( + Date created, + Date finished, + String slug, + String fileCode, + ImportConfig config, + UserAccountJpa user, + boolean archived, + List transactions) { + this.created = created; + this.finished = finished; + this.slug = slug; + this.fileCode = fileCode; + this.config = config; + this.user = user; + this.archived = archived; + this.transactions = transactions; + } - protected ImportJpa() {} + protected ImportJpa() {} - @PrePersist - void initialize() { - if (created == null) { - created = new Date(); + @PrePersist + void initialize() { + if (created == null) { + created = new Date(); + } } - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobJpa.java index 2bd9bf8c..98d56d0f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobJpa.java @@ -1,7 +1,9 @@ package com.jongsoft.finance.jpa.insight; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; + import lombok.Data; @Data @@ -9,15 +11,15 @@ @Table(name = "analyze_job") public class AnalyzeJobJpa { - @Id - private String id; + @Id + private String id; - @Column(name = "year_month_found") - private String yearMonth; + @Column(name = "year_month_found") + private String yearMonth; - @ManyToOne - private UserAccountJpa user; + @ManyToOne + private UserAccountJpa user; - private boolean completed; - private boolean failed; + private boolean completed; + private boolean failed; } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpa.java index b7fcda2f..fb50e9ac 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpa.java @@ -9,84 +9,87 @@ import com.jongsoft.finance.messaging.commands.insight.CreateAnalyzeJob; import com.jongsoft.finance.messaging.commands.insight.FailAnalyzeJob; import com.jongsoft.finance.providers.AnalyzeJobProvider; + import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.time.YearMonth; import java.util.Optional; import java.util.UUID; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton class AnalyzeJobProviderJpa implements AnalyzeJobProvider { - private final ReactiveEntityManager entityManager; - - public AnalyzeJobProviderJpa(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - public Optional first() { - return entityManager - .from(AnalyzeJobJpa.class) - .fieldEq("completed", false) - .fieldEq("failed", false) - .orderBy("yearMonth", true) - .limit(1) - .singleResult() - .map(this::convert) - .map(Optional::ofNullable) - .getOrSupply(Optional::empty); - } - - @BusinessEventListener - public void createAnalyzeJob(CreateAnalyzeJob command) { - log.info("Creating analyze job for month {}.", command.month()); - - var entity = new AnalyzeJobJpa(); - entity.setId(UUID.randomUUID().toString()); - entity.setUser(entityManager - .from(UserAccountJpa.class) - .fieldEq("username", command.user().email()) - .singleResult() - .get()); - entity.setYearMonth(command.month().toString()); - - entityManager.getEntityManager().persist(entity); - } - - @BusinessEventListener - public void completeAnalyzeJob(CompleteAnalyzeJob command) { - log.info("Completing analyze job for month {}.", command.month()); - - entityManager - .update(AnalyzeJobJpa.class) - .fieldEq("yearMonth", command.month().toString()) - .fieldEq("completed", false) - .fieldEq("user.username", command.user().email()) - .set("completed", true) - .execute(); - } - - @BusinessEventListener - public void completeAnalyzeJob(FailAnalyzeJob command) { - log.info("Failing analyze job for month {}.", command.month()); - - entityManager - .update(AnalyzeJobJpa.class) - .fieldEq("yearMonth", command.month().toString()) - .fieldEq("completed", false) - .fieldEq("user.username", command.user().email()) - .set("failed", true) - .execute(); - } - - private AnalyzeJob convert(AnalyzeJobJpa entity) { - return AnalyzeJob.builder() - .jobId(entity.getId()) - .month(YearMonth.parse(entity.getYearMonth())) - .user(new UserIdentifier(entity.getUser().getUsername())) - .completed(entity.isCompleted()) - .build(); - } + private final ReactiveEntityManager entityManager; + + public AnalyzeJobProviderJpa(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional first() { + return entityManager + .from(AnalyzeJobJpa.class) + .fieldEq("completed", false) + .fieldEq("failed", false) + .orderBy("yearMonth", true) + .limit(1) + .singleResult() + .map(this::convert) + .map(Optional::ofNullable) + .getOrSupply(Optional::empty); + } + + @BusinessEventListener + public void createAnalyzeJob(CreateAnalyzeJob command) { + log.info("Creating analyze job for month {}.", command.month()); + + var entity = new AnalyzeJobJpa(); + entity.setId(UUID.randomUUID().toString()); + entity.setUser(entityManager + .from(UserAccountJpa.class) + .fieldEq("username", command.user().email()) + .singleResult() + .get()); + entity.setYearMonth(command.month().toString()); + + entityManager.getEntityManager().persist(entity); + } + + @BusinessEventListener + public void completeAnalyzeJob(CompleteAnalyzeJob command) { + log.info("Completing analyze job for month {}.", command.month()); + + entityManager + .update(AnalyzeJobJpa.class) + .fieldEq("yearMonth", command.month().toString()) + .fieldEq("completed", false) + .fieldEq("user.username", command.user().email()) + .set("completed", true) + .execute(); + } + + @BusinessEventListener + public void completeAnalyzeJob(FailAnalyzeJob command) { + log.info("Failing analyze job for month {}.", command.month()); + + entityManager + .update(AnalyzeJobJpa.class) + .fieldEq("yearMonth", command.month().toString()) + .fieldEq("completed", false) + .fieldEq("user.username", command.user().email()) + .set("failed", true) + .execute(); + } + + private AnalyzeJob convert(AnalyzeJobJpa entity) { + return AnalyzeJob.builder() + .jobId(entity.getId()) + .month(YearMonth.parse(entity.getYearMonth())) + .user(new UserIdentifier(entity.getUser().getUsername())) + .completed(entity.isCompleted()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightFilterCommand.java index 4f29eb29..6c42425d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightFilterCommand.java @@ -2,45 +2,46 @@ import com.jongsoft.finance.jpa.query.JpaFilterBuilder; import com.jongsoft.finance.providers.SpendingInsightProvider; + import java.time.YearMonth; public class SpendingInsightFilterCommand extends JpaFilterBuilder - implements SpendingInsightProvider.FilterCommand { - - public SpendingInsightFilterCommand() { - orderAscending = true; - orderBy = "detectedDate"; - } - - @Override - public SpendingInsightFilterCommand category(String value, boolean exact) { - if (exact) { - query().fieldEq("category", value); - } else { - query().fieldLike("category", value); + implements SpendingInsightProvider.FilterCommand { + + public SpendingInsightFilterCommand() { + orderAscending = true; + orderBy = "detectedDate"; + } + + @Override + public SpendingInsightFilterCommand category(String value, boolean exact) { + if (exact) { + query().fieldEq("category", value); + } else { + query().fieldLike("category", value); + } + return this; + } + + @Override + public SpendingInsightFilterCommand yearMonth(YearMonth yearMonth) { + query().fieldEq("yearMonth", yearMonth.toString()); + return this; + } + + @Override + public SpendingInsightFilterCommand page(int page, int pageSize) { + limitRows = pageSize; + skipRows = page * pageSize; + return this; + } + + public void user(String username) { + query().fieldEq("user.username", username); + } + + @Override + public Class entityType() { + return SpendingInsightJpa.class; } - return this; - } - - @Override - public SpendingInsightFilterCommand yearMonth(YearMonth yearMonth) { - query().fieldEq("yearMonth", yearMonth.toString()); - return this; - } - - @Override - public SpendingInsightFilterCommand page(int page, int pageSize) { - limitRows = pageSize; - skipRows = page * pageSize; - return this; - } - - public void user(String username) { - query().fieldEq("user.username", username); - } - - @Override - public Class entityType() { - return SpendingInsightJpa.class; - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightJpa.java index 94444054..8e0b9bdf 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightJpa.java @@ -4,76 +4,79 @@ import com.jongsoft.finance.domain.insight.Severity; import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; + +import lombok.Builder; +import lombok.Getter; + import java.time.LocalDate; import java.time.YearMonth; import java.util.HashMap; import java.util.Map; -import lombok.Builder; -import lombok.Getter; @Getter @Entity @Table(name = "spending_insights") public class SpendingInsightJpa extends EntityJpa { - @Enumerated(EnumType.STRING) - private InsightType type; + @Enumerated(EnumType.STRING) + private InsightType type; - private String category; + private String category; - @Enumerated(EnumType.STRING) - private Severity severity; + @Enumerated(EnumType.STRING) + private Severity severity; - private double score; - private LocalDate detectedDate; - private String message; - private Long transactionId; + private double score; + private LocalDate detectedDate; + private String message; + private Long transactionId; - @Column(name = "year_month_found") - private String yearMonth; + @Column(name = "year_month_found") + private String yearMonth; - @ElementCollection - @CollectionTable( - name = "spending_insight_metadata", - joinColumns = @JoinColumn(name = "insight_id")) - @MapKeyColumn(name = "metadata_key") - @Column(name = "metadata_value") - private Map metadata = new HashMap<>(); + @ElementCollection + @CollectionTable( + name = "spending_insight_metadata", + joinColumns = @JoinColumn(name = "insight_id")) + @MapKeyColumn(name = "metadata_key") + @Column(name = "metadata_value") + private Map metadata = new HashMap<>(); - @ManyToOne - @JoinColumn(nullable = false, updatable = false) - private UserAccountJpa user; + @ManyToOne + @JoinColumn(nullable = false, updatable = false) + private UserAccountJpa user; - @Builder - private SpendingInsightJpa( - InsightType type, - String category, - Severity severity, - double score, - LocalDate detectedDate, - String message, - YearMonth yearMonth, - Map metadata, - Long transactionId, - UserAccountJpa user) { - this.type = type; - this.category = category; - this.severity = severity; - this.score = score; - this.detectedDate = detectedDate; - this.message = message; - this.transactionId = transactionId; - this.yearMonth = yearMonth != null ? yearMonth.toString() : null; - if (metadata != null) { - this.metadata = metadata; + @Builder + private SpendingInsightJpa( + InsightType type, + String category, + Severity severity, + double score, + LocalDate detectedDate, + String message, + YearMonth yearMonth, + Map metadata, + Long transactionId, + UserAccountJpa user) { + this.type = type; + this.category = category; + this.severity = severity; + this.score = score; + this.detectedDate = detectedDate; + this.message = message; + this.transactionId = transactionId; + this.yearMonth = yearMonth != null ? yearMonth.toString() : null; + if (metadata != null) { + this.metadata = metadata; + } + this.user = user; } - this.user = user; - } - protected SpendingInsightJpa() {} + protected SpendingInsightJpa() {} - public YearMonth getYearMonth() { - return yearMonth != null ? YearMonth.parse(yearMonth) : null; - } + public YearMonth getYearMonth() { + return yearMonth != null ? YearMonth.parse(yearMonth) : null; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpa.java index 98b1260e..907f3564 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpa.java @@ -11,14 +11,18 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Named; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.time.YearMonth; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; @Slf4j @ReadOnly @@ -27,125 +31,126 @@ @Named("spendingInsightProvider") public class SpendingInsightProviderJpa implements SpendingInsightProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - public SpendingInsightProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Sequence lookup() { - log.trace("Fetching all spending insights"); - - return entityManager - .from(SpendingInsightJpa.class) - .joinFetch("user") - .fieldEq("user.username", authenticationFacade.authenticated()) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public Optional lookup(String category) { - log.trace("Fetching spending insight for category: {}", category); - - return entityManager - .from(SpendingInsightJpa.class) - .joinFetch("user") - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("category", category) - .singleResult() - .map(this::convert); - } - - @Override - public Sequence lookup(YearMonth yearMonth) { - log.trace("Fetching spending insights for year-month: {}", yearMonth); - - return entityManager - .from(SpendingInsightJpa.class) - .joinFetch("user") - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("yearMonth", yearMonth.toString()) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public ResultPage lookup(SpendingInsightProvider.FilterCommand filter) { - log.trace("Fetching spending insights with filter: {}", filter); - - if (filter instanceof SpendingInsightFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - return entityManager.from(delegate).paged().map(this::convert); + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + public SpendingInsightProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Sequence lookup() { + log.trace("Fetching all spending insights"); + + return entityManager + .from(SpendingInsightJpa.class) + .joinFetch("user") + .fieldEq("user.username", authenticationFacade.authenticated()) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } + + @Override + public Optional lookup(String category) { + log.trace("Fetching spending insight for category: {}", category); + + return entityManager + .from(SpendingInsightJpa.class) + .joinFetch("user") + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("category", category) + .singleResult() + .map(this::convert); + } + + @Override + public Sequence lookup(YearMonth yearMonth) { + log.trace("Fetching spending insights for year-month: {}", yearMonth); + + return entityManager + .from(SpendingInsightJpa.class) + .joinFetch("user") + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("yearMonth", yearMonth.toString()) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); } - throw new IllegalStateException("Cannot use non-JPA filter on SpendingInsightProviderJpa"); - } + @Override + public ResultPage lookup(SpendingInsightProvider.FilterCommand filter) { + log.trace("Fetching spending insights with filter: {}", filter); + + if (filter instanceof SpendingInsightFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + return entityManager.from(delegate).paged().map(this::convert); + } - @BusinessEventListener - public void save(CreateSpendingInsight command) { - log.trace("Saving spending insight: {}", command); + throw new IllegalStateException("Cannot use non-JPA filter on SpendingInsightProviderJpa"); + } - // Convert metadata to string values - Map metadata = new HashMap<>(); - for (Map.Entry entry : command.metadata().entrySet()) { - metadata.put(entry.getKey(), entry.getValue() != null ? entry.getValue().toString() : null); + @BusinessEventListener + public void save(CreateSpendingInsight command) { + log.trace("Saving spending insight: {}", command); + + // Convert metadata to string values + Map metadata = new HashMap<>(); + for (Map.Entry entry : command.metadata().entrySet()) { + metadata.put( + entry.getKey(), entry.getValue() != null ? entry.getValue().toString() : null); + } + + // Create the JPA entity + SpendingInsightJpa jpa = SpendingInsightJpa.builder() + .type(command.type()) + .category(command.category()) + .severity(command.severity()) + .score(command.score()) + .detectedDate(command.detectedDate()) + .message(command.message()) + .yearMonth(YearMonth.from(command.detectedDate())) + .transactionId(command.transactionId()) + .metadata(metadata) + .user(entityManager.currentUser()) + .build(); + + // Save the entity + entityManager.persist(jpa); } - // Create the JPA entity - SpendingInsightJpa jpa = SpendingInsightJpa.builder() - .type(command.type()) - .category(command.category()) - .severity(command.severity()) - .score(command.score()) - .detectedDate(command.detectedDate()) - .message(command.message()) - .yearMonth(YearMonth.from(command.detectedDate())) - .transactionId(command.transactionId()) - .metadata(metadata) - .user(entityManager.currentUser()) - .build(); - - // Save the entity - entityManager.persist(jpa); - } - - @BusinessEventListener - public void cleanForMonth(CleanInsightsForMonth command) { - log.trace("Cleaning spending insights for month: {}", command.month()); - entityManager - .getEntityManager() - .createQuery( - "DELETE FROM SpendingInsightJpa WHERE yearMonth = :yearMonth and user.id in (select id from UserAccountJpa a where a.username = :username)") - .setParameter("yearMonth", command.month().toString()) - .setParameter("username", authenticationFacade.authenticated()) - .executeUpdate(); - } - - private SpendingInsight convert(SpendingInsightJpa source) { - if (source == null) { - return null; + @BusinessEventListener + public void cleanForMonth(CleanInsightsForMonth command) { + log.trace("Cleaning spending insights for month: {}", command.month()); + entityManager + .getEntityManager() + .createQuery( + "DELETE FROM SpendingInsightJpa WHERE yearMonth = :yearMonth and user.id in (select id from UserAccountJpa a where a.username = :username)") + .setParameter("yearMonth", command.month().toString()) + .setParameter("username", authenticationFacade.authenticated()) + .executeUpdate(); } - // Convert metadata from string values to objects - Map metadata = source.getMetadata().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - return SpendingInsight.builder() - .type(source.getType()) - .score(source.getScore()) - .severity(source.getSeverity()) - .category(source.getCategory()) - .message(source.getMessage()) - .transactionId(source.getTransactionId()) - .metadata(metadata) - .detectedDate(source.getDetectedDate()) - .build(); - } + private SpendingInsight convert(SpendingInsightJpa source) { + if (source == null) { + return null; + } + + // Convert metadata from string values to objects + Map metadata = source.getMetadata().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return SpendingInsight.builder() + .type(source.getType()) + .score(source.getScore()) + .severity(source.getSeverity()) + .category(source.getCategory()) + .message(source.getMessage()) + .transactionId(source.getTransactionId()) + .metadata(metadata) + .detectedDate(source.getDetectedDate()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternFilterCommand.java index 52204011..00e531f0 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternFilterCommand.java @@ -4,45 +4,46 @@ import com.jongsoft.finance.jpa.query.JpaFilterBuilder; import com.jongsoft.finance.providers.SpendingPatternProvider; + import java.time.YearMonth; public class SpendingPatternFilterCommand extends JpaFilterBuilder - implements SpendingPatternProvider.FilterCommand { - - public SpendingPatternFilterCommand() { - orderAscending = true; - orderBy = "detectedDate"; - } - - @Override - public SpendingPatternFilterCommand category(String value, boolean exact) { - if (exact) { - query().fieldEq(COLUMN_CATEGORY, value); - } else { - query().fieldLike(COLUMN_CATEGORY, value); + implements SpendingPatternProvider.FilterCommand { + + public SpendingPatternFilterCommand() { + orderAscending = true; + orderBy = "detectedDate"; + } + + @Override + public SpendingPatternFilterCommand category(String value, boolean exact) { + if (exact) { + query().fieldEq(COLUMN_CATEGORY, value); + } else { + query().fieldLike(COLUMN_CATEGORY, value); + } + return this; + } + + @Override + public SpendingPatternFilterCommand yearMonth(YearMonth yearMonth) { + query().fieldEq(COLUMN_YEAR_MONTH, yearMonth.toString()); + return this; + } + + @Override + public SpendingPatternFilterCommand page(int page, int pageSize) { + limitRows = pageSize; + skipRows = page * pageSize; + return this; + } + + public void user(String username) { + query().fieldEq(COLUMN_USERNAME, username); + } + + @Override + public Class entityType() { + return SpendingPatternJpa.class; } - return this; - } - - @Override - public SpendingPatternFilterCommand yearMonth(YearMonth yearMonth) { - query().fieldEq(COLUMN_YEAR_MONTH, yearMonth.toString()); - return this; - } - - @Override - public SpendingPatternFilterCommand page(int page, int pageSize) { - limitRows = pageSize; - skipRows = page * pageSize; - return this; - } - - public void user(String username) { - query().fieldEq(COLUMN_USERNAME, username); - } - - @Override - public Class entityType() { - return SpendingPatternJpa.class; - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternJpa.java index da146252..a8724fed 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternJpa.java @@ -3,68 +3,71 @@ import com.jongsoft.finance.domain.insight.PatternType; import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; + +import lombok.Builder; +import lombok.Getter; + import java.time.LocalDate; import java.time.YearMonth; import java.util.HashMap; import java.util.Map; -import lombok.Builder; -import lombok.Getter; @Getter @Entity @Table(name = "spending_patterns") public class SpendingPatternJpa extends EntityJpa { - static final String COLUMN_CATEGORY = "category"; - static final String COLUMN_USERNAME = "user.username"; - static final String COLUMN_YEAR_MONTH = "yearMonth"; + static final String COLUMN_CATEGORY = "category"; + static final String COLUMN_USERNAME = "user.username"; + static final String COLUMN_YEAR_MONTH = "yearMonth"; - @Enumerated(EnumType.STRING) - private PatternType type; + @Enumerated(EnumType.STRING) + private PatternType type; - private String category; - private double confidence; - private LocalDate detectedDate; + private String category; + private double confidence; + private LocalDate detectedDate; - @Column(name = "year_month_found") - private String yearMonth; + @Column(name = "year_month_found") + private String yearMonth; - @ElementCollection - @CollectionTable( - name = "spending_pattern_metadata", - joinColumns = @JoinColumn(name = "pattern_id")) - @MapKeyColumn(name = "metadata_key") - @Column(name = "metadata_value") - private Map metadata = new HashMap<>(); + @ElementCollection + @CollectionTable( + name = "spending_pattern_metadata", + joinColumns = @JoinColumn(name = "pattern_id")) + @MapKeyColumn(name = "metadata_key") + @Column(name = "metadata_value") + private Map metadata = new HashMap<>(); - @ManyToOne - @JoinColumn(nullable = false, updatable = false) - private UserAccountJpa user; + @ManyToOne + @JoinColumn(nullable = false, updatable = false) + private UserAccountJpa user; - @Builder - private SpendingPatternJpa( - PatternType type, - String category, - double confidence, - LocalDate detectedDate, - YearMonth yearMonth, - Map metadata, - UserAccountJpa user) { - this.type = type; - this.category = category; - this.confidence = confidence; - this.detectedDate = detectedDate; - this.yearMonth = yearMonth != null ? yearMonth.toString() : null; - if (metadata != null) { - this.metadata = metadata; + @Builder + private SpendingPatternJpa( + PatternType type, + String category, + double confidence, + LocalDate detectedDate, + YearMonth yearMonth, + Map metadata, + UserAccountJpa user) { + this.type = type; + this.category = category; + this.confidence = confidence; + this.detectedDate = detectedDate; + this.yearMonth = yearMonth != null ? yearMonth.toString() : null; + if (metadata != null) { + this.metadata = metadata; + } + this.user = user; } - this.user = user; - } - protected SpendingPatternJpa() {} + protected SpendingPatternJpa() {} - public YearMonth getYearMonth() { - return yearMonth != null ? YearMonth.parse(yearMonth) : null; - } + public YearMonth getYearMonth() { + return yearMonth != null ? YearMonth.parse(yearMonth) : null; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternProviderJpa.java index c9c7ba2c..3f9a98f5 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/insight/SpendingPatternProviderJpa.java @@ -14,14 +14,18 @@ import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Named; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.time.YearMonth; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; @Slf4j @ReadOnly @@ -30,121 +34,121 @@ @Named("spendingPatternProvider") public class SpendingPatternProviderJpa implements SpendingPatternProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - public SpendingPatternProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Sequence lookup() { - log.trace("Fetching all spending patterns"); - - return entityManager - .from(SpendingPatternJpa.class) - .joinFetch("user") - .fieldEq(COLUMN_USERNAME, authenticationFacade.authenticated()) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public Optional lookup(String category) { - log.trace("Fetching spending pattern for category: {}", category); - - return entityManager - .from(SpendingPatternJpa.class) - .joinFetch("user") - .fieldEq(COLUMN_USERNAME, authenticationFacade.authenticated()) - .fieldEq(COLUMN_CATEGORY, category) - .singleResult() - .map(this::convert); - } - - @Override - public Sequence lookup(YearMonth yearMonth) { - log.trace("Fetching spending patterns for year-month: {}", yearMonth); - - return entityManager - .from(SpendingPatternJpa.class) - .joinFetch("user") - .fieldEq(COLUMN_USERNAME, authenticationFacade.authenticated()) - .fieldEq(COLUMN_YEAR_MONTH, yearMonth.toString()) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public ResultPage lookup(FilterCommand filter) { - log.trace("Fetching spending patterns with filter: {}", filter); - - if (filter instanceof SpendingPatternFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - return entityManager.from(delegate).paged().map(this::convert); + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + public SpendingPatternProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Sequence lookup() { + log.trace("Fetching all spending patterns"); + + return entityManager + .from(SpendingPatternJpa.class) + .joinFetch("user") + .fieldEq(COLUMN_USERNAME, authenticationFacade.authenticated()) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } + + @Override + public Optional lookup(String category) { + log.trace("Fetching spending pattern for category: {}", category); + + return entityManager + .from(SpendingPatternJpa.class) + .joinFetch("user") + .fieldEq(COLUMN_USERNAME, authenticationFacade.authenticated()) + .fieldEq(COLUMN_CATEGORY, category) + .singleResult() + .map(this::convert); + } + + @Override + public Sequence lookup(YearMonth yearMonth) { + log.trace("Fetching spending patterns for year-month: {}", yearMonth); + + return entityManager + .from(SpendingPatternJpa.class) + .joinFetch("user") + .fieldEq(COLUMN_USERNAME, authenticationFacade.authenticated()) + .fieldEq(COLUMN_YEAR_MONTH, yearMonth.toString()) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); } - throw new IllegalStateException("Cannot use non-JPA filter on SpendingPatternProviderJpa"); - } + @Override + public ResultPage lookup(FilterCommand filter) { + log.trace("Fetching spending patterns with filter: {}", filter); + + if (filter instanceof SpendingPatternFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + return entityManager.from(delegate).paged().map(this::convert); + } - @BusinessEventListener - public void save(CreateSpendingPattern command) { - log.trace("Saving spending pattern: {}", command); + throw new IllegalStateException("Cannot use non-JPA filter on SpendingPatternProviderJpa"); + } - // Convert metadata to string values - Map metadata = new HashMap<>(); - for (Map.Entry entry : command.metadata().entrySet()) { - metadata.put( - entry.getKey(), - Control.Option(entry.getValue()).map(Object::toString).getOrSupply(() -> null)); + @BusinessEventListener + public void save(CreateSpendingPattern command) { + log.trace("Saving spending pattern: {}", command); + + // Convert metadata to string values + Map metadata = new HashMap<>(); + for (Map.Entry entry : command.metadata().entrySet()) { + metadata.put( + entry.getKey(), + Control.Option(entry.getValue()).map(Object::toString).getOrSupply(() -> null)); + } + + // Create the JPA entity + SpendingPatternJpa jpa = SpendingPatternJpa.builder() + .type(command.type()) + .category(command.category()) + .confidence(command.confidence()) + .detectedDate(command.detectedDate()) + .yearMonth(YearMonth.from(command.detectedDate())) + .metadata(metadata) + .user(entityManager.currentUser()) + .build(); + + // Save the entity + entityManager.persist(jpa); } - // Create the JPA entity - SpendingPatternJpa jpa = SpendingPatternJpa.builder() - .type(command.type()) - .category(command.category()) - .confidence(command.confidence()) - .detectedDate(command.detectedDate()) - .yearMonth(YearMonth.from(command.detectedDate())) - .metadata(metadata) - .user(entityManager.currentUser()) - .build(); - - // Save the entity - entityManager.persist(jpa); - } - - @BusinessEventListener - public void cleanForMonth(CleanInsightsForMonth command) { - log.trace("Cleaning spending insights for month: {}", command.month()); - entityManager - .getEntityManager() - .createQuery( - "DELETE FROM SpendingPatternJpa WHERE yearMonth = :yearMonth and user.id in (select id from UserAccountJpa a where a.username = :username)") - .setParameter("yearMonth", command.month().toString()) - .setParameter("username", authenticationFacade.authenticated()) - .executeUpdate(); - } - - private SpendingPattern convert(SpendingPatternJpa source) { - if (source == null) { - return null; + @BusinessEventListener + public void cleanForMonth(CleanInsightsForMonth command) { + log.trace("Cleaning spending insights for month: {}", command.month()); + entityManager + .getEntityManager() + .createQuery( + "DELETE FROM SpendingPatternJpa WHERE yearMonth = :yearMonth and user.id in (select id from UserAccountJpa a where a.username = :username)") + .setParameter("yearMonth", command.month().toString()) + .setParameter("username", authenticationFacade.authenticated()) + .executeUpdate(); } - // Convert metadata from string values to objects - Map metadata = source.getMetadata().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - return SpendingPattern.builder() - .type(source.getType()) - .category(source.getCategory()) - .metadata(metadata) - .confidence(source.getConfidence()) - .detectedDate(source.getDetectedDate()) - .build(); - } + private SpendingPattern convert(SpendingPatternJpa source) { + if (source == null) { + return null; + } + + // Convert metadata from string values to objects + Map metadata = source.getMetadata().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return SpendingPattern.builder() + .type(source.getType()) + .category(source.getCategory()) + .metadata(metadata) + .confidence(source.getConfidence()) + .detectedDate(source.getDetectedDate()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/PairProjection.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/PairProjection.java index 99406168..9557c628 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/PairProjection.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/PairProjection.java @@ -6,6 +6,6 @@ @Getter @AllArgsConstructor public class PairProjection { - private final K key; - private final T value; + private final K key; + private final T value; } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/TripleProjection.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/TripleProjection.java index aafb490c..38460b18 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/TripleProjection.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/projections/TripleProjection.java @@ -6,7 +6,7 @@ @Getter @AllArgsConstructor public class TripleProjection { - private final K first; - private final T second; - private final I third; + private final K first; + private final T second; + private final I third; } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BaseQuery.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BaseQuery.java index 4659bc9d..07f72332 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BaseQuery.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BaseQuery.java @@ -2,6 +2,7 @@ import com.jongsoft.finance.jpa.query.expression.Expressions; import com.jongsoft.finance.jpa.query.expression.FieldEquation; + import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; @@ -9,159 +10,161 @@ import java.util.function.Consumer; public abstract class BaseQuery> implements Query { - protected record InnerQuery(String fieldCondition, SubQuery subQuery) - implements BooleanExpression { + protected record InnerQuery(String fieldCondition, SubQuery subQuery) + implements BooleanExpression { + @Override + public String hqlExpression() { + return fieldCondition + subQuery.hqlExpression(); + } + + @Override + public void addParameters(jakarta.persistence.Query query) { + subQuery.addParameters(query); + } + + @Override + public BooleanExpression cloneWithAlias(String alias) { + return new InnerQuery(fieldCondition, (SubQuery) subQuery.cloneWithAlias(alias)); + } + } + + private final List conditions; + private final String tableAlias; + + protected BaseQuery(String tableAlias) { + this.conditions = new ArrayList<>(); + this.tableAlias = tableAlias; + } + + @Override + @SuppressWarnings("unchecked") + public Q fieldEq(String field, C condition) { + conditions.add(Expressions.fieldCondition(tableAlias, field, FieldEquation.EQ, condition)); + return (Q) this; + } + + @Override + @SuppressWarnings("unchecked") + public Q fieldLike(String field, String condition) { + conditions.add(Expressions.fieldLike(tableAlias, field, condition)); + return (Q) this; + } + @Override - public String hqlExpression() { - return fieldCondition + subQuery.hqlExpression(); + @SuppressWarnings("unchecked") + public Q fieldBetween(String field, C start, C end) { + conditions.add(Expressions.fieldBetween(tableAlias, field, start, end)); + return (Q) this; } @Override - public void addParameters(jakarta.persistence.Query query) { - subQuery.addParameters(query); + @SuppressWarnings("unchecked") + public Q fieldEqOneOf(String field, C... conditions) { + if (conditions.length == 1) { + return fieldEq(field, conditions[0]); + } else if (conditions.length == 2) { + this.conditions.add(Expressions.or( + Expressions.fieldCondition(tableAlias, field, FieldEquation.EQ, conditions[0]), + Expressions.fieldCondition( + tableAlias, field, FieldEquation.EQ, conditions[1]))); + } else { + this.conditions.add(Expressions.fieldCondition( + tableAlias, field, FieldEquation.IN, Arrays.asList(conditions))); + } + + return (Q) this; } @Override - public BooleanExpression cloneWithAlias(String alias) { - return new InnerQuery(fieldCondition, (SubQuery) subQuery.cloneWithAlias(alias)); - } - } - - private final List conditions; - private final String tableAlias; - - protected BaseQuery(String tableAlias) { - this.conditions = new ArrayList<>(); - this.tableAlias = tableAlias; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldEq(String field, C condition) { - conditions.add(Expressions.fieldCondition(tableAlias, field, FieldEquation.EQ, condition)); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldLike(String field, String condition) { - conditions.add(Expressions.fieldLike(tableAlias, field, condition)); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldBetween(String field, C start, C end) { - conditions.add(Expressions.fieldBetween(tableAlias, field, start, end)); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldEqOneOf(String field, C... conditions) { - if (conditions.length == 1) { - return fieldEq(field, conditions[0]); - } else if (conditions.length == 2) { - this.conditions.add(Expressions.or( - Expressions.fieldCondition(tableAlias, field, FieldEquation.EQ, conditions[0]), - Expressions.fieldCondition(tableAlias, field, FieldEquation.EQ, conditions[1]))); - } else { - this.conditions.add(Expressions.fieldCondition( - tableAlias, field, FieldEquation.IN, Arrays.asList(conditions))); - } - - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldNotEqOneOf(String field, C... conditions) { - this.conditions.add(Expressions.fieldCondition( - tableAlias, field, FieldEquation.NIN, Arrays.asList(conditions))); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldGtOrEq(String field, C condition) { - conditions.add(Expressions.fieldCondition(tableAlias, field, FieldEquation.GTE, condition)); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldGtOrEqNullable(String field, C condition) { - conditions.add(Expressions.or( - Expressions.fieldCondition(tableAlias, field, FieldEquation.GTE, condition), - Expressions.fieldNull(tableAlias, field))); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldLtOrEq(String field, C condition) { - conditions.add(Expressions.fieldCondition(tableAlias, field, FieldEquation.LTE, condition)); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q fieldNull(String field) { - conditions.add(Expressions.fieldNull(tableAlias, field)); - return (Q) this; - } - - @SuppressWarnings("unchecked") - public Q fieldIn(String field, Class subQueryEntity, Consumer subQueryBuilder) { - var alias = tableAlias == null ? "" : tableAlias + "."; - - var qubQuery = new SubQuery(tableAlias, BaseQuery.generateRandomString()).from(subQueryEntity); - subQueryBuilder.accept(qubQuery); - conditions.add(new InnerQuery(alias + field + " IN ", qubQuery)); - return (Q) this; - } - - @Override - public Q whereExists(Consumer subQueryBuilder) { - var subQuery = new SubQuery(tableAlias, BaseQuery.generateRandomString()); - subQueryBuilder.accept(subQuery); - conditions.add(new InnerQuery("EXISTS", subQuery)); - return (Q) this; - } - - @Override - public Q whereNotExists(Consumer subQueryBuilder) { - var subQuery = new SubQuery(tableAlias, BaseQuery.generateRandomString()); - subQueryBuilder.accept(subQuery); - conditions.add(new InnerQuery("NOT EXISTS", subQuery)); - return (Q) this; - } - - @Override - @SuppressWarnings("unchecked") - public Q condition(BooleanExpression expression) { - conditions.add(expression); - return (Q) this; - } - - protected List conditions() { - return conditions; - } - - public String tableAlias() { - return tableAlias; - } - - static String generateRandomString() { - var random = new SecureRandom(); - var sb = new StringBuilder(5); - var allowedChard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - - for (int i = 0; i < 12; i++) { - int randomIndex = random.nextInt(allowedChard.length()); - sb.append(allowedChard.charAt(randomIndex)); - } - - return sb.toString(); - } + @SuppressWarnings("unchecked") + public Q fieldNotEqOneOf(String field, C... conditions) { + this.conditions.add(Expressions.fieldCondition( + tableAlias, field, FieldEquation.NIN, Arrays.asList(conditions))); + return (Q) this; + } + + @Override + @SuppressWarnings("unchecked") + public Q fieldGtOrEq(String field, C condition) { + conditions.add(Expressions.fieldCondition(tableAlias, field, FieldEquation.GTE, condition)); + return (Q) this; + } + + @Override + @SuppressWarnings("unchecked") + public Q fieldGtOrEqNullable(String field, C condition) { + conditions.add(Expressions.or( + Expressions.fieldCondition(tableAlias, field, FieldEquation.GTE, condition), + Expressions.fieldNull(tableAlias, field))); + return (Q) this; + } + + @Override + @SuppressWarnings("unchecked") + public Q fieldLtOrEq(String field, C condition) { + conditions.add(Expressions.fieldCondition(tableAlias, field, FieldEquation.LTE, condition)); + return (Q) this; + } + + @Override + @SuppressWarnings("unchecked") + public Q fieldNull(String field) { + conditions.add(Expressions.fieldNull(tableAlias, field)); + return (Q) this; + } + + @SuppressWarnings("unchecked") + public Q fieldIn(String field, Class subQueryEntity, Consumer subQueryBuilder) { + var alias = tableAlias == null ? "" : tableAlias + "."; + + var qubQuery = + new SubQuery(tableAlias, BaseQuery.generateRandomString()).from(subQueryEntity); + subQueryBuilder.accept(qubQuery); + conditions.add(new InnerQuery(alias + field + " IN ", qubQuery)); + return (Q) this; + } + + @Override + public Q whereExists(Consumer subQueryBuilder) { + var subQuery = new SubQuery(tableAlias, BaseQuery.generateRandomString()); + subQueryBuilder.accept(subQuery); + conditions.add(new InnerQuery("EXISTS", subQuery)); + return (Q) this; + } + + @Override + public Q whereNotExists(Consumer subQueryBuilder) { + var subQuery = new SubQuery(tableAlias, BaseQuery.generateRandomString()); + subQueryBuilder.accept(subQuery); + conditions.add(new InnerQuery("NOT EXISTS", subQuery)); + return (Q) this; + } + + @Override + @SuppressWarnings("unchecked") + public Q condition(BooleanExpression expression) { + conditions.add(expression); + return (Q) this; + } + + protected List conditions() { + return conditions; + } + + public String tableAlias() { + return tableAlias; + } + + static String generateRandomString() { + var random = new SecureRandom(); + var sb = new StringBuilder(5); + var allowedChard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + for (int i = 0; i < 12; i++) { + int randomIndex = random.nextInt(allowedChard.length()); + sb.append(allowedChard.charAt(randomIndex)); + } + + return sb.toString(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BooleanExpression.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BooleanExpression.java index 71a32e93..45849562 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BooleanExpression.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/BooleanExpression.java @@ -4,15 +4,15 @@ public interface BooleanExpression { - default BooleanExpression cloneWithAlias(String alias) { - return this; - } + default BooleanExpression cloneWithAlias(String alias) { + return this; + } - default String tableAlias() { - return null; - } + default String tableAlias() { + return null; + } - String hqlExpression(); + String hqlExpression(); - default void addParameters(Query query) {} + default void addParameters(Query query) {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaFilterBuilder.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaFilterBuilder.java index e7df46f4..554af763 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaFilterBuilder.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaFilterBuilder.java @@ -4,50 +4,50 @@ public abstract class JpaFilterBuilder { - protected Integer skipRows; - protected Integer limitRows; - protected String orderBy; - protected boolean orderAscending; - private final BaseQuery query; - - public JpaFilterBuilder() { - this.query = new BaseQuery(null) {}; - } - - protected Query query() { - return query; - } - - /** - * Applies the conditions from the current query to the provided query for filtering. - * - * @param applyTo the query to apply the conditions to - */ - public void applyTo(Query applyTo) { - query.conditions().forEach(condition -> { - if (condition.tableAlias() == null && applyTo instanceof JpaQuery jpaQuery) { - applyTo.condition(condition.cloneWithAlias("e")); - } else { - applyTo.condition(condition); - } - }); - if (applyTo instanceof JpaQuery jpaQuery) { - applyPagingOnly(jpaQuery); + protected Integer skipRows; + protected Integer limitRows; + protected String orderBy; + protected boolean orderAscending; + private final BaseQuery query; + + public JpaFilterBuilder() { + this.query = new BaseQuery(null) {}; } - } - - /** - * Applies pagination settings to the provided JpaQuery object. - * - * @param applyTo the JpaQuery object to apply pagination settings to - */ - public void applyPagingOnly(JpaQuery applyTo) { - Control.Option(skipRows).ifPresent(applyTo::skip); - Control.Option(limitRows).ifPresent(applyTo::limit); - applyTo.orderBy(orderBy, orderAscending); - } - - public abstract void user(String username); - - public abstract Class entityType(); + + protected Query query() { + return query; + } + + /** + * Applies the conditions from the current query to the provided query for filtering. + * + * @param applyTo the query to apply the conditions to + */ + public void applyTo(Query applyTo) { + query.conditions().forEach(condition -> { + if (condition.tableAlias() == null && applyTo instanceof JpaQuery jpaQuery) { + applyTo.condition(condition.cloneWithAlias("e")); + } else { + applyTo.condition(condition); + } + }); + if (applyTo instanceof JpaQuery jpaQuery) { + applyPagingOnly(jpaQuery); + } + } + + /** + * Applies pagination settings to the provided JpaQuery object. + * + * @param applyTo the JpaQuery object to apply pagination settings to + */ + public void applyPagingOnly(JpaQuery applyTo) { + Control.Option(skipRows).ifPresent(applyTo::skip); + Control.Option(limitRows).ifPresent(applyTo::limit); + applyTo.orderBy(orderBy, orderAscending); + } + + public abstract void user(String username); + + public abstract Class entityType(); } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaQuery.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaQuery.java index 16f08f21..8c55721d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaQuery.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaQuery.java @@ -6,14 +6,17 @@ import com.jongsoft.lang.Collections; import com.jongsoft.lang.Control; import com.jongsoft.lang.control.Optional; + import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Represents a simple query class that assists in building and executing queries for a specified @@ -23,246 +26,248 @@ */ public class JpaQuery extends BaseQuery> { - private record JoinStatement(String field, boolean fetch) {} - - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final Class table; - private final EntityManager entityManager; - private final List joinTables = new ArrayList<>(); - private final List groupings = new ArrayList<>(); - - private Integer skipRows; - private Integer limitRows; - private String orderBy; - private boolean orderAscending; - - JpaQuery(EntityManager entityManager, Class entityType) { - super("e"); - this.entityManager = entityManager; - table = entityType; - } - - /** - * Sets the number of rows to skip in the query result. - * - * @param numberOfRows the number of rows to skip - * @return the updated JpaQuery instance with the specified number of rows skipped - */ - public JpaQuery skip(int numberOfRows) { - skipRows = numberOfRows; - return this; - } - - /** - * Sets the limit for the number of rows to be retrieved in the query result. - * - * @param numberOfRows the number of rows to limit the query result to - * @return the updated JpaQuery instance with the specified row limit - */ - public JpaQuery limit(int numberOfRows) { - limitRows = numberOfRows; - return this; - } - - /** - * Sets the field to order the query result by and specifies whether the ordering should be - * ascending or descending. - * - * @param field the field to order by - * @param ascending boolean value indicating whether to order in ascending order - * @return the updated JpaQuery instance with the specified ordering - */ - public JpaQuery orderBy(String field, boolean ascending) { - orderBy = field; - orderAscending = ascending; - return this; - } - - /** - * Adds the specified fields to the list of groupings for the query result. - * - * @param fields the fields to group by - * @return the updated JpaQuery instance with the added groupings - */ - public JpaQuery groupBy(String... fields) { - for (var field : fields) { - groupings.add(Expressions.field("e." + field)); + private record JoinStatement(String field, boolean fetch) {} + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Class table; + private final EntityManager entityManager; + private final List joinTables = new ArrayList<>(); + private final List groupings = new ArrayList<>(); + + private Integer skipRows; + private Integer limitRows; + private String orderBy; + private boolean orderAscending; + + JpaQuery(EntityManager entityManager, Class entityType) { + super("e"); + this.entityManager = entityManager; + table = entityType; } - return this; - } - - /** - * Adds the specified computation expressions to the list of groupings for the query result. - * - * @param expressions the computation expressions to group by - * @return the updated JpaQuery instance with the added groupings - */ - public JpaQuery groupBy(ComputationExpression... expressions) { - groupings.addAll(Arrays.asList(expressions)); - return this; - } - - /** - * Adds a join fetch for a specific field to the query. - * - * @param field the field to join fetch - * @return the updated SimpleQuery instance with the specified field join fetched - */ - public JpaQuery joinFetch(String field) { - joinTables.add(new JoinStatement(field, true)); - return this; - } - - /** - * Joins the specified field in the query. - * - * @param field the field to join - * @return the updated JpaQuery instance with the specified field joined - */ - public JpaQuery join(String field) { - joinTables.add(new JoinStatement(field, false)); - return this; - } - - /** - * Projects a specific type of projection using the provided projection type and expression. - * - * @param the type of the projection result - * @param projectionType the class representing the type of projection result - * @param projection the expression for the projection - * @return an Optional containing the projected result if successful, otherwise an empty - * Optional - */ - public Optional projectSingleValue(Class projectionType, String projection) { - var hql = "SELECT %s %s".formatted(projection, generateHql(true)); - - return Control.Try(() -> (C) createQuery(projectionType, hql).getSingleResult()) - .map(Control::Option) - .recover(e -> { - logger.warn("Unable to find projection, cause: {}", e.getLocalizedMessage()); - return Control.Option(); - }) - .get(); - } - - /** - * Projects a specific type of projection using the provided projection type and expression. - * - * @param the type of the projection result - * @param projectionType the class representing the type of projection result - * @param projection the expression for the projection - * @return a Stream of the projected results based on the specified projection type and - * expression - */ - public Stream project(Class projectionType, String projection) { - var hql = "SELECT %s %s".formatted(projection, generateHql(true)); - return createQuery(projectionType, hql).getResultStream(); - } - - /** - * Create and execute a stream query based on the specified conditions. - * - * @return a Stream of the elements resulting from the query execution - */ - public Stream stream() { - var hql = "SELECT DISTINCT e %s".formatted(generateHql(true)); - return createQuery(table, hql).getResultStream(); - } - - /** - * Retrieves a single result based on the specified query conditions. If multiple results are - * found, it returns an empty Optional. If no results are found, it logs a trace and returns an - * empty Optional. - * - * @return an Optional containing the single result if found, otherwise an empty Optional - */ - public Optional singleResult() { - var hql = "SELECT DISTINCT e %s".formatted(generateHql(true)); - - return Control.Try(() -> (E) createQuery(table, hql).getSingleResult()) - .map(Control::Option) - .recover(e -> { - logger.trace("Unable to find entity, cause: {}", e.getLocalizedMessage()); - return Control.Option(); - }) - .get(); - } - - /** - * Retrieves a paginated result page based on the specified query conditions. - * - * @return a ResultPage containing elements resulting from the query execution paginated based - * on the provided conditions - */ - public ResultPage paged() { - var countHql = "SELECT count(DISTINCT e.id) %s".formatted(generateHql(false)); - var query = entityManager.createQuery(countHql, Long.class); - for (var condition : conditions()) { - condition.addParameters(query); + + /** + * Sets the number of rows to skip in the query result. + * + * @param numberOfRows the number of rows to skip + * @return the updated JpaQuery instance with the specified number of rows skipped + */ + public JpaQuery skip(int numberOfRows) { + skipRows = numberOfRows; + return this; + } + + /** + * Sets the limit for the number of rows to be retrieved in the query result. + * + * @param numberOfRows the number of rows to limit the query result to + * @return the updated JpaQuery instance with the specified row limit + */ + public JpaQuery limit(int numberOfRows) { + limitRows = numberOfRows; + return this; } - var numberOfRecords = query.getSingleResult(); - var limit = limitRows == null ? Integer.MAX_VALUE : limitRows; - if (numberOfRecords > 0) { - // only run the actual query if we had hits in the count query. - return new ResultPageImpl( - stream().collect(ReactiveEntityManager.sequenceCollector()), limit, numberOfRecords); + /** + * Sets the field to order the query result by and specifies whether the ordering should be + * ascending or descending. + * + * @param field the field to order by + * @param ascending boolean value indicating whether to order in ascending order + * @return the updated JpaQuery instance with the specified ordering + */ + public JpaQuery orderBy(String field, boolean ascending) { + orderBy = field; + orderAscending = ascending; + return this; } - return new ResultPageImpl(Collections.List(), limit, 0); - } + /** + * Adds the specified fields to the list of groupings for the query result. + * + * @param fields the fields to group by + * @return the updated JpaQuery instance with the added groupings + */ + public JpaQuery groupBy(String... fields) { + for (var field : fields) { + groupings.add(Expressions.field("e." + field)); + } + return this; + } + + /** + * Adds the specified computation expressions to the list of groupings for the query result. + * + * @param expressions the computation expressions to group by + * @return the updated JpaQuery instance with the added groupings + */ + public JpaQuery groupBy(ComputationExpression... expressions) { + groupings.addAll(Arrays.asList(expressions)); + return this; + } - private TypedQuery createQuery(Class resultType, String hql) { - logger.trace("Running query, with base table {}: {}", table.getSimpleName(), hql); - var query = entityManager.createQuery(hql, resultType); - for (var condition : conditions()) { - condition.addParameters(query); + /** + * Adds a join fetch for a specific field to the query. + * + * @param field the field to join fetch + * @return the updated SimpleQuery instance with the specified field join fetched + */ + public JpaQuery joinFetch(String field) { + joinTables.add(new JoinStatement(field, true)); + return this; } - if (skipRows != null) { - query.setFirstResult(skipRows); + /** + * Joins the specified field in the query. + * + * @param field the field to join + * @return the updated JpaQuery instance with the specified field joined + */ + public JpaQuery join(String field) { + joinTables.add(new JoinStatement(field, false)); + return this; } - if (limitRows != null) { - query.setMaxResults(limitRows); + + /** + * Projects a specific type of projection using the provided projection type and expression. + * + * @param the type of the projection result + * @param projectionType the class representing the type of projection result + * @param projection the expression for the projection + * @return an Optional containing the projected result if successful, otherwise an empty + * Optional + */ + public Optional projectSingleValue(Class projectionType, String projection) { + var hql = "SELECT %s %s".formatted(projection, generateHql(true)); + + return Control.Try(() -> (C) createQuery(projectionType, hql).getSingleResult()) + .map(Control::Option) + .recover(e -> { + logger.warn("Unable to find projection, cause: {}", e.getLocalizedMessage()); + return Control.Option(); + }) + .get(); } - return query; - } + /** + * Projects a specific type of projection using the provided projection type and expression. + * + * @param the type of the projection result + * @param projectionType the class representing the type of projection result + * @param projection the expression for the projection + * @return a Stream of the projected results based on the specified projection type and + * expression + */ + public Stream project(Class projectionType, String projection) { + var hql = "SELECT %s %s".formatted(projection, generateHql(true)); + return createQuery(projectionType, hql).getResultStream(); + } - private String generateHql(boolean withModifiers) { - if (conditions().isEmpty()) { - logger.warn("Query ran without any filters against {}.", table.getSimpleName()); + /** + * Create and execute a stream query based on the specified conditions. + * + * @return a Stream of the elements resulting from the query execution + */ + public Stream stream() { + var hql = "SELECT DISTINCT e %s".formatted(generateHql(true)); + return createQuery(table, hql).getResultStream(); } - StringBuilder hql = new StringBuilder("FROM %s e".formatted(table.getSimpleName())); - for (var joinTable : joinTables) { - hql.append(" JOIN "); - if (joinTable.fetch()) { - hql.append("FETCH "); - } - hql.append("e.%s".formatted(joinTable.field())); + /** + * Retrieves a single result based on the specified query conditions. If multiple results are + * found, it returns an empty Optional. If no results are found, it logs a trace and returns an + * empty Optional. + * + * @return an Optional containing the single result if found, otherwise an empty Optional + */ + public Optional singleResult() { + var hql = "SELECT DISTINCT e %s".formatted(generateHql(true)); + + return Control.Try(() -> (E) createQuery(table, hql).getSingleResult()) + .map(Control::Option) + .recover(e -> { + logger.trace("Unable to find entity, cause: {}", e.getLocalizedMessage()); + return Control.Option(); + }) + .get(); } - hql.append(" WHERE 1=1 "); - for (var condition : conditions()) { - hql.append(" AND ").append(condition.hqlExpression()); + /** + * Retrieves a paginated result page based on the specified query conditions. + * + * @return a ResultPage containing elements resulting from the query execution paginated based + * on the provided conditions + */ + public ResultPage paged() { + var countHql = "SELECT count(DISTINCT e.id) %s".formatted(generateHql(false)); + var query = entityManager.createQuery(countHql, Long.class); + for (var condition : conditions()) { + condition.addParameters(query); + } + + var numberOfRecords = query.getSingleResult(); + var limit = limitRows == null ? Integer.MAX_VALUE : limitRows; + if (numberOfRecords > 0) { + // only run the actual query if we had hits in the count query. + return new ResultPageImpl( + stream().collect(ReactiveEntityManager.sequenceCollector()), + limit, + numberOfRecords); + } + + return new ResultPageImpl(Collections.List(), limit, 0); } - if (withModifiers) { - if (!groupings.isEmpty()) { - hql.append(" GROUP BY "); - for (var field : groupings) { - hql.append(field.hqlExpression()).append(","); + private TypedQuery createQuery(Class resultType, String hql) { + logger.trace("Running query, with base table {}: {}", table.getSimpleName(), hql); + var query = entityManager.createQuery(hql, resultType); + for (var condition : conditions()) { + condition.addParameters(query); + } + + if (skipRows != null) { + query.setFirstResult(skipRows); + } + if (limitRows != null) { + query.setMaxResults(limitRows); } - hql.deleteCharAt(hql.length() - 1); - } - if (orderBy != null) { - hql.append(" ORDER BY e.%s %s".formatted(orderBy, orderAscending ? "ASC" : "DESC")); - } + return query; } - return hql.toString(); - } + private String generateHql(boolean withModifiers) { + if (conditions().isEmpty()) { + logger.warn("Query ran without any filters against {}.", table.getSimpleName()); + } + + StringBuilder hql = new StringBuilder("FROM %s e".formatted(table.getSimpleName())); + for (var joinTable : joinTables) { + hql.append(" JOIN "); + if (joinTable.fetch()) { + hql.append("FETCH "); + } + hql.append("e.%s".formatted(joinTable.field())); + } + + hql.append(" WHERE 1=1 "); + for (var condition : conditions()) { + hql.append(" AND ").append(condition.hqlExpression()); + } + + if (withModifiers) { + if (!groupings.isEmpty()) { + hql.append(" GROUP BY "); + for (var field : groupings) { + hql.append(field.hqlExpression()).append(","); + } + hql.deleteCharAt(hql.length() - 1); + } + + if (orderBy != null) { + hql.append(" ORDER BY e.%s %s".formatted(orderBy, orderAscending ? "ASC" : "DESC")); + } + } + + return hql.toString(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaUpdate.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaUpdate.java index 0988d941..fc710e97 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaUpdate.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/JpaUpdate.java @@ -1,57 +1,60 @@ package com.jongsoft.finance.jpa.query; import com.jongsoft.finance.jpa.query.expression.Expressions; + import jakarta.persistence.EntityManager; -import java.util.HashMap; -import java.util.Map; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.HashMap; +import java.util.Map; + public class JpaUpdate extends BaseQuery> { - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final EntityManager entityManager; - private final Class entityType; - private final Map updateFields; - - JpaUpdate(EntityManager entityManager, Class entityType) { - super(null); - this.entityManager = entityManager; - this.entityType = entityType; - updateFields = new HashMap<>(); - } - - public JpaUpdate set(String field, V value) { - updateFields.put(field, Expressions.value(value)); - return this; - } - - public JpaUpdate set(String field, ComputationExpression computation) { - updateFields.put(field, computation); - return this; - } - - public void execute() { - var hql = new StringBuilder("update %s set ".formatted(entityType.getSimpleName())); - for (var x = 0; x < updateFields.size(); x++) { - if (x > 0) { - hql.append(", "); - } - var field = updateFields.keySet().toArray()[x]; - hql.append("%s = %s".formatted(field, updateFields.get(field).hqlExpression())); - } - hql.append(" where 1=1 "); - for (var condition : conditions()) { - hql.append(" AND ").append(condition.hqlExpression()); + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final EntityManager entityManager; + private final Class entityType; + private final Map updateFields; + + JpaUpdate(EntityManager entityManager, Class entityType) { + super(null); + this.entityManager = entityManager; + this.entityType = entityType; + updateFields = new HashMap<>(); } - logger.trace("Running update on {}: {}", entityType.getSimpleName(), hql); + public JpaUpdate set(String field, V value) { + updateFields.put(field, Expressions.value(value)); + return this; + } - var query = entityManager.createQuery(hql.toString()); - updateFields.values().forEach(expression -> expression.addParameters(query)); - conditions().forEach(condition -> condition.addParameters(query)); + public JpaUpdate set(String field, ComputationExpression computation) { + updateFields.put(field, computation); + return this; + } - query.executeUpdate(); - } + public void execute() { + var hql = new StringBuilder("update %s set ".formatted(entityType.getSimpleName())); + for (var x = 0; x < updateFields.size(); x++) { + if (x > 0) { + hql.append(", "); + } + var field = updateFields.keySet().toArray()[x]; + hql.append("%s = %s".formatted(field, updateFields.get(field).hqlExpression())); + } + hql.append(" where 1=1 "); + for (var condition : conditions()) { + hql.append(" AND ").append(condition.hqlExpression()); + } + + logger.trace("Running update on {}: {}", entityType.getSimpleName(), hql); + + var query = entityManager.createQuery(hql.toString()); + updateFields.values().forEach(expression -> expression.addParameters(query)); + conditions().forEach(condition -> condition.addParameters(query)); + + query.executeUpdate(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/Query.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/Query.java index 43eba84b..8973d75a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/Query.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/Query.java @@ -4,120 +4,120 @@ public interface Query> { - /** - * Adds a condition to the query where the specified field is equal to the provided condition. - * - * @param field the field to apply the equality condition - * @param condition the condition value to match - * @return the updated SimpleQuery instance with the specified equality condition added - */ - Q fieldEq(String field, C condition); + /** + * Adds a condition to the query where the specified field is equal to the provided condition. + * + * @param field the field to apply the equality condition + * @param condition the condition value to match + * @return the updated SimpleQuery instance with the specified equality condition added + */ + Q fieldEq(String field, C condition); - /** - * Retrieves a query instance with a conditional check where the specified field's value is - * similar to the provided condition. - * - * @param field the field to apply the similarity condition on - * @param condition the condition value to match for similarity - * @return the updated query instance with the field similarity condition applied - */ - Q fieldLike(String field, String condition); + /** + * Retrieves a query instance with a conditional check where the specified field's value is + * similar to the provided condition. + * + * @param field the field to apply the similarity condition on + * @param condition the condition value to match for similarity + * @return the updated query instance with the field similarity condition applied + */ + Q fieldLike(String field, String condition); - /** - * Retrieves a query instance with a conditional check where the specified field's value falls - * between the provided start and end values. - * - * @param the type of the start and end values - * @param field the field to apply the range condition on - * @param start the starting value of the range - * @param end the ending value of the range - * @return the updated query instance with the field range condition applied - */ - Q fieldBetween(String field, C start, C end); + /** + * Retrieves a query instance with a conditional check where the specified field's value falls + * between the provided start and end values. + * + * @param the type of the start and end values + * @param field the field to apply the range condition on + * @param start the starting value of the range + * @param end the ending value of the range + * @return the updated query instance with the field range condition applied + */ + Q fieldBetween(String field, C start, C end); - /** - * Adds a condition to the query where the specified field is equal to the provided conditions. - * - * @param field the field to apply the equality condition - * @param conditions the condition values to match - * @return the updated instance of the query with the specified equality conditions added - */ - Q fieldEqOneOf(String field, C... conditions); + /** + * Adds a condition to the query where the specified field is equal to the provided conditions. + * + * @param field the field to apply the equality condition + * @param conditions the condition values to match + * @return the updated instance of the query with the specified equality conditions added + */ + Q fieldEqOneOf(String field, C... conditions); - /** - * Adds a condition to the query where the specified field is not equal to any of the provided - * conditions. - * - * @param field the field to apply the not equals condition - * @param conditions the condition values to be checked for inequality - * @return the updated instance of the query with the specified not equals conditions added - */ - Q fieldNotEqOneOf(String field, C... conditions); + /** + * Adds a condition to the query where the specified field is not equal to any of the provided + * conditions. + * + * @param field the field to apply the not equals condition + * @param conditions the condition values to be checked for inequality + * @return the updated instance of the query with the specified not equals conditions added + */ + Q fieldNotEqOneOf(String field, C... conditions); - /** - * Adds a condition to the query where the specified field is greater than or equal to the - * provided condition. - * - * @param field the field to apply the greater than or equal condition - * @param condition the condition value to match - * @return the updated SimpleQuery instance with the specified greater than or equal condition - * added - */ - Q fieldGtOrEq(String field, C condition); + /** + * Adds a condition to the query where the specified field is greater than or equal to the + * provided condition. + * + * @param field the field to apply the greater than or equal condition + * @param condition the condition value to match + * @return the updated SimpleQuery instance with the specified greater than or equal condition + * added + */ + Q fieldGtOrEq(String field, C condition); - /** - * Adds a condition to the query where the specified field is greater than or equal to the - * provided condition. Null-values are also allowed. - * - * @param field the field to apply the greater than or equal condition - * @param condition the condition value to match - * @return the updated SimpleQuery instance with the specified greater than or equal condition - * added - */ - Q fieldGtOrEqNullable(String field, C condition); + /** + * Adds a condition to the query where the specified field is greater than or equal to the + * provided condition. Null-values are also allowed. + * + * @param field the field to apply the greater than or equal condition + * @param condition the condition value to match + * @return the updated SimpleQuery instance with the specified greater than or equal condition + * added + */ + Q fieldGtOrEqNullable(String field, C condition); - /** - * Retrieves a query instance with a conditional check where the specified field's value is less - * than or equal to the provided condition. - * - * @param field the field to apply the less than or equal condition on - * @param condition the condition value to match for the less than or equal comparison - * @return the updated query instance with the field less than or equal condition applied - */ - Q fieldLtOrEq(String field, C condition); + /** + * Retrieves a query instance with a conditional check where the specified field's value is less + * than or equal to the provided condition. + * + * @param field the field to apply the less than or equal condition on + * @param condition the condition value to match for the less than or equal comparison + * @return the updated query instance with the field less than or equal condition applied + */ + Q fieldLtOrEq(String field, C condition); - /** - * Adds a condition to the query where the specified field is null. - * - * @param field the field to check for null value - * @return the updated query instance with the specified null check condition added - */ - Q fieldNull(String field); + /** + * Adds a condition to the query where the specified field is null. + * + * @param field the field to check for null value + * @return the updated query instance with the specified null check condition added + */ + Q fieldNull(String field); - /** - * Adds a condition to the query based on the given BooleanExpression. - * - * @param expression the BooleanExpression to be evaluated for adding a condition to the query - * @return the updated query instance with the specified condition added - */ - Q condition(BooleanExpression expression); + /** + * Adds a condition to the query based on the given BooleanExpression. + * + * @param expression the BooleanExpression to be evaluated for adding a condition to the query + * @return the updated query instance with the specified condition added + */ + Q condition(BooleanExpression expression); - /** - * Executes the given subQueryBuilder only if it exists in the context of the current query. - * - * @param subQueryBuilder the consumer function defining the sub-query to be checked for - * existence - * @return the updated query instance with the existence check condition applied - */ - Q whereExists(Consumer subQueryBuilder); + /** + * Executes the given subQueryBuilder only if it exists in the context of the current query. + * + * @param subQueryBuilder the consumer function defining the sub-query to be checked for + * existence + * @return the updated query instance with the existence check condition applied + */ + Q whereExists(Consumer subQueryBuilder); - /** - * Executes the given subQueryBuilder only if it does not exist in the context of the current - * query. - * - * @param subQueryBuilder the consumer function defining the sub-query to be checked for - * non-existence - * @return the updated query instance with the non-existence check condition applied - */ - Q whereNotExists(Consumer subQueryBuilder); + /** + * Executes the given subQueryBuilder only if it does not exist in the context of the current + * query. + * + * @param subQueryBuilder the consumer function defining the sub-query to be checked for + * non-existence + * @return the updated query instance with the non-existence check condition applied + */ + Q whereNotExists(Consumer subQueryBuilder); } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/ReactiveEntityManager.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/ReactiveEntityManager.java index 00115f5f..a4fa7ef4 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/ReactiveEntityManager.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/ReactiveEntityManager.java @@ -6,88 +6,91 @@ import com.jongsoft.lang.collection.Map; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.collection.support.Collections; + import jakarta.inject.Singleton; import jakarta.persistence.EntityManager; + +import lombok.Getter; + import java.util.ArrayList; import java.util.stream.Collector; -import lombok.Getter; @Singleton public class ReactiveEntityManager { - @Getter - private final EntityManager entityManager; + @Getter + private final EntityManager entityManager; - private final AuthenticationFacade authenticationFacade; + private final AuthenticationFacade authenticationFacade; - ReactiveEntityManager(EntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } + ReactiveEntityManager(EntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } - public void persist(T entity) { - if (entity.getId() == null) { - entityManager.persist(entity); - } else { - entityManager.merge(entity); + public void persist(T entity) { + if (entity.getId() == null) { + entityManager.persist(entity); + } else { + entityManager.merge(entity); + } + entityManager.flush(); } - entityManager.flush(); - } - /** - * Creates a JpaQuery instance for the specified entity type. - * - * @param type The entity class for which to create the query - * @return A new JpaQuery instance configured with the provided entity type and entity manager - */ - public JpaQuery from(Class type) { - return new JpaQuery<>(entityManager, type); - } + /** + * Creates a JpaQuery instance for the specified entity type. + * + * @param type The entity class for which to create the query + * @return A new JpaQuery instance configured with the provided entity type and entity manager + */ + public JpaQuery from(Class type) { + return new JpaQuery<>(entityManager, type); + } - /** - * Creates a JpaUpdate instance for the specified entity type. - * - * @param type The entity class for which to create the update - * @return A new JpaUpdate instance configured with the provided entity type and entity manager - */ - public JpaUpdate update(Class type) { - return new JpaUpdate<>(entityManager, type); - } + /** + * Creates a JpaUpdate instance for the specified entity type. + * + * @param type The entity class for which to create the update + * @return A new JpaUpdate instance configured with the provided entity type and entity manager + */ + public JpaUpdate update(Class type) { + return new JpaUpdate<>(entityManager, type); + } - /** - * Creates a new JpaQuery instance based on the provided JpaFilterBuilder. - * - * @param filterBuilder The JpaFilterBuilder used to construct the query - * @return A new JpaQuery instance configured with the entity manager and entity type extracted - * from the filterBuilder - */ - public JpaQuery from(JpaFilterBuilder filterBuilder) { - var query = new JpaQuery<>(entityManager, filterBuilder.entityType()); - filterBuilder.applyTo(query); - return query; - } + /** + * Creates a new JpaQuery instance based on the provided JpaFilterBuilder. + * + * @param filterBuilder The JpaFilterBuilder used to construct the query + * @return A new JpaQuery instance configured with the entity manager and entity type extracted + * from the filterBuilder + */ + public JpaQuery from(JpaFilterBuilder filterBuilder) { + var query = new JpaQuery<>(entityManager, filterBuilder.entityType()); + filterBuilder.applyTo(query); + return query; + } - public T getDetached(Class type, Map filter) { - var queryBuilder = from(type); - filter.forEach(e -> queryBuilder.fieldEq(e.getFirst(), e.getSecond())); - var entity = queryBuilder.singleResult().get(); - entityManager.detach(entity); - return entity; - } + public T getDetached(Class type, Map filter) { + var queryBuilder = from(type); + filter.forEach(e -> queryBuilder.fieldEq(e.getFirst(), e.getSecond())); + var entity = queryBuilder.singleResult().get(); + entityManager.detach(entity); + return entity; + } - public T getById(Class type, Long id) { - return entityManager.find(type, id); - } + public T getById(Class type, Long id) { + return entityManager.find(type, id); + } - public UserAccountJpa currentUser() { - return from(UserAccountJpa.class) - .fieldEq("username", authenticationFacade.authenticated()) - .singleResult() - .get(); - } + public UserAccountJpa currentUser() { + return from(UserAccountJpa.class) + .fieldEq("username", authenticationFacade.authenticated()) + .singleResult() + .get(); + } - /** Convert a stream to a sequence. */ - public static - Collector, ? extends Sequence> sequenceCollector() { - return Collections.collector(com.jongsoft.lang.Collections::List); - } + /** Convert a stream to a sequence. */ + public static + Collector, ? extends Sequence> sequenceCollector() { + return Collections.collector(com.jongsoft.lang.Collections::List); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/SubQuery.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/SubQuery.java index 2d39044c..da73866a 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/SubQuery.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/SubQuery.java @@ -1,81 +1,82 @@ package com.jongsoft.finance.jpa.query; import com.jongsoft.finance.jpa.query.expression.Expressions; + import jakarta.persistence.Query; public class SubQuery extends BaseQuery implements BooleanExpression { - private final String parentAlias; - private String from; - private String projection; - - SubQuery(String parentAlias, String tableAlias) { - super(tableAlias); - this.projection = "1"; - this.from = ""; - this.parentAlias = parentAlias; - } + private final String parentAlias; + private String from; + private String projection; - public SubQuery from(String field) { - if (parentAlias != null) { - this.from = parentAlias + "."; + SubQuery(String parentAlias, String tableAlias) { + super(tableAlias); + this.projection = "1"; + this.from = ""; + this.parentAlias = parentAlias; } - this.from += field; - return this; - } - public SubQuery from(Class entityType) { - this.from = entityType.getSimpleName(); - return this; - } + public SubQuery from(String field) { + if (parentAlias != null) { + this.from = parentAlias + "."; + } + this.from += field; + return this; + } - public SubQuery fieldEqParentField(String field, String parentField) { - var actualParent = parentAlias == null ? "e." : parentAlias + "."; - condition(Expressions.equals( - Expressions.field(tableAlias() + "." + field), - Expressions.field(actualParent + parentField))); - return this; - } + public SubQuery from(Class entityType) { + this.from = entityType.getSimpleName(); + return this; + } - public SubQuery project(String projection) { - this.projection = projection; - return this; - } + public SubQuery fieldEqParentField(String field, String parentField) { + var actualParent = parentAlias == null ? "e." : parentAlias + "."; + condition(Expressions.equals( + Expressions.field(tableAlias() + "." + field), + Expressions.field(actualParent + parentField))); + return this; + } - @Override - public String hqlExpression() { - if (conditions().isEmpty()) { - throw new IllegalStateException("Cannot create a sub selection without filters."); + public SubQuery project(String projection) { + this.projection = projection; + return this; } - StringBuilder hql = new StringBuilder("(SELECT ") - .append(projection) - .append(" FROM ") - .append(from) - .append(" ") - .append(tableAlias()) - .append(" WHERE "); - hql.append(" 1=1 "); - for (var condition : conditions()) { - hql.append(" AND ").append(condition.hqlExpression()); + @Override + public String hqlExpression() { + if (conditions().isEmpty()) { + throw new IllegalStateException("Cannot create a sub selection without filters."); + } + + StringBuilder hql = new StringBuilder("(SELECT ") + .append(projection) + .append(" FROM ") + .append(from) + .append(" ") + .append(tableAlias()) + .append(" WHERE "); + hql.append(" 1=1 "); + for (var condition : conditions()) { + hql.append(" AND ").append(condition.hqlExpression()); + } + hql.append(")"); + return hql.toString(); } - hql.append(")"); - return hql.toString(); - } - @Override - public void addParameters(Query query) { - for (var condition : conditions()) { - condition.addParameters(query); + @Override + public void addParameters(Query query) { + for (var condition : conditions()) { + condition.addParameters(query); + } } - } - @Override - public BooleanExpression cloneWithAlias(String alias) { - var subQuery = new SubQuery(alias, tableAlias()); - subQuery.projection = this.projection; - subQuery.from(from); - conditions().forEach(condition -> subQuery.conditions().add(condition)); - return subQuery; - } + @Override + public BooleanExpression cloneWithAlias(String alias) { + var subQuery = new SubQuery(alias, tableAlias()); + subQuery.projection = this.projection; + subQuery.from(from); + conditions().forEach(condition -> subQuery.conditions().add(condition)); + return subQuery; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/Expressions.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/Expressions.java index 53655ad3..163e1490 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/Expressions.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/Expressions.java @@ -2,167 +2,174 @@ import com.jongsoft.finance.jpa.query.BooleanExpression; import com.jongsoft.finance.jpa.query.ComputationExpression; + import jakarta.persistence.Query; + import java.security.SecureRandom; public final class Expressions { - private Expressions() {} - - public static BooleanExpression fieldLike(String tableAlias, String field, String value) { - return new FieldCondition( - tableAlias, generateRandomString(), field, FieldEquation.LIKE, "%" + value + "%"); - } - - public static BooleanExpression fieldNull(String tableAlias, String field) { - return new FieldNull(tableAlias, field); - } - - public static BooleanExpression fieldCondition( - String tableAlias, String field, FieldEquation equation, T value) { - return new FieldCondition(tableAlias, generateRandomString(), field, equation, value); - } - - public static BooleanExpression fieldBetween( - String tableAlias, String field, T start, T end) { - return new FieldBetween<>(tableAlias, generateRandomString(), field, start, end); - } - - public static BooleanExpression or(BooleanExpression left, BooleanExpression right) { - return new OrExpression(left, right); - } - - public static BooleanExpression and(BooleanExpression left, BooleanExpression right) { - return new BooleanExpression() { - @Override - public String hqlExpression() { - return "(%s AND %s)".formatted(left.hqlExpression(), right.hqlExpression()); - } - - @Override - public void addParameters(Query query) { - left.addParameters(query); - right.addParameters(query); - } - - @Override - public BooleanExpression cloneWithAlias(String alias) { - return and(left.cloneWithAlias(alias), right.cloneWithAlias(alias)); - } - }; - } - - /** - * Returns a computation expression representing the equality comparison between the left and - * right computation expressions. - * - * @param left the left computation expression to compare - * @param right the right computation expression to compare - * @return a computation expression representing the equality comparison between the left and - * right expressions - */ - public static ComputationExpression equals( - ComputationExpression left, ComputationExpression right) { - return () -> "(%s = %s)".formatted(left.hqlExpression(), right.hqlExpression()); - } - - /** - * Creates a computation expression with the specified value. - * - * @param value the value to be represented in the computation expression - * @return a computation expression representing the specified value - */ - public static ComputationExpression value(T value) { - var encodedKey = "value_%s".formatted(generateRandomString()); - return new ComputationExpression() { - @Override - public String hqlExpression() { - return " :%s ".formatted(encodedKey); - } - - @Override - public void addParameters(Query query) { - query.setParameter(encodedKey, value); - } - }; - } - - /** - * Returns a computation expression representing the specified field. - * - * @param field the field to represent in the computation expression - * @return a computation expression representing the specified field - */ - public static ComputationExpression field(String field) { - return new ComputationExpression() { - @Override - public String hqlExpression() { - return " %s ".formatted(field); - } - ; - }; - } - - /** - * Creates a new computation expression representing a CASE WHEN clause in SQL. - * - * @param expression the boolean expression to evaluate in the CASE WHEN clause - * @param then the computation expression to use when the expression is true - * @param elseValue the computation expression to use when the expression is false - * @return a new computation expression representing the CASE WHEN clause - */ - public static ComputationExpression caseWhen( - BooleanExpression expression, ComputationExpression then, ComputationExpression elseValue) { - return new ComputationExpression() { - @Override - public String hqlExpression() { - return "CASE WHEN %s THEN %s ELSE %s END" - .formatted(expression.hqlExpression(), then.hqlExpression(), elseValue.hqlExpression()); - } - - @Override - public void addParameters(Query query) { - expression.addParameters(query); - then.addParameters(query); - elseValue.addParameters(query); - } - }; - } - - /** - * Creates a new computation expression representing the addition of two computation - * expressions. - * - * @param left the left computation expression to add - * @param right the right computation expression to add - * @return a new computation expression representing the addition of the two computation - * expressions - */ - public static ComputationExpression addition( - ComputationExpression left, ComputationExpression right) { - return new ComputationExpression() { - @Override - public String hqlExpression() { - return "(%s + %s)".formatted(left.hqlExpression(), right.hqlExpression()); - } - - @Override - public void addParameters(Query query) { - left.addParameters(query); - right.addParameters(query); - } - }; - } - - static String generateRandomString() { - var random = new SecureRandom(); - var sb = new StringBuilder(12); - var allowedChard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - - for (int i = 0; i < 12; i++) { - int randomIndex = random.nextInt(allowedChard.length()); - sb.append(allowedChard.charAt(randomIndex)); + private Expressions() {} + + public static BooleanExpression fieldLike(String tableAlias, String field, String value) { + return new FieldCondition( + tableAlias, generateRandomString(), field, FieldEquation.LIKE, "%" + value + "%"); + } + + public static BooleanExpression fieldNull(String tableAlias, String field) { + return new FieldNull(tableAlias, field); + } + + public static BooleanExpression fieldCondition( + String tableAlias, String field, FieldEquation equation, T value) { + return new FieldCondition(tableAlias, generateRandomString(), field, equation, value); + } + + public static BooleanExpression fieldBetween( + String tableAlias, String field, T start, T end) { + return new FieldBetween<>(tableAlias, generateRandomString(), field, start, end); + } + + public static BooleanExpression or(BooleanExpression left, BooleanExpression right) { + return new OrExpression(left, right); + } + + public static BooleanExpression and(BooleanExpression left, BooleanExpression right) { + return new BooleanExpression() { + @Override + public String hqlExpression() { + return "(%s AND %s)".formatted(left.hqlExpression(), right.hqlExpression()); + } + + @Override + public void addParameters(Query query) { + left.addParameters(query); + right.addParameters(query); + } + + @Override + public BooleanExpression cloneWithAlias(String alias) { + return and(left.cloneWithAlias(alias), right.cloneWithAlias(alias)); + } + }; } - return sb.toString(); - } + /** + * Returns a computation expression representing the equality comparison between the left and + * right computation expressions. + * + * @param left the left computation expression to compare + * @param right the right computation expression to compare + * @return a computation expression representing the equality comparison between the left and + * right expressions + */ + public static ComputationExpression equals( + ComputationExpression left, ComputationExpression right) { + return () -> "(%s = %s)".formatted(left.hqlExpression(), right.hqlExpression()); + } + + /** + * Creates a computation expression with the specified value. + * + * @param value the value to be represented in the computation expression + * @return a computation expression representing the specified value + */ + public static ComputationExpression value(T value) { + var encodedKey = "value_%s".formatted(generateRandomString()); + return new ComputationExpression() { + @Override + public String hqlExpression() { + return " :%s ".formatted(encodedKey); + } + + @Override + public void addParameters(Query query) { + query.setParameter(encodedKey, value); + } + }; + } + + /** + * Returns a computation expression representing the specified field. + * + * @param field the field to represent in the computation expression + * @return a computation expression representing the specified field + */ + public static ComputationExpression field(String field) { + return new ComputationExpression() { + @Override + public String hqlExpression() { + return " %s ".formatted(field); + } + ; + }; + } + + /** + * Creates a new computation expression representing a CASE WHEN clause in SQL. + * + * @param expression the boolean expression to evaluate in the CASE WHEN clause + * @param then the computation expression to use when the expression is true + * @param elseValue the computation expression to use when the expression is false + * @return a new computation expression representing the CASE WHEN clause + */ + public static ComputationExpression caseWhen( + BooleanExpression expression, + ComputationExpression then, + ComputationExpression elseValue) { + return new ComputationExpression() { + @Override + public String hqlExpression() { + return "CASE WHEN %s THEN %s ELSE %s END" + .formatted( + expression.hqlExpression(), + then.hqlExpression(), + elseValue.hqlExpression()); + } + + @Override + public void addParameters(Query query) { + expression.addParameters(query); + then.addParameters(query); + elseValue.addParameters(query); + } + }; + } + + /** + * Creates a new computation expression representing the addition of two computation + * expressions. + * + * @param left the left computation expression to add + * @param right the right computation expression to add + * @return a new computation expression representing the addition of the two computation + * expressions + */ + public static ComputationExpression addition( + ComputationExpression left, ComputationExpression right) { + return new ComputationExpression() { + @Override + public String hqlExpression() { + return "(%s + %s)".formatted(left.hqlExpression(), right.hqlExpression()); + } + + @Override + public void addParameters(Query query) { + left.addParameters(query); + right.addParameters(query); + } + }; + } + + static String generateRandomString() { + var random = new SecureRandom(); + var sb = new StringBuilder(12); + var allowedChard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + for (int i = 0; i < 12; i++) { + int randomIndex = random.nextInt(allowedChard.length()); + sb.append(allowedChard.charAt(randomIndex)); + } + + return sb.toString(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldBetween.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldBetween.java index 6e216a03..0491b6ba 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldBetween.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldBetween.java @@ -1,24 +1,26 @@ package com.jongsoft.finance.jpa.query.expression; import com.jongsoft.finance.jpa.query.BooleanExpression; + import jakarta.persistence.Query; record FieldBetween(String tableAlias, String fieldId, String field, T start, T end) - implements BooleanExpression { - @Override - public String hqlExpression() { - var actualAlias = tableAlias != null ? tableAlias + "." : ""; - return " %s%s BETWEEN :%s_start AND :%s_end ".formatted(actualAlias, field, fieldId, fieldId); - } + implements BooleanExpression { + @Override + public String hqlExpression() { + var actualAlias = tableAlias != null ? tableAlias + "." : ""; + return " %s%s BETWEEN :%s_start AND :%s_end " + .formatted(actualAlias, field, fieldId, fieldId); + } - @Override - public void addParameters(Query query) { - query.setParameter("%s_start".formatted(fieldId), start); - query.setParameter("%s_end".formatted(fieldId), end); - } + @Override + public void addParameters(Query query) { + query.setParameter("%s_start".formatted(fieldId), start); + query.setParameter("%s_end".formatted(fieldId), end); + } - @Override - public BooleanExpression cloneWithAlias(String alias) { - return new FieldBetween<>(alias, fieldId, field, start, end); - } + @Override + public BooleanExpression cloneWithAlias(String alias) { + return new FieldBetween<>(alias, fieldId, field, start, end); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldCondition.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldCondition.java index 599f40c5..49b3d0c2 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldCondition.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldCondition.java @@ -1,46 +1,48 @@ package com.jongsoft.finance.jpa.query.expression; import com.jongsoft.finance.jpa.query.BooleanExpression; + import jakarta.persistence.Query; + import java.util.function.Supplier; record FieldCondition( - String tableAlias, String fieldId, String field, FieldEquation equation, Object value) - implements BooleanExpression { - @Override - public String hqlExpression() { - var actualAlias = tableAlias != null ? tableAlias + "." : ""; - var comparator = - switch (equation) { - case EQ -> "="; - case GTE -> ">="; - case LTE -> "<="; - case LT -> "<"; - case IN, NIN -> ""; - case LIKE -> "LIKE"; - }; - - Supplier fieldFunc = () -> "%s%s".formatted(actualAlias, field); - if (value instanceof String && equation == FieldEquation.LIKE) { - fieldFunc = () -> "lower(%s%s)".formatted(actualAlias, field); - } + String tableAlias, String fieldId, String field, FieldEquation equation, Object value) + implements BooleanExpression { + @Override + public String hqlExpression() { + var actualAlias = tableAlias != null ? tableAlias + "." : ""; + var comparator = + switch (equation) { + case EQ -> "="; + case GTE -> ">="; + case LTE -> "<="; + case LT -> "<"; + case IN, NIN -> ""; + case LIKE -> "LIKE"; + }; - if (equation == FieldEquation.IN) { - return " %s IN(:%s) ".formatted(fieldFunc.get(), fieldId); - } else if (equation == FieldEquation.NIN) { - return " %s NOT IN(:%s) ".formatted(fieldFunc.get(), fieldId); - } + Supplier fieldFunc = () -> "%s%s".formatted(actualAlias, field); + if (value instanceof String && equation == FieldEquation.LIKE) { + fieldFunc = () -> "lower(%s%s)".formatted(actualAlias, field); + } - return " %s %s :%s ".formatted(fieldFunc.get(), comparator, fieldId); - } + if (equation == FieldEquation.IN) { + return " %s IN(:%s) ".formatted(fieldFunc.get(), fieldId); + } else if (equation == FieldEquation.NIN) { + return " %s NOT IN(:%s) ".formatted(fieldFunc.get(), fieldId); + } - @Override - public void addParameters(Query query) { - query.setParameter(fieldId, value); - } + return " %s %s :%s ".formatted(fieldFunc.get(), comparator, fieldId); + } + + @Override + public void addParameters(Query query) { + query.setParameter(fieldId, value); + } - @Override - public BooleanExpression cloneWithAlias(String alias) { - return new FieldCondition(alias, fieldId, field, equation, value); - } + @Override + public BooleanExpression cloneWithAlias(String alias) { + return new FieldCondition(alias, fieldId, field, equation, value); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldEquation.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldEquation.java index a9f6fff5..91c26a8c 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldEquation.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldEquation.java @@ -7,25 +7,25 @@ */ public enum FieldEquation { - /** - * Enumeration representing the field equation option for equality comparison in expressions. - */ - EQ, - /** - * Represents the field equation option for greater than or equal to comparison in expressions. - */ - GTE, - /** Represents the field equation option for less than to comparison in expressions. */ - LT, - /** - * Enumeration representing the field equation option for less than or equal to comparison in - * expressions. - */ - LTE, - /** Represents the "IN" field equation option for inclusion checks in expressions. */ - IN, - /** Represents exclusion field equation for expressions. */ - NIN, - /** Represents the field equation option for string pattern matching in expressions. */ - LIKE; + /** + * Enumeration representing the field equation option for equality comparison in expressions. + */ + EQ, + /** + * Represents the field equation option for greater than or equal to comparison in expressions. + */ + GTE, + /** Represents the field equation option for less than to comparison in expressions. */ + LT, + /** + * Enumeration representing the field equation option for less than or equal to comparison in + * expressions. + */ + LTE, + /** Represents the "IN" field equation option for inclusion checks in expressions. */ + IN, + /** Represents exclusion field equation for expressions. */ + NIN, + /** Represents the field equation option for string pattern matching in expressions. */ + LIKE; } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldNull.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldNull.java index 1c1a75ee..dfd63f23 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldNull.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/FieldNull.java @@ -3,14 +3,14 @@ import com.jongsoft.finance.jpa.query.BooleanExpression; record FieldNull(String tableAlias, String field) implements BooleanExpression { - @Override - public String hqlExpression() { - var actualAlias = tableAlias != null ? tableAlias + "." : ""; - return " %s%s IS NULL ".formatted(actualAlias, field); - } + @Override + public String hqlExpression() { + var actualAlias = tableAlias != null ? tableAlias + "." : ""; + return " %s%s IS NULL ".formatted(actualAlias, field); + } - @Override - public BooleanExpression cloneWithAlias(String alias) { - return new FieldNull(alias, field); - } + @Override + public BooleanExpression cloneWithAlias(String alias) { + return new FieldNull(alias, field); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/OrExpression.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/OrExpression.java index 3baaaad9..5b859456 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/OrExpression.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/query/expression/OrExpression.java @@ -1,23 +1,24 @@ package com.jongsoft.finance.jpa.query.expression; import com.jongsoft.finance.jpa.query.BooleanExpression; + import jakarta.persistence.Query; public record OrExpression(BooleanExpression left, BooleanExpression right) - implements BooleanExpression { - @Override - public String hqlExpression() { - return "(%s OR %s)".formatted(left.hqlExpression(), right.hqlExpression()); - } + implements BooleanExpression { + @Override + public String hqlExpression() { + return "(%s OR %s)".formatted(left.hqlExpression(), right.hqlExpression()); + } - @Override - public void addParameters(Query query) { - left.addParameters(query); - right.addParameters(query); - } + @Override + public void addParameters(Query query) { + left.addParameters(query); + right.addParameters(query); + } - @Override - public BooleanExpression cloneWithAlias(String alias) { - return new OrExpression(left.cloneWithAlias(alias), right.cloneWithAlias(alias)); - } + @Override + public BooleanExpression cloneWithAlias(String alias) { + return new OrExpression(left.cloneWithAlias(alias), right.cloneWithAlias(alias)); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeConditionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeConditionHandler.java index 3176a86c..df3d4d34 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeConditionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeConditionHandler.java @@ -4,9 +4,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.rule.ChangeConditionCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -14,24 +17,24 @@ @Transactional public class ChangeConditionHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public ChangeConditionHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeConditionCommand command) { - log.info("[{}] - Updating rule condition", command.id()); - - entityManager - .update(RuleConditionJpa.class) - .set("operation", command.operation()) - .set("condition", command.condition()) - .set("field", command.field()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public ChangeConditionHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeConditionCommand command) { + log.info("[{}] - Updating rule condition", command.id()); + + entityManager + .update(RuleConditionJpa.class) + .set("operation", command.operation()) + .set("condition", command.condition()) + .set("field", command.field()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeRuleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeRuleHandler.java index fac99843..494cb152 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeRuleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ChangeRuleHandler.java @@ -4,9 +4,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.rule.ChangeRuleCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -14,23 +17,23 @@ @Transactional public class ChangeRuleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public ChangeRuleHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeRuleCommand command) { - log.info("[{}] - Updating rule change", command.id()); - - entityManager - .update(RuleChangeJpa.class) - .set("field", command.column()) - .set("`value`", command.change()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public ChangeRuleHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeRuleCommand command) { + log.info("[{}] - Updating rule change", command.id()); + + entityManager + .update(RuleChangeJpa.class) + .set("field", command.column()) + .set("`value`", command.change()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/CreateRuleGroupHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/CreateRuleGroupHandler.java index 9e975f0d..0371f530 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/CreateRuleGroupHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/CreateRuleGroupHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.rule.CreateRuleGroupCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,33 +18,33 @@ @Transactional public class CreateRuleGroupHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public CreateRuleGroupHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(CreateRuleGroupCommand command) { - log.info("[{}] - Processing rule group create event", command.name()); - - var currentMax = entityManager - .from(RuleGroupJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .projectSingleValue(Integer.class, "max(sort)"); - - var jpaEntity = RuleGroupJpa.builder() - .name(command.name()) - .user(entityManager.currentUser()) - .sort(currentMax.getOrSupply(() -> 1)) - .build(); - - entityManager.persist(jpaEntity); - } + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public CreateRuleGroupHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(CreateRuleGroupCommand command) { + log.info("[{}] - Processing rule group create event", command.name()); + + var currentMax = entityManager + .from(RuleGroupJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .projectSingleValue(Integer.class, "max(sort)"); + + var jpaEntity = RuleGroupJpa.builder() + .name(command.name()) + .user(entityManager.currentUser()) + .sort(currentMax.getOrSupply(() -> 1)) + .build(); + + entityManager.persist(jpaEntity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RenameRuleGroupHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RenameRuleGroupHandler.java index 21704283..7c43d273 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RenameRuleGroupHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RenameRuleGroupHandler.java @@ -4,9 +4,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.rule.RenameRuleGroupCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -14,22 +17,22 @@ @Transactional public class RenameRuleGroupHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public RenameRuleGroupHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public RenameRuleGroupHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(RenameRuleGroupCommand command) { - log.info("[{}] - Processing rule group rename event", command.id()); + @Override + @BusinessEventListener + public void handle(RenameRuleGroupCommand command) { + log.info("[{}] - Processing rule group rename event", command.id()); - entityManager - .update(RuleGroupJpa.class) - .set("name", command.name()) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(RuleGroupJpa.class) + .set("name", command.name()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleGroupHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleGroupHandler.java index 465358af..b1340899 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleGroupHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleGroupHandler.java @@ -7,9 +7,12 @@ import com.jongsoft.finance.messaging.commands.rule.ReorderRuleGroupCommand; import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Collections; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,44 +20,43 @@ @Transactional public class ReorderRuleGroupHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public ReorderRuleGroupHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(ReorderRuleGroupCommand command) { - log.info("[{}] - Processing rule group sorting event", command.id()); - - var jpaEntity = - entityManager.getDetached(RuleGroupJpa.class, Collections.Map("id", command.id())); - - var updateQuery = entityManager - .update(RuleGroupJpa.class) - .fieldIn("id", RuleGroupJpa.class, subQuery -> subQuery - .project("id") - .fieldEq("user.username", authenticationFacade.authenticated())); - if ((command.sort() - jpaEntity.getSort()) < 0) { - updateQuery.fieldBetween("sort", command.sort(), jpaEntity.getSort()); - updateQuery.set( - "sort", Expressions.addition(Expressions.field("sort"), Expressions.value(1))); - } else { - updateQuery.fieldBetween("sort", jpaEntity.getSort(), command.sort()); - updateQuery.set( - "sort", Expressions.addition(Expressions.field("sort"), Expressions.value(-1))); + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public ReorderRuleGroupHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(ReorderRuleGroupCommand command) { + log.info("[{}] - Processing rule group sorting event", command.id()); + + var jpaEntity = + entityManager.getDetached(RuleGroupJpa.class, Collections.Map("id", command.id())); + + var updateQuery = entityManager + .update(RuleGroupJpa.class) + .fieldIn("id", RuleGroupJpa.class, subQuery -> subQuery.project("id") + .fieldEq("user.username", authenticationFacade.authenticated())); + if ((command.sort() - jpaEntity.getSort()) < 0) { + updateQuery.fieldBetween("sort", command.sort(), jpaEntity.getSort()); + updateQuery.set( + "sort", Expressions.addition(Expressions.field("sort"), Expressions.value(1))); + } else { + updateQuery.fieldBetween("sort", jpaEntity.getSort(), command.sort()); + updateQuery.set( + "sort", Expressions.addition(Expressions.field("sort"), Expressions.value(-1))); + } + updateQuery.execute(); + + entityManager + .update(RuleGroupJpa.class) + .set("sort", command.sort()) + .fieldEq("id", command.id()) + .execute(); } - updateQuery.execute(); - - entityManager - .update(RuleGroupJpa.class) - .set("sort", command.sort()) - .fieldEq("id", command.id()) - .execute(); - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleHandler.java index 42a2c77a..dccb4edb 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/ReorderRuleHandler.java @@ -7,9 +7,12 @@ import com.jongsoft.finance.messaging.commands.rule.ReorderRuleCommand; import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Collections; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,45 +20,49 @@ @Transactional public class ReorderRuleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public ReorderRuleHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(ReorderRuleCommand command) { - log.info("[{}] - Processing transaction rule sort event", command.id()); - - var jpaEntity = entityManager.getDetached(RuleJpa.class, Collections.Map("id", command.id())); - - var updateQuery = entityManager - .update(RuleJpa.class) - .fieldIn("id", RuleJpa.class, subQuery -> subQuery - .project("id") - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("group.name", jpaEntity.getGroup().getName())); - - if ((command.sort() - jpaEntity.getSort()) < 0) { - updateQuery - .set("sort", Expressions.addition(Expressions.field("sort"), Expressions.value(1))) - .fieldBetween("sort", command.sort(), jpaEntity.getSort()); - } else { - updateQuery - .set("sort", Expressions.addition(Expressions.field("sort"), Expressions.value(-1))) - .fieldBetween("sort", jpaEntity.getSort(), command.sort()); + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public ReorderRuleHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(ReorderRuleCommand command) { + log.info("[{}] - Processing transaction rule sort event", command.id()); + + var jpaEntity = + entityManager.getDetached(RuleJpa.class, Collections.Map("id", command.id())); + + var updateQuery = entityManager + .update(RuleJpa.class) + .fieldIn("id", RuleJpa.class, subQuery -> subQuery.project("id") + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("group.name", jpaEntity.getGroup().getName())); + + if ((command.sort() - jpaEntity.getSort()) < 0) { + updateQuery + .set( + "sort", + Expressions.addition(Expressions.field("sort"), Expressions.value(1))) + .fieldBetween("sort", command.sort(), jpaEntity.getSort()); + } else { + updateQuery + .set( + "sort", + Expressions.addition(Expressions.field("sort"), Expressions.value(-1))) + .fieldBetween("sort", jpaEntity.getSort(), command.sort()); + } + updateQuery.execute(); + + entityManager + .update(RuleJpa.class) + .set("sort", command.sort()) + .fieldEq("id", command.id()) + .execute(); } - updateQuery.execute(); - - entityManager - .update(RuleJpa.class) - .set("sort", command.sort()) - .fieldEq("id", command.id()) - .execute(); - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleChangeJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleChangeJpa.java index cd5684e0..05af4d93 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleChangeJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleChangeJpa.java @@ -2,7 +2,9 @@ import com.jongsoft.finance.core.RuleColumn; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.*; + import lombok.Builder; import lombok.Getter; @@ -11,25 +13,25 @@ @Table(name = "rule_change") public class RuleChangeJpa extends EntityJpa { - @Enumerated(value = EnumType.STRING) - private RuleColumn field; + @Enumerated(value = EnumType.STRING) + private RuleColumn field; - @Column(name = "change_val") - private String value; + @Column(name = "change_val") + private String value; - @ManyToOne - @JoinColumn - private RuleJpa rule; + @ManyToOne + @JoinColumn + private RuleJpa rule; - public RuleChangeJpa() { - super(); - } + public RuleChangeJpa() { + super(); + } - @Builder - private RuleChangeJpa(Long id, RuleColumn field, String value, RuleJpa rule) { - super(id); - this.field = field; - this.value = value; - this.rule = rule; - } + @Builder + private RuleChangeJpa(Long id, RuleColumn field, String value, RuleJpa rule) { + super(id); + this.field = field; + this.value = value; + this.rule = rule; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleConditionJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleConditionJpa.java index 6cb003af..0cfade83 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleConditionJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleConditionJpa.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.core.RuleColumn; import com.jongsoft.finance.core.RuleOperation; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -10,6 +11,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -18,28 +20,28 @@ @Table(name = "rule_condition") public class RuleConditionJpa extends EntityJpa { - @Enumerated(value = EnumType.STRING) - private RuleColumn field; + @Enumerated(value = EnumType.STRING) + private RuleColumn field; - @Enumerated(value = EnumType.STRING) - private RuleOperation operation; + @Enumerated(value = EnumType.STRING) + private RuleOperation operation; - @Column(name = "cond_value") - private String condition; + @Column(name = "cond_value") + private String condition; - @ManyToOne - @JoinColumn - private RuleJpa rule; + @ManyToOne + @JoinColumn + private RuleJpa rule; - @Builder - private RuleConditionJpa( - Long id, RuleColumn field, RuleOperation operation, String condition, RuleJpa rule) { - super(id); - this.field = field; - this.operation = operation; - this.condition = condition; - this.rule = rule; - } + @Builder + private RuleConditionJpa( + Long id, RuleColumn field, RuleOperation operation, String condition, RuleJpa rule) { + super(id); + this.field = field; + this.operation = operation; + this.condition = condition; + this.rule = rule; + } - public RuleConditionJpa() {} + public RuleConditionJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupDeleteHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupDeleteHandler.java index da331027..5e2b2d2c 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupDeleteHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupDeleteHandler.java @@ -3,8 +3,11 @@ import com.jongsoft.finance.annotation.BusinessEventListener; import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.commands.rule.RuleGroupDeleteCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Singleton; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,22 +15,22 @@ @Transactional class RuleGroupDeleteHandler { - private static final Logger log = LoggerFactory.getLogger(RuleGroupDeleteHandler.class); + private static final Logger log = LoggerFactory.getLogger(RuleGroupDeleteHandler.class); - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - RuleGroupDeleteHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + RuleGroupDeleteHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @BusinessEventListener - void handle(RuleGroupDeleteCommand command) { - log.info("[{}] - Processing rule group delete event", command.id()); + @BusinessEventListener + void handle(RuleGroupDeleteCommand command) { + log.info("[{}] - Processing rule group delete event", command.id()); - entityManager - .update(RuleGroupJpa.class) - .set("archived", true) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(RuleGroupJpa.class) + .set("archived", true) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupJpa.java index f7a99cc2..ccffd46d 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleGroupJpa.java @@ -2,10 +2,12 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -14,21 +16,21 @@ @Table(name = "rule_group") public class RuleGroupJpa extends EntityJpa { - private String name; - private int sort; - private boolean archived; + private String name; + private int sort; + private boolean archived; - @ManyToOne - @JoinColumn - private UserAccountJpa user; + @ManyToOne + @JoinColumn + private UserAccountJpa user; - @Builder - private RuleGroupJpa(String name, int sort, boolean archived, UserAccountJpa user) { - this.name = name; - this.sort = sort; - this.archived = archived; - this.user = user; - } + @Builder + private RuleGroupJpa(String name, int sort, boolean archived, UserAccountJpa user) { + this.name = name; + this.sort = sort; + this.archived = archived; + this.user = user; + } - public RuleGroupJpa() {} + public RuleGroupJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleJpa.java index a7734138..237e820b 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleJpa.java @@ -2,76 +2,79 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; -import java.util.List; + import lombok.Builder; import lombok.Getter; +import java.util.List; + @Getter @Entity @Table(name = "rule") public class RuleJpa extends EntityJpa { - private String name; - private String description; - private boolean restrictive; - private boolean active; - private boolean archived; - private int sort; + private String name; + private String description; + private boolean restrictive; + private boolean active; + private boolean archived; + private int sort; - @ManyToOne - @JoinColumn - private UserAccountJpa user; + @ManyToOne + @JoinColumn + private UserAccountJpa user; - @ManyToOne - @JoinColumn - private RuleGroupJpa group; + @ManyToOne + @JoinColumn + private RuleGroupJpa group; - @OneToMany( - mappedBy = "rule", - orphanRemoval = true, - cascade = {CascadeType.ALL}) - private List conditions; + @OneToMany( + mappedBy = "rule", + orphanRemoval = true, + cascade = {CascadeType.ALL}) + private List conditions; - @OneToMany( - mappedBy = "rule", - orphanRemoval = true, - cascade = {CascadeType.ALL}) - private List changes; + @OneToMany( + mappedBy = "rule", + orphanRemoval = true, + cascade = {CascadeType.ALL}) + private List changes; - @Builder - private RuleJpa( - Long id, - String name, - String description, - boolean restrictive, - boolean active, - boolean archived, - int sort, - UserAccountJpa user, - RuleGroupJpa group, - List conditions, - List changes) { - super(id); - this.name = name; - this.description = description; - this.restrictive = restrictive; - this.active = active; - this.archived = archived; - this.sort = sort; - this.user = user; - this.group = group; - this.conditions = conditions; - this.changes = changes; - } + @Builder + private RuleJpa( + Long id, + String name, + String description, + boolean restrictive, + boolean active, + boolean archived, + int sort, + UserAccountJpa user, + RuleGroupJpa group, + List conditions, + List changes) { + super(id); + this.name = name; + this.description = description; + this.restrictive = restrictive; + this.active = active; + this.archived = archived; + this.sort = sort; + this.user = user; + this.group = group; + this.conditions = conditions; + this.changes = changes; + } - public RuleJpa() {} + public RuleJpa() {} - public void setConditions(final List conditions) { - this.conditions = conditions; - } + public void setConditions(final List conditions) { + this.conditions = conditions; + } - public void setChanges(final List changes) { - this.changes = changes; - } + public void setChanges(final List changes) { + this.changes = changes; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleRemovedHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleRemovedHandler.java new file mode 100644 index 00000000..a2e1a11d --- /dev/null +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/RuleRemovedHandler.java @@ -0,0 +1,42 @@ +package com.jongsoft.finance.jpa.rule; + +import com.jongsoft.finance.annotation.BusinessEventListener; +import com.jongsoft.finance.jpa.query.ReactiveEntityManager; +import com.jongsoft.finance.messaging.CommandHandler; +import com.jongsoft.finance.messaging.commands.rule.RuleRemovedCommand; +import com.jongsoft.finance.security.AuthenticationFacade; + +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +@Transactional +class RuleRemovedHandler implements CommandHandler { + + private final Logger logger; + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + RuleRemovedHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + this.logger = LoggerFactory.getLogger(RuleRemovedHandler.class); + } + + @Override + @BusinessEventListener + public void handle(RuleRemovedCommand command) { + logger.info("[{}] - Processing rule delete event.", command.ruleId()); + + entityManager + .update(RuleJpa.class) + .set("archived", true) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("id", command.ruleId()) + .execute(); + } +} diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpa.java index 7684b2ec..2a19c1cc 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpa.java @@ -6,10 +6,13 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -18,51 +21,51 @@ @Named("transactionRuleGroupProvider") public class TransactionRuleGroupProviderJpa implements TransactionRuleGroupProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public TransactionRuleGroupProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; - @Override - public Sequence lookup() { - log.trace("TransactionRuleGroup listing"); + @Inject + public TransactionRuleGroupProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } - return entityManager - .from(RuleGroupJpa.class) - .fieldEq("archived", false) - .fieldEq("user.username", authenticationFacade.authenticated()) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } + @Override + public Sequence lookup() { + log.trace("TransactionRuleGroup listing"); - @Override - public Optional lookup(String name) { - log.trace("TransactionRuleGroup lookup with name: {}", name); + return entityManager + .from(RuleGroupJpa.class) + .fieldEq("archived", false) + .fieldEq("user.username", authenticationFacade.authenticated()) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } - return entityManager - .from(RuleGroupJpa.class) - .fieldEq("archived", false) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } + @Override + public Optional lookup(String name) { + log.trace("TransactionRuleGroup lookup with name: {}", name); - private TransactionRuleGroup convert(RuleGroupJpa source) { - if (source == null) { - return null; + return entityManager + .from(RuleGroupJpa.class) + .fieldEq("archived", false) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); } - return TransactionRuleGroup.builder() - .id(source.getId()) - .name(source.getName()) - .sort(source.getSort()) - .build(); - } + private TransactionRuleGroup convert(RuleGroupJpa source) { + if (source == null) { + return null; + } + + return TransactionRuleGroup.builder() + .id(source.getId()) + .name(source.getName()) + .sort(source.getSort()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpa.java index 15576ef6..45b53752 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpa.java @@ -12,172 +12,177 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Collections; import com.jongsoft.lang.collection.Sequence; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import java.util.Optional; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Optional; + @Singleton @Transactional @RequiresJpa @Named("transactionRuleProvider") public class TransactionRuleProviderJpa implements TransactionRuleProvider { - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public TransactionRuleProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Sequence lookup() { - logger.trace("Listing all transaction rules."); - - return entityManager - .from(RuleJpa.class) - .joinFetch("user") - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .orderBy("sort", true) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public com.jongsoft.lang.control.Optional lookup(long id) { - logger.trace("Looking up transaction rule with id {}.", id); - - return entityManager - .from(RuleJpa.class) - .fieldEq("id", id) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public Sequence lookup(String group) { - logger.trace("Listing all transaction rules in group {}.", group); - - return entityManager - .from(RuleJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("group.name", group) - .fieldEq("archived", false) - .orderBy("sort", true) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public void save(TransactionRule rule) { - int sortOrder = rule.getSort(); - if (rule.getId() == null || rule.getSort() == 0) { - sortOrder = entityManager - .from(RuleJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("group.name", rule.getGroup()) - .projectSingleValue(Integer.class, "max(sort)") - .getOrSupply(() -> 1); + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + @Inject + public TransactionRuleProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Sequence lookup() { + logger.trace("Listing all transaction rules."); + + return entityManager + .from(RuleJpa.class) + .joinFetch("user") + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .orderBy("sort", true) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } + + @Override + public com.jongsoft.lang.control.Optional lookup(long id) { + logger.trace("Looking up transaction rule with id {}.", id); + + return entityManager + .from(RuleJpa.class) + .fieldEq("id", id) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); + } + + @Override + public Sequence lookup(String group) { + logger.trace("Listing all transaction rules in group {}.", group); + + return entityManager + .from(RuleJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("group.name", group) + .fieldEq("archived", false) + .orderBy("sort", true) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); + } + + @Override + public void save(TransactionRule rule) { + int sortOrder = rule.getSort(); + if (rule.getId() == null || rule.getSort() == 0) { + sortOrder = entityManager + .from(RuleJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("group.name", rule.getGroup()) + .projectSingleValue(Integer.class, "max(sort)") + .getOrSupply(() -> 1); + } + + var ruleJpa = RuleJpa.builder() + .id(rule.getId()) + .name(rule.getName()) + .description(rule.getDescription()) + .restrictive(rule.isRestrictive()) + .active(rule.isActive()) + .user(activeUser()) + .archived(rule.isDeleted()) + .group(group(rule.getGroup())) + .sort(sortOrder) + .build(); + + ruleJpa.setConditions(convertConditions(ruleJpa, Collections.List(rule.getConditions())) + .toJava()); + ruleJpa.setChanges( + convertChanges(ruleJpa, Collections.List(rule.getChanges())).toJava()); + + entityManager.persist(ruleJpa); + } + + protected TransactionRule convert(RuleJpa source) { + if (source == null) { + return null; + } + + var rule = TransactionRule.builder() + .id(source.getId()) + .name(source.getName()) + .restrictive(source.isRestrictive()) + .user(UserAccount.builder() + .id(source.getUser().getId()) + .username(new UserIdentifier(source.getUser().getUsername())) + .build()) + .description(source.getDescription()) + .active(source.isActive()) + .group(Optional.ofNullable(source.getGroup()) + .map(RuleGroupJpa::getName) + .orElse(null)) + .sort(source.getSort()) + .build(); + + source.getConditions() + .forEach(c -> rule + .new Condition(c.getId(), c.getField(), c.getOperation(), c.getCondition())); + source.getChanges().forEach(c -> rule.new Change(c.getId(), c.getField(), c.getValue())); + + return rule; + } + + private Sequence convertChanges( + RuleJpa rule, Sequence changes) { + return changes.map(c -> RuleChangeJpa.builder() + .id(c.getId()) + .rule(rule) + .field(c.getField()) + .value(c.getChange()) + .build()); } - var ruleJpa = RuleJpa.builder() - .id(rule.getId()) - .name(rule.getName()) - .description(rule.getDescription()) - .restrictive(rule.isRestrictive()) - .active(rule.isActive()) - .user(activeUser()) - .archived(rule.isDeleted()) - .group(group(rule.getGroup())) - .sort(sortOrder) - .build(); - - ruleJpa.setConditions( - convertConditions(ruleJpa, Collections.List(rule.getConditions())).toJava()); - ruleJpa.setChanges( - convertChanges(ruleJpa, Collections.List(rule.getChanges())).toJava()); - - entityManager.persist(ruleJpa); - } - - protected TransactionRule convert(RuleJpa source) { - if (source == null) { - return null; + private Sequence convertConditions( + RuleJpa rule, Sequence conditions) { + return conditions.map(c -> RuleConditionJpa.builder() + .id(c.getId()) + .field(c.getField()) + .rule(rule) + .operation(c.getOperation()) + .condition(c.getCondition()) + .build()); } - var rule = TransactionRule.builder() - .id(source.getId()) - .name(source.getName()) - .restrictive(source.isRestrictive()) - .user(UserAccount.builder() - .id(source.getUser().getId()) - .username(new UserIdentifier(source.getUser().getUsername())) - .build()) - .description(source.getDescription()) - .active(source.isActive()) - .group(Optional.ofNullable(source.getGroup()).map(RuleGroupJpa::getName).orElse(null)) - .sort(source.getSort()) - .build(); - - source - .getConditions() - .forEach( - c -> rule.new Condition(c.getId(), c.getField(), c.getOperation(), c.getCondition())); - source.getChanges().forEach(c -> rule.new Change(c.getId(), c.getField(), c.getValue())); - - return rule; - } - - private Sequence convertChanges( - RuleJpa rule, Sequence changes) { - return changes.map(c -> RuleChangeJpa.builder() - .id(c.getId()) - .rule(rule) - .field(c.getField()) - .value(c.getChange()) - .build()); - } - - private Sequence convertConditions( - RuleJpa rule, Sequence conditions) { - return conditions.map(c -> RuleConditionJpa.builder() - .id(c.getId()) - .field(c.getField()) - .rule(rule) - .operation(c.getOperation()) - .condition(c.getCondition()) - .build()); - } - - private UserAccountJpa activeUser() { - return entityManager - .from(UserAccountJpa.class) - .fieldEq("username", authenticationFacade.authenticated()) - .singleResult() - .get(); - } - - private RuleGroupJpa group(String group) { - return entityManager - .from(RuleGroupJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("name", group) - .singleResult() - .getOrSupply(() -> { - EventBus.getBus().send(new CreateRuleGroupCommand(group)); - return group(group); - }); - } + private UserAccountJpa activeUser() { + return entityManager + .from(UserAccountJpa.class) + .fieldEq("username", authenticationFacade.authenticated()) + .singleResult() + .get(); + } + + private RuleGroupJpa group(String group) { + return entityManager + .from(RuleGroupJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("name", group) + .singleResult() + .getOrSupply(() -> { + EventBus.getBus().send(new CreateRuleGroupCommand(group)); + return group(group); + }); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustSavingGoalHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustSavingGoalHandler.java index d1c1038d..29e27a80 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustSavingGoalHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustSavingGoalHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.savings.AdjustSavingGoalCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,23 +19,23 @@ @Transactional public class AdjustSavingGoalHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public AdjustSavingGoalHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(AdjustSavingGoalCommand command) { - log.info("[{}] - Adjusting a saving goal.", command.id()); - - entityManager - .update(SavingGoalJpa.class) - .set("targetDate", command.targetDate()) - .set("goal", command.goal()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public AdjustSavingGoalHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(AdjustSavingGoalCommand command) { + log.info("[{}] - Adjusting a saving goal.", command.id()); + + entityManager + .update(SavingGoalJpa.class) + .set("targetDate", command.targetDate()) + .set("goal", command.goal()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustScheduleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustScheduleHandler.java index 8183a740..f6bd5a30 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustScheduleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/AdjustScheduleHandler.java @@ -5,8 +5,11 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.savings.AdjustScheduleCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,23 +18,23 @@ @Transactional public class AdjustScheduleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - public AdjustScheduleHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(AdjustScheduleCommand command) { - log.info("[{}] - Adjusting schedule for a saving goal.", command.id()); - - entityManager - .update(SavingGoalJpa.class) - .set("targetDate", command.schedulable().getEnd()) - .set("periodicity", command.schedulable().getSchedule().periodicity()) - .set("interval", command.schedulable().getSchedule().interval()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + public AdjustScheduleHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(AdjustScheduleCommand command) { + log.info("[{}] - Adjusting schedule for a saving goal.", command.id()); + + entityManager + .update(SavingGoalJpa.class) + .set("targetDate", command.schedulable().getEnd()) + .set("periodicity", command.schedulable().getSchedule().periodicity()) + .set("interval", command.schedulable().getSchedule().interval()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CompleteSavingGoalHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CompleteSavingGoalHandler.java index e71012a6..8f45a0ab 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CompleteSavingGoalHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CompleteSavingGoalHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.savings.CompleteSavingGoalCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,22 +19,22 @@ @Transactional public class CompleteSavingGoalHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CompleteSavingGoalHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CompleteSavingGoalHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CompleteSavingGoalCommand command) { - log.info("[{}] - Marking saving goal for completed.", command.id()); + @Override + @BusinessEventListener + public void handle(CompleteSavingGoalCommand command) { + log.info("[{}] - Marking saving goal for completed.", command.id()); - entityManager - .update(SavingGoalJpa.class) - .set("archived", true) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(SavingGoalJpa.class) + .set("archived", true) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CreateSavingGoalHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CreateSavingGoalHandler.java index b86899c4..32912ce5 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CreateSavingGoalHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/CreateSavingGoalHandler.java @@ -6,8 +6,11 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.savings.CreateSavingGoalCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,24 +19,24 @@ @Transactional public class CreateSavingGoalHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - public CreateSavingGoalHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + public CreateSavingGoalHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateSavingGoalCommand command) { - log.info("[{}] - Creating new saving goal.", command.name()); + @Override + @BusinessEventListener + public void handle(CreateSavingGoalCommand command) { + log.info("[{}] - Creating new saving goal.", command.name()); - var entity = SavingGoalJpa.builder() - .goal(command.goal()) - .targetDate(command.targetDate()) - .name(command.name()) - .account(entityManager.getById(AccountJpa.class, command.accountId())) - .build(); + var entity = SavingGoalJpa.builder() + .goal(command.goal()) + .targetDate(command.targetDate()) + .name(command.name()) + .account(entityManager.getById(AccountJpa.class, command.accountId())) + .build(); - entityManager.persist(entity); - } + entityManager.persist(entity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/RegisterSavingInstallmentHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/RegisterSavingInstallmentHandler.java index d7361470..73d33ca5 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/RegisterSavingInstallmentHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/RegisterSavingInstallmentHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.query.expression.Expressions; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.savings.RegisterSavingInstallmentCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,27 +19,28 @@ @RequiresJpa @Transactional public class RegisterSavingInstallmentHandler - implements CommandHandler { - - private final ReactiveEntityManager entityManager; - - @Inject - public RegisterSavingInstallmentHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RegisterSavingInstallmentCommand command) { - log.info("[{}] - Incrementing allocation for saving goal.", command.id()); - - entityManager - .update(SavingGoalJpa.class) - .set( - "allocated", - Expressions.addition( - Expressions.field("allocated"), Expressions.value(command.amount()))) - .fieldEq("id", command.id()) - .execute(); - } + implements CommandHandler { + + private final ReactiveEntityManager entityManager; + + @Inject + public RegisterSavingInstallmentHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RegisterSavingInstallmentCommand command) { + log.info("[{}] - Incrementing allocation for saving goal.", command.id()); + + entityManager + .update(SavingGoalJpa.class) + .set( + "allocated", + Expressions.addition( + Expressions.field("allocated"), + Expressions.value(command.amount()))) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/SavingGoalJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/SavingGoalJpa.java index 2d9f9125..105a1d91 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/SavingGoalJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/savings/SavingGoalJpa.java @@ -3,64 +3,68 @@ import com.jongsoft.finance.jpa.account.AccountJpa; import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.schedule.Periodicity; + import jakarta.persistence.*; -import java.math.BigDecimal; -import java.time.LocalDate; + import lombok.Builder; import lombok.Getter; + import org.hibernate.annotations.DynamicInsert; +import java.math.BigDecimal; +import java.time.LocalDate; + @Getter @Entity @DynamicInsert @Table(name = "saving_goal") public class SavingGoalJpa extends EntityJpa { - @Column(name = "target_date", columnDefinition = "DATE", nullable = false) - private LocalDate targetDate; - - @Column(nullable = false) - private BigDecimal goal; - - private BigDecimal allocated; - - @Column(nullable = false) - private String name; - - private String description; - - @Enumerated(value = EnumType.STRING) - private Periodicity periodicity; - - @Column(name = "reoccurrence") - private int interval; - - @ManyToOne - private AccountJpa account; - - private boolean archived; - - @Builder - public SavingGoalJpa( - Long id, - LocalDate targetDate, - BigDecimal goal, - BigDecimal allocated, - String name, - String description, - Periodicity periodicity, - int interval, - AccountJpa account) { - super(id); - this.targetDate = targetDate; - this.goal = goal; - this.allocated = allocated; - this.name = name; - this.description = description; - this.periodicity = periodicity; - this.interval = interval; - this.account = account; - } - - public SavingGoalJpa() {} + @Column(name = "target_date", columnDefinition = "DATE", nullable = false) + private LocalDate targetDate; + + @Column(nullable = false) + private BigDecimal goal; + + private BigDecimal allocated; + + @Column(nullable = false) + private String name; + + private String description; + + @Enumerated(value = EnumType.STRING) + private Periodicity periodicity; + + @Column(name = "reoccurrence") + private int interval; + + @ManyToOne + private AccountJpa account; + + private boolean archived; + + @Builder + public SavingGoalJpa( + Long id, + LocalDate targetDate, + BigDecimal goal, + BigDecimal allocated, + String name, + String description, + Periodicity periodicity, + int interval, + AccountJpa account) { + super(id); + this.targetDate = targetDate; + this.goal = goal; + this.allocated = allocated; + this.name = name; + this.description = description; + this.periodicity = periodicity; + this.interval = interval; + this.account = account; + } + + public SavingGoalJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleFromContractHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleFromContractHandler.java index 4240c901..cf94c061 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleFromContractHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleFromContractHandler.java @@ -7,9 +7,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.schedule.CreateScheduleForContractCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,32 +20,33 @@ @RequiresJpa @Transactional public class CreateScheduleFromContractHandler - implements CommandHandler { - - private final ReactiveEntityManager entityManager; - - @Inject - public CreateScheduleFromContractHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(CreateScheduleForContractCommand command) { - log.info("[{}] - Creating a schedule from an existing contract", command.name()); - - var jpaEntity = ScheduledTransactionJpa.builder() - .name(command.name()) - .amount(command.amount()) - .contract(entityManager.getById(ContractJpa.class, command.contract().getId())) - .source(entityManager.getById(AccountJpa.class, command.source().getId())) - .destination(entityManager.getById( - AccountJpa.class, command.contract().getCompany().getId())) - .periodicity(command.schedule().periodicity()) - .interval(command.schedule().interval()) - .user(entityManager.currentUser()) - .build(); - - entityManager.persist(jpaEntity); - } + implements CommandHandler { + + private final ReactiveEntityManager entityManager; + + @Inject + public CreateScheduleFromContractHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(CreateScheduleForContractCommand command) { + log.info("[{}] - Creating a schedule from an existing contract", command.name()); + + var jpaEntity = ScheduledTransactionJpa.builder() + .name(command.name()) + .amount(command.amount()) + .contract(entityManager.getById( + ContractJpa.class, command.contract().getId())) + .source(entityManager.getById(AccountJpa.class, command.source().getId())) + .destination(entityManager.getById( + AccountJpa.class, command.contract().getCompany().getId())) + .periodicity(command.schedule().periodicity()) + .interval(command.schedule().interval()) + .user(entityManager.currentUser()) + .build(); + + entityManager.persist(jpaEntity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleHandler.java index d84d03a3..42b812fc 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/CreateScheduleHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.schedule.CreateScheduleCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,30 +20,30 @@ @Transactional public class CreateScheduleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public CreateScheduleHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(CreateScheduleCommand command) { - log.info("[{}] - Processing schedule create event", command.name()); - var from = entityManager.getById(AccountJpa.class, command.from().getId()); - var to = entityManager.getById(AccountJpa.class, command.destination().getId()); - - var jpaEntity = ScheduledTransactionJpa.builder() - .user(from.getUser()) - .source(from) - .destination(to) - .periodicity(command.schedule().periodicity()) - .interval(command.schedule().interval()) - .amount(command.amount()) - .name(command.name()) - .build(); - - entityManager.persist(jpaEntity); - } + private final ReactiveEntityManager entityManager; + + @Inject + public CreateScheduleHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(CreateScheduleCommand command) { + log.info("[{}] - Processing schedule create event", command.name()); + var from = entityManager.getById(AccountJpa.class, command.from().getId()); + var to = entityManager.getById(AccountJpa.class, command.destination().getId()); + + var jpaEntity = ScheduledTransactionJpa.builder() + .user(from.getUser()) + .source(from) + .destination(to) + .periodicity(command.schedule().periodicity()) + .interval(command.schedule().interval()) + .amount(command.amount()) + .name(command.name()) + .build(); + + entityManager.persist(jpaEntity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/DescribeScheduleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/DescribeScheduleHandler.java index f0e7538b..2a51362f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/DescribeScheduleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/DescribeScheduleHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.schedule.DescribeScheduleCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,23 +19,23 @@ @Transactional public class DescribeScheduleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public DescribeScheduleHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(DescribeScheduleCommand command) { - log.info("[{}] - Processing schedule describe event", command.id()); - - entityManager - .update(ScheduledTransactionJpa.class) - .set("description", command.description()) - .set("name", command.name()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public DescribeScheduleHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(DescribeScheduleCommand command) { + log.info("[{}] - Processing schedule describe event", command.id()); + + entityManager + .update(ScheduledTransactionJpa.class) + .set("description", command.description()) + .set("name", command.name()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/LimitScheduleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/LimitScheduleHandler.java index e27dab52..eb8d4e93 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/LimitScheduleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/LimitScheduleHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.schedule.LimitScheduleCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,23 +19,23 @@ @Transactional public class LimitScheduleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public LimitScheduleHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(LimitScheduleCommand command) { - log.info("[{}] - Processing schedule limit event", command.id()); - - entityManager - .update(ScheduledTransactionJpa.class) - .set("start", command.start()) - .set("end", command.end()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public LimitScheduleHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(LimitScheduleCommand command) { + log.info("[{}] - Processing schedule limit event", command.id()); + + entityManager + .update(ScheduledTransactionJpa.class) + .set("start", command.start()) + .set("end", command.end()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/RescheduleHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/RescheduleHandler.java index ac930ab3..b0143310 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/RescheduleHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/RescheduleHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.schedule.RescheduleCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,23 +19,23 @@ @Transactional public class RescheduleHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public RescheduleHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RescheduleCommand command) { - log.info("[{}] - Processing schedule reschedule event", command.id()); - - entityManager - .update(ScheduledTransactionJpa.class) - .set("interval", command.schedule().interval()) - .set("periodicity", command.schedule().periodicity()) - .fieldEq("id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public RescheduleHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RescheduleCommand command) { + log.info("[{}] - Processing schedule reschedule event", command.id()); + + entityManager + .update(ScheduledTransactionJpa.class) + .set("interval", command.schedule().interval()) + .set("periodicity", command.schedule().periodicity()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduleFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduleFilterCommand.java index 6373e4f0..b7f9fe25 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduleFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduleFilterCommand.java @@ -4,37 +4,39 @@ import com.jongsoft.finance.jpa.query.JpaFilterBuilder; import com.jongsoft.finance.providers.TransactionScheduleProvider; import com.jongsoft.lang.collection.Sequence; + import java.time.LocalDate; public class ScheduleFilterCommand extends JpaFilterBuilder - implements TransactionScheduleProvider.FilterCommand { - - public ScheduleFilterCommand() { - orderAscending = false; - orderBy = "start"; - } - - @Override - public TransactionScheduleProvider.FilterCommand contract(Sequence contracts) { - if (!contracts.isEmpty()) { - query() - .fieldEqOneOf("contract.id", contracts.map(EntityRef::getId).toJava().toArray()); + implements TransactionScheduleProvider.FilterCommand { + + public ScheduleFilterCommand() { + orderAscending = false; + orderBy = "start"; + } + + @Override + public TransactionScheduleProvider.FilterCommand contract(Sequence contracts) { + if (!contracts.isEmpty()) { + query().fieldEqOneOf( + "contract.id", + contracts.map(EntityRef::getId).toJava().toArray()); + } + return this; + } + + @Override + public TransactionScheduleProvider.FilterCommand activeOnly() { + query().fieldGtOrEqNullable("end", LocalDate.now()); + return this; + } + + public void user(String username) { + query().fieldEq("user.username", username); + } + + @Override + public Class entityType() { + return ScheduledTransactionJpa.class; } - return this; - } - - @Override - public TransactionScheduleProvider.FilterCommand activeOnly() { - query().fieldGtOrEqNullable("end", LocalDate.now()); - return this; - } - - public void user(String username) { - query().fieldEq("user.username", username); - } - - @Override - public Class entityType() { - return ScheduledTransactionJpa.class; - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduledTransactionJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduledTransactionJpa.java index 4ce104fa..28a0fa70 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduledTransactionJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/ScheduledTransactionJpa.java @@ -5,69 +5,72 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.schedule.Periodicity; + import jakarta.persistence.*; -import java.time.LocalDate; + import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; + @Getter @Entity @Table(name = "transaction_schedule") public class ScheduledTransactionJpa extends EntityJpa { - @Column(name = "start_date", columnDefinition = "DATE") - private LocalDate start; - - @Column(name = "end_date", columnDefinition = "DATE") - private LocalDate end; - - private double amount; - private String name; - private String description; - - @Enumerated(value = EnumType.STRING) - private Periodicity periodicity; - - @Column(name = "reoccur") - private int interval; - - @ManyToOne - private UserAccountJpa user; - - @ManyToOne - private AccountJpa source; - - @ManyToOne - private AccountJpa destination; - - @ManyToOne - private ContractJpa contract; - - @Builder - public ScheduledTransactionJpa( - LocalDate start, - LocalDate end, - double amount, - String name, - String description, - Periodicity periodicity, - int interval, - UserAccountJpa user, - AccountJpa source, - AccountJpa destination, - ContractJpa contract) { - this.start = start; - this.end = end; - this.amount = amount; - this.name = name; - this.description = description; - this.periodicity = periodicity; - this.interval = interval; - this.user = user; - this.source = source; - this.destination = destination; - this.contract = contract; - } - - public ScheduledTransactionJpa() {} + @Column(name = "start_date", columnDefinition = "DATE") + private LocalDate start; + + @Column(name = "end_date", columnDefinition = "DATE") + private LocalDate end; + + private double amount; + private String name; + private String description; + + @Enumerated(value = EnumType.STRING) + private Periodicity periodicity; + + @Column(name = "reoccur") + private int interval; + + @ManyToOne + private UserAccountJpa user; + + @ManyToOne + private AccountJpa source; + + @ManyToOne + private AccountJpa destination; + + @ManyToOne + private ContractJpa contract; + + @Builder + public ScheduledTransactionJpa( + LocalDate start, + LocalDate end, + double amount, + String name, + String description, + Periodicity periodicity, + int interval, + UserAccountJpa user, + AccountJpa source, + AccountJpa destination, + ContractJpa contract) { + this.start = start; + this.end = end; + this.amount = amount; + this.name = name; + this.description = description; + this.periodicity = periodicity; + this.interval = interval; + this.user = user; + this.source = source; + this.destination = destination; + this.contract = contract; + } + + public ScheduledTransactionJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/TransactionScheduleProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/TransactionScheduleProviderJpa.java index 9f9d91bf..636aad8b 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/TransactionScheduleProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/schedule/TransactionScheduleProviderJpa.java @@ -13,10 +13,13 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.collection.support.Collections; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; + import java.time.LocalDate; @ReadOnly @@ -25,84 +28,85 @@ @Named("transactionScheduleProvider") public class TransactionScheduleProviderJpa implements TransactionScheduleProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public TransactionScheduleProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Optional lookup(long id) { - return entityManager - .from(ScheduledTransactionJpa.class) - .fieldEq("id", id) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .map(this::convert); - } - - @Override - public ResultPage lookup(FilterCommand filterCommand) { - if (filterCommand instanceof ScheduleFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager.from(delegate).paged().map(this::convert); + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + @Inject + public TransactionScheduleProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; } - throw new IllegalStateException("Cannot use non JPA filter on TransactionScheduleProviderJpa"); - } - - @Override - public Sequence lookup() { - return entityManager - .from(ScheduledTransactionJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldGtOrEqNullable("end", LocalDate.now()) - .stream() - .map(this::convert) - .collect(Collections.collector(com.jongsoft.lang.Collections::List)); - } - - protected ScheduledTransaction convert(ScheduledTransactionJpa source) { - if (source == null) { - return null; + @Override + public Optional lookup(long id) { + return entityManager + .from(ScheduledTransactionJpa.class) + .fieldEq("id", id) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .map(this::convert); } - return ScheduledTransaction.builder() - .id(source.getId()) - .name(source.getName()) - .description(source.getDescription()) - .schedule(new ScheduleValue(source.getPeriodicity(), source.getInterval())) - .start(source.getStart()) - .end(source.getEnd()) - .source(Account.builder() - .id(source.getSource().getId()) - .name(source.getSource().getName()) - .type(source.getSource().getType().getLabel()) - .build()) - .destination(Account.builder() - .id(source.getDestination().getId()) - .name(source.getDestination().getName()) - .type(source.getDestination().getType().getLabel()) - .build()) - .contract(build(source.getContract())) - .amount(source.getAmount()) - .build(); - } - - private Contract build(ContractJpa source) { - if (source == null) { - return null; + @Override + public ResultPage lookup(FilterCommand filterCommand) { + if (filterCommand instanceof ScheduleFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + return entityManager.from(delegate).paged().map(this::convert); + } + + throw new IllegalStateException( + "Cannot use non JPA filter on TransactionScheduleProviderJpa"); } - return Contract.builder() - .id(source.getId()) - .startDate(source.getStartDate()) - .endDate(source.getEndDate()) - .build(); - } + @Override + public Sequence lookup() { + return entityManager + .from(ScheduledTransactionJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldGtOrEqNullable("end", LocalDate.now()) + .stream() + .map(this::convert) + .collect(Collections.collector(com.jongsoft.lang.Collections::List)); + } + + protected ScheduledTransaction convert(ScheduledTransactionJpa source) { + if (source == null) { + return null; + } + + return ScheduledTransaction.builder() + .id(source.getId()) + .name(source.getName()) + .description(source.getDescription()) + .schedule(new ScheduleValue(source.getPeriodicity(), source.getInterval())) + .start(source.getStart()) + .end(source.getEnd()) + .source(Account.builder() + .id(source.getSource().getId()) + .name(source.getSource().getName()) + .type(source.getSource().getType().getLabel()) + .build()) + .destination(Account.builder() + .id(source.getDestination().getId()) + .name(source.getDestination().getName()) + .type(source.getDestination().getType().getLabel()) + .build()) + .contract(build(source.getContract())) + .amount(source.getAmount()) + .build(); + } + + private Contract build(ContractJpa source) { + if (source == null) { + return null; + } + + return Contract.builder() + .id(source.getId()) + .startDate(source.getStartDate()) + .endDate(source.getEndDate()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/CreateTagHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/CreateTagHandler.java index d22eebb9..6968beca 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/CreateTagHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/CreateTagHandler.java @@ -4,9 +4,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.tag.CreateTagCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -14,24 +17,24 @@ @Transactional public class CreateTagHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public CreateTagHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public CreateTagHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(CreateTagCommand command) { - log.info("[{}] - Processing tag creation event", command.tag()); + @Override + @BusinessEventListener + public void handle(CreateTagCommand command) { + log.info("[{}] - Processing tag creation event", command.tag()); - var toCreate = TagJpa.builder() - .name(command.tag()) - .user(entityManager.currentUser()) - .archived(false) - .build(); + var toCreate = TagJpa.builder() + .name(command.tag()) + .user(entityManager.currentUser()) + .archived(false) + .build(); - entityManager.persist(toCreate); - } + entityManager.persist(toCreate); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/DeleteTagHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/DeleteTagHandler.java index 627012a4..ba086fc8 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/DeleteTagHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/DeleteTagHandler.java @@ -5,8 +5,11 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.DeleteTagCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -14,25 +17,25 @@ @Transactional public class DeleteTagHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - public DeleteTagHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(DeleteTagCommand command) { - log.info("[{}] - Processing tag deletion event", command.tag()); - - entityManager - .update(TagJpa.class) - .set("archived", true) - .fieldEq("name", command.tag()) - .fieldEq("user.username", authenticationFacade.authenticated()) - .execute(); - } + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + public DeleteTagHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(DeleteTagCommand command) { + log.info("[{}] - Processing tag deletion event", command.tag()); + + entityManager + .update(TagJpa.class) + .set("archived", true) + .fieldEq("name", command.tag()) + .fieldEq("user.username", authenticationFacade.authenticated()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagFilterCommand.java index c51ab8b4..426386cd 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagFilterCommand.java @@ -4,39 +4,39 @@ import com.jongsoft.finance.providers.TagProvider; public class TagFilterCommand extends JpaFilterBuilder - implements TagProvider.FilterCommand { - - public TagFilterCommand() { - query().fieldEq("archived", false); - orderAscending = true; - orderBy = "name"; - } - - @Override - public TagFilterCommand name(String value, boolean exact) { - if (exact) { - query().fieldEq("name", value); - } else { - query().fieldLike("name", value); + implements TagProvider.FilterCommand { + + public TagFilterCommand() { + query().fieldEq("archived", false); + orderAscending = true; + orderBy = "name"; } - return this; - } - - @Override - public TagFilterCommand page(int page, int pageSize) { - limitRows = pageSize; - skipRows = page * pageSize; - return this; - } - - @Override - public void user(String username) { - query().fieldEq("user.username", username); - } - - @Override - public Class entityType() { - return TagJpa.class; - } + @Override + public TagFilterCommand name(String value, boolean exact) { + if (exact) { + query().fieldEq("name", value); + } else { + query().fieldLike("name", value); + } + + return this; + } + + @Override + public TagFilterCommand page(int page, int pageSize) { + limitRows = pageSize; + skipRows = page * pageSize; + return this; + } + + @Override + public void user(String username) { + query().fieldEq("user.username", username); + } + + @Override + public Class entityType() { + return TagJpa.class; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagJpa.java index f85b1093..bc5550c2 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagJpa.java @@ -2,10 +2,12 @@ import com.jongsoft.finance.jpa.core.entity.EntityJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Getter; @@ -14,19 +16,19 @@ @Table(name = "tags") public class TagJpa extends EntityJpa { - private String name; - private boolean archived; + private String name; + private boolean archived; - @ManyToOne - @JoinColumn(nullable = false, updatable = false) - private UserAccountJpa user; + @ManyToOne + @JoinColumn(nullable = false, updatable = false) + private UserAccountJpa user; - @Builder - private TagJpa(String name, boolean archived, UserAccountJpa user) { - this.name = name; - this.archived = archived; - this.user = user; - } + @Builder + private TagJpa(String name, boolean archived, UserAccountJpa user) { + this.name = name; + this.archived = archived; + this.user = user; + } - protected TagJpa() {} + protected TagJpa() {} } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagProviderJpa.java index 51a241c1..e6bd06d2 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/tag/TagProviderJpa.java @@ -8,10 +8,13 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -21,62 +24,62 @@ @Named("tagProvider") public class TagProviderJpa implements TagProvider { - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; - - @Inject - public TagProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } - - @Override - public Sequence lookup() { - log.trace("Tag listing"); - - return entityManager - .from(TagJpa.class) - .joinFetch("user") - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("archived", false) - .stream() - .map(this::convert) - .collect(ReactiveEntityManager.sequenceCollector()); - } - - @Override - public Optional lookup(String name) { - log.trace("Tag lookup by name: {}", name); - - return entityManager - .from(TagJpa.class) - .joinFetch("user") - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("name", name) - .fieldEq("archived", false) - .singleResult() - .map(this::convert); - } - - @Override - public ResultPage lookup(FilterCommand filter) { - log.trace("Tag lookup by filter: {}", filter); - - if (filter instanceof TagFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager.from(delegate).paged().map(this::convert); + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + + @Inject + public TagProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } + + @Override + public Sequence lookup() { + log.trace("Tag listing"); + + return entityManager + .from(TagJpa.class) + .joinFetch("user") + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("archived", false) + .stream() + .map(this::convert) + .collect(ReactiveEntityManager.sequenceCollector()); } - throw new IllegalStateException("Cannot use non JPA filter on TagProviderJpa"); - } + @Override + public Optional lookup(String name) { + log.trace("Tag lookup by name: {}", name); - protected Tag convert(TagJpa source) { - if (source == null) { - return null; + return entityManager + .from(TagJpa.class) + .joinFetch("user") + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("name", name) + .fieldEq("archived", false) + .singleResult() + .map(this::convert); } - return new Tag(source.getName()); - } + @Override + public ResultPage lookup(FilterCommand filter) { + log.trace("Tag lookup by filter: {}", filter); + + if (filter instanceof TagFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + return entityManager.from(delegate).paged().map(this::convert); + } + + throw new IllegalStateException("Cannot use non JPA filter on TagProviderJpa"); + } + + protected Tag convert(TagJpa source) { + if (source == null) { + return null; + } + + return new Tag(source.getName()); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionAmountHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionAmountHandler.java index c0525ae0..0d0497a5 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionAmountHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionAmountHandler.java @@ -8,9 +8,12 @@ import com.jongsoft.finance.jpa.query.expression.FieldEquation; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.ChangeTransactionAmountCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -18,41 +21,41 @@ @RequiresJpa @Transactional public class ChangeTransactionAmountHandler - implements CommandHandler { - - private final ReactiveEntityManager entityManager; - - @Inject - public ChangeTransactionAmountHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeTransactionAmountCommand command) { - log.info("[{}] - Processing transaction amount change event", command.id()); - - entityManager - .update(TransactionJpa.class) - .set( - "amount", - Expressions.caseWhen( - Expressions.fieldCondition(null, "amount", FieldEquation.GTE, 0), - Expressions.value(command.amount()), - Expressions.value(command.amount().negate()))) - .fieldEq("journal.id", command.id()) - .execute(); - - entityManager - .update(TransactionJournal.class) - .set( - "currency", - entityManager - .from(CurrencyJpa.class) - .fieldEq("code", command.currency()) - .singleResult() - .get()) - .fieldEq("id", command.id()) - .execute(); - } + implements CommandHandler { + + private final ReactiveEntityManager entityManager; + + @Inject + public ChangeTransactionAmountHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeTransactionAmountCommand command) { + log.info("[{}] - Processing transaction amount change event", command.id()); + + entityManager + .update(TransactionJpa.class) + .set( + "amount", + Expressions.caseWhen( + Expressions.fieldCondition(null, "amount", FieldEquation.GTE, 0), + Expressions.value(command.amount()), + Expressions.value(command.amount().negate()))) + .fieldEq("journal.id", command.id()) + .execute(); + + entityManager + .update(TransactionJournal.class) + .set( + "currency", + entityManager + .from(CurrencyJpa.class) + .fieldEq("code", command.currency()) + .singleResult() + .get()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionDatesHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionDatesHandler.java index 423a2745..6169136e 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionDatesHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionDatesHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.ChangeTransactionDatesCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,26 +18,26 @@ @RequiresJpa @Transactional public class ChangeTransactionDatesHandler - implements CommandHandler { - - private final ReactiveEntityManager entityManager; - - @Inject - public ChangeTransactionDatesHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeTransactionDatesCommand command) { - log.info("[{}] - Processing transaction book event", command.id()); - - entityManager - .update(TransactionJournal.class) - .set("bookDate", command.bookingDate()) - .set("date", command.date()) - .set("interestDate", command.interestDate()) - .fieldEq("id", command.id()) - .execute(); - } + implements CommandHandler { + + private final ReactiveEntityManager entityManager; + + @Inject + public ChangeTransactionDatesHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeTransactionDatesCommand command) { + log.info("[{}] - Processing transaction book event", command.id()); + + entityManager + .update(TransactionJournal.class) + .set("bookDate", command.bookingDate()) + .set("date", command.date()) + .set("interestDate", command.interestDate()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionPartAccountHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionPartAccountHandler.java index b635c2cf..c7b72043 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionPartAccountHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/ChangeTransactionPartAccountHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.ChangeTransactionPartAccount; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,24 +18,24 @@ @RequiresJpa @Transactional public class ChangeTransactionPartAccountHandler - implements CommandHandler { - - private final ReactiveEntityManager entityManager; - - @Inject - public ChangeTransactionPartAccountHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(ChangeTransactionPartAccount command) { - log.info("[{}] - Processing transaction account change", command.id()); - - entityManager - .update(TransactionJpa.class) - .set("account.id", command.accountId()) - .fieldEq("id", command.id()) - .execute(); - } + implements CommandHandler { + + private final ReactiveEntityManager entityManager; + + @Inject + public ChangeTransactionPartAccountHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(ChangeTransactionPartAccount command) { + log.info("[{}] - Processing transaction account change", command.id()); + + entityManager + .update(TransactionJpa.class) + .set("account.id", command.accountId()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/CreateTransactionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/CreateTransactionHandler.java index 4dfd6d91..589d0255 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/CreateTransactionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/CreateTransactionHandler.java @@ -19,132 +19,140 @@ import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.Sequence; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.math.BigDecimal; import java.util.HashSet; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @RequiresJpa @Transactional public class CreateTransactionHandler - implements CommandHandler, TransactionCreationHandler { - - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public CreateTransactionHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(CreateTransactionCommand command) { - handleCreatedEvent(command); - } - - @Override - public long handleCreatedEvent(CreateTransactionCommand command) { - log.info("[{}] - Processing transaction create event", command.transaction().getDescription()); - - var jpaEntity = TransactionJournal.builder() - .date(command.transaction().getDate()) - .bookDate(command.transaction().getBookDate()) - .interestDate(command.transaction().getInterestDate()) - .description(command.transaction().getDescription()) - .currency(entityManager - .from(CurrencyJpa.class) - .fieldEq("code", command.transaction().getCurrency()) - .singleResult() - .get()) - .user(entityManager.currentUser()) - .type(TransactionType.valueOf(command.transaction().computeType().name())) - .failureCode(command.transaction().getFailureCode()) - .transactions(new HashSet<>()) - .category(Control.Option(command.transaction().getCategory()) - .map(this::category) - .getOrSupply(() -> null)) - .budget(Control.Option(command.transaction().getBudget()) - .map(this::expense) - .getOrSupply(() -> null)) - .contract(Control.Option(command.transaction().getContract()) - .map(this::contract) - .getOrSupply(() -> null)) - .tags(Control.Option(command.transaction().getTags()) - .map(Sequence::distinct) - .map(set -> set.map(this::tag).toJava()) - .getOrSupply(() -> null)) - .batchImport(Control.Option(command.transaction().getImportSlug()) - .map(this::job) - .getOrSupply(() -> null)) - .build(); - - entityManager.persist(jpaEntity); - - for (Transaction.Part transfer : command.transaction().getTransactions()) { - // todo change to native BigDecimal later on - var transferJpa = TransactionJpa.builder() - .amount(BigDecimal.valueOf(transfer.getAmount())) - .account(entityManager.getById(AccountJpa.class, transfer.getAccount().getId())) - .journal(jpaEntity) - .build(); - - jpaEntity.getTransactions().add(transferJpa); - entityManager.persist(transferJpa); + implements CommandHandler, TransactionCreationHandler { + + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public CreateTransactionHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; } - TransactionCreated.transactionCreated(jpaEntity.getId()); - return jpaEntity.getId(); - } - - private CategoryJpa category(String label) { - return entityManager - .from(CategoryJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("label", label) - .singleResult() - .getOrSupply(() -> null); - } - - private ExpenseJpa expense(String name) { - return entityManager - .from(ExpenseJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrSupply(() -> null); - } - - private ContractJpa contract(String name) { - return entityManager - .from(ContractJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrSupply(() -> null); - } - - private ImportJpa job(String slug) { - return entityManager - .from(ImportJpa.class) - .fieldEq("slug", slug) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrSupply(() -> null); - } - - private TagJpa tag(String name) { - return entityManager - .from(TagJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrSupply(() -> null); - } + @Override + @BusinessEventListener + public void handle(CreateTransactionCommand command) { + handleCreatedEvent(command); + } + + @Override + public long handleCreatedEvent(CreateTransactionCommand command) { + log.info( + "[{}] - Processing transaction create event", + command.transaction().getDescription()); + + var jpaEntity = TransactionJournal.builder() + .date(command.transaction().getDate()) + .bookDate(command.transaction().getBookDate()) + .interestDate(command.transaction().getInterestDate()) + .description(command.transaction().getDescription()) + .currency(entityManager + .from(CurrencyJpa.class) + .fieldEq("code", command.transaction().getCurrency()) + .singleResult() + .get()) + .user(entityManager.currentUser()) + .type(TransactionType.valueOf( + command.transaction().computeType().name())) + .failureCode(command.transaction().getFailureCode()) + .transactions(new HashSet<>()) + .category(Control.Option(command.transaction().getCategory()) + .map(this::category) + .getOrSupply(() -> null)) + .budget(Control.Option(command.transaction().getBudget()) + .map(this::expense) + .getOrSupply(() -> null)) + .contract(Control.Option(command.transaction().getContract()) + .map(this::contract) + .getOrSupply(() -> null)) + .tags(Control.Option(command.transaction().getTags()) + .map(Sequence::distinct) + .map(set -> set.map(this::tag).toJava()) + .getOrSupply(() -> null)) + .batchImport(Control.Option(command.transaction().getImportSlug()) + .map(this::job) + .getOrSupply(() -> null)) + .build(); + + entityManager.persist(jpaEntity); + + for (Transaction.Part transfer : command.transaction().getTransactions()) { + // todo change to native BigDecimal later on + var transferJpa = TransactionJpa.builder() + .amount(BigDecimal.valueOf(transfer.getAmount())) + .account(entityManager.getById( + AccountJpa.class, transfer.getAccount().getId())) + .journal(jpaEntity) + .build(); + + jpaEntity.getTransactions().add(transferJpa); + entityManager.persist(transferJpa); + } + + TransactionCreated.transactionCreated(jpaEntity.getId()); + return jpaEntity.getId(); + } + + private CategoryJpa category(String label) { + return entityManager + .from(CategoryJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("label", label) + .singleResult() + .getOrSupply(() -> null); + } + + private ExpenseJpa expense(String name) { + return entityManager + .from(ExpenseJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrSupply(() -> null); + } + + private ContractJpa contract(String name) { + return entityManager + .from(ContractJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrSupply(() -> null); + } + + private ImportJpa job(String slug) { + return entityManager + .from(ImportJpa.class) + .fieldEq("slug", slug) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrSupply(() -> null); + } + + private TagJpa tag(String name) { + return entityManager + .from(TagJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrSupply(() -> null); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DailySummaryImpl.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DailySummaryImpl.java index 61358534..28a9736f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DailySummaryImpl.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DailySummaryImpl.java @@ -1,49 +1,51 @@ package com.jongsoft.finance.jpa.transaction; import com.jongsoft.finance.providers.TransactionProvider; + import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; public class DailySummaryImpl implements TransactionProvider.DailySummary { - private LocalDate day; - private BigDecimal summary; - - public DailySummaryImpl(LocalDate day, BigDecimal summary) { - this.day = day; - this.summary = summary; - } - - public DailySummaryImpl(int year, int month, int day, BigDecimal summary) { - this(LocalDate.of(year, month, day), summary); - } - - @Override - public LocalDate day() { - return day; - } - - @Override - public double summary() { - return summary.doubleValue(); - } - - @Override - public boolean equals(Object o) { - if (o instanceof TransactionProvider.DailySummary other) { - return summary.compareTo(BigDecimal.valueOf(other.summary())) == 0 && day.equals(other.day()); + private LocalDate day; + private BigDecimal summary; + + public DailySummaryImpl(LocalDate day, BigDecimal summary) { + this.day = day; + this.summary = summary; + } + + public DailySummaryImpl(int year, int month, int day, BigDecimal summary) { + this(LocalDate.of(year, month, day), summary); + } + + @Override + public LocalDate day() { + return day; } - return false; - } + @Override + public double summary() { + return summary.doubleValue(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof TransactionProvider.DailySummary other) { + return summary.compareTo(BigDecimal.valueOf(other.summary())) == 0 + && day.equals(other.day()); + } + + return false; + } - @Override - public int hashCode() { - return Objects.hash(day, summary); - } + @Override + public int hashCode() { + return Objects.hash(day, summary); + } - @Override - public String toString() { - return "DailySummaryImpl{" + "day=" + day + ", summary=" + summary + '}'; - } + @Override + public String toString() { + return "DailySummaryImpl{" + "day=" + day + ", summary=" + summary + '}'; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DeleteTransactionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DeleteTransactionHandler.java index f59e28f8..c1179459 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DeleteTransactionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DeleteTransactionHandler.java @@ -5,40 +5,44 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.DeleteTransactionCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.util.Date; + import lombok.extern.slf4j.Slf4j; +import java.util.Date; + @Slf4j @Singleton @RequiresJpa @Transactional public class DeleteTransactionHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public DeleteTransactionHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(DeleteTransactionCommand command) { - log.info("[{}] - Processing transaction delete event", command.id()); - - entityManager - .update(TransactionJournal.class) - .set("deleted", new Date()) - .fieldEq("id", command.id()) - .execute(); - - entityManager - .update(TransactionJpa.class) - .set("deleted", new Date()) - .fieldEq("journal.id", command.id()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public DeleteTransactionHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(DeleteTransactionCommand command) { + log.info("[{}] - Processing transaction delete event", command.id()); + + entityManager + .update(TransactionJournal.class) + .set("deleted", new Date()) + .fieldEq("id", command.id()) + .execute(); + + entityManager + .update(TransactionJpa.class) + .set("deleted", new Date()) + .fieldEq("journal.id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DescribeTransactionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DescribeTransactionHandler.java index 20693ed7..03c86045 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DescribeTransactionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/DescribeTransactionHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.DescribeTransactionCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,22 +19,22 @@ @Transactional public class DescribeTransactionHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public DescribeTransactionHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public DescribeTransactionHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(DescribeTransactionCommand command) { - log.info("[{}] - Processing transaction describe event", command.id()); + @Override + @BusinessEventListener + public void handle(DescribeTransactionCommand command) { + log.info("[{}] - Processing transaction describe event", command.id()); - entityManager - .update(TransactionJournal.class) - .set("description", command.description()) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(TransactionJournal.class) + .set("description", command.description()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/LinkTransactionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/LinkTransactionHandler.java index 9f1ca397..1db08db2 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/LinkTransactionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/LinkTransactionHandler.java @@ -11,9 +11,12 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.LinkTransactionCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -22,86 +25,88 @@ @Transactional public class LinkTransactionHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; - @Inject - public LinkTransactionHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } + @Inject + public LinkTransactionHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } - @Override - @BusinessEventListener - public void handle(LinkTransactionCommand command) { - log.info("[{}] - Processing transaction relation change {}", command.id(), command.type()); + @Override + @BusinessEventListener + public void handle(LinkTransactionCommand command) { + log.info("[{}] - Processing transaction relation change {}", command.id(), command.type()); - var updateQuery = entityManager.update(TransactionJournal.class).fieldEq("id", command.id()); + var updateQuery = + entityManager.update(TransactionJournal.class).fieldEq("id", command.id()); - switch (command.type()) { - case CATEGORY -> - updateQuery.set("category", fetchRelation(command.type(), command.relation())); - case CONTRACT -> - updateQuery.set("contract", fetchRelation(command.type(), command.relation())); - case EXPENSE -> updateQuery.set("budget", fetchRelation(command.type(), command.relation())); - case IMPORT -> - updateQuery.set("batchImport", fetchRelation(command.type(), command.relation())); + switch (command.type()) { + case CATEGORY -> + updateQuery.set("category", fetchRelation(command.type(), command.relation())); + case CONTRACT -> + updateQuery.set("contract", fetchRelation(command.type(), command.relation())); + case EXPENSE -> + updateQuery.set("budget", fetchRelation(command.type(), command.relation())); + case IMPORT -> + updateQuery.set("batchImport", fetchRelation(command.type(), command.relation())); + } + updateQuery.execute(); } - updateQuery.execute(); - } - private EntityJpa fetchRelation(LinkTransactionCommand.LinkType type, String relation) { - return switch (type) { - case CATEGORY -> category(relation); - case CONTRACT -> contract(relation); - case EXPENSE -> expense(relation); - case IMPORT -> job(relation); - }; - } + private EntityJpa fetchRelation(LinkTransactionCommand.LinkType type, String relation) { + return switch (type) { + case CATEGORY -> category(relation); + case CONTRACT -> contract(relation); + case EXPENSE -> expense(relation); + case IMPORT -> job(relation); + }; + } - private CategoryJpa category(String label) { - if (label == null) { - return null; + private CategoryJpa category(String label) { + if (label == null) { + return null; + } + return entityManager + .from(CategoryJpa.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("label", label) + .singleResult() + .getOrThrow(() -> new IllegalArgumentException("Category not found")); } - return entityManager - .from(CategoryJpa.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("label", label) - .singleResult() - .getOrThrow(() -> new IllegalArgumentException("Category not found")); - } - private ExpenseJpa expense(String name) { - if (name == null) { - return null; + private ExpenseJpa expense(String name) { + if (name == null) { + return null; + } + return entityManager + .from(ExpenseJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrThrow(() -> new IllegalArgumentException("Budget not found")); } - return entityManager - .from(ExpenseJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrThrow(() -> new IllegalArgumentException("Budget not found")); - } - private ContractJpa contract(String name) { - if (name == null) { - return null; + private ContractJpa contract(String name) { + if (name == null) { + return null; + } + return entityManager + .from(ContractJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrThrow(() -> new IllegalArgumentException("Contract not found")); } - return entityManager - .from(ContractJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrThrow(() -> new IllegalArgumentException("Contract not found")); - } - private ImportJpa job(String slug) { - return entityManager - .from(ImportJpa.class) - .fieldEq("slug", slug) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrThrow(() -> new IllegalArgumentException("Job not found")); - } + private ImportJpa job(String slug) { + return entityManager + .from(ImportJpa.class) + .fieldEq("slug", slug) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrThrow(() -> new IllegalArgumentException("Job not found")); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/RegisterFailureHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/RegisterFailureHandler.java index d6efd421..c0beab54 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/RegisterFailureHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/RegisterFailureHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.query.ReactiveEntityManager; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.RegisterFailureCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,22 +19,22 @@ @Transactional public class RegisterFailureHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public RegisterFailureHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public RegisterFailureHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(RegisterFailureCommand command) { - log.info("[{}] - Processing transaction failed register event", command.id()); + @Override + @BusinessEventListener + public void handle(RegisterFailureCommand command) { + log.info("[{}] - Processing transaction failed register event", command.id()); - entityManager - .update(TransactionJournal.class) - .set("failureCode", command.code()) - .fieldEq("id", command.id()) - .execute(); - } + entityManager + .update(TransactionJournal.class) + .set("failureCode", command.code()) + .fieldEq("id", command.id()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/SplitTransactionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/SplitTransactionHandler.java index bb832c72..739c2b76 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/SplitTransactionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/SplitTransactionHandler.java @@ -8,13 +8,17 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.SplitTransactionCommand; import com.jongsoft.lang.Collections; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.math.BigDecimal; import java.util.Date; import java.util.Objects; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @@ -22,55 +26,55 @@ @Transactional public class SplitTransactionHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public SplitTransactionHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public SplitTransactionHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(SplitTransactionCommand command) { - log.info("[{}] - Processing transaction split event", command.id()); + @Override + @BusinessEventListener + public void handle(SplitTransactionCommand command) { + log.info("[{}] - Processing transaction split event", command.id()); - var transaction = - entityManager.getDetached(TransactionJournal.class, Collections.Map("id", command.id())); + var transaction = entityManager.getDetached( + TransactionJournal.class, Collections.Map("id", command.id())); - var survivors = command.split().map(Transaction.Part::getId).reject(Objects::isNull); + var survivors = command.split().map(Transaction.Part::getId).reject(Objects::isNull); - // Mark all old parts as deleted - var deletedIds = Collections.List(transaction.getTransactions()) - .reject(t -> survivors.contains(t.getId())) - .map(TransactionJpa::getId); + // Mark all old parts as deleted + var deletedIds = Collections.List(transaction.getTransactions()) + .reject(t -> survivors.contains(t.getId())) + .map(TransactionJpa::getId); - entityManager - .update(TransactionJpa.class) - .set("deleted", new Date()) - .fieldEqOneOf("id", deletedIds.stream().toArray()) - .execute(); + entityManager + .update(TransactionJpa.class) + .set("deleted", new Date()) + .fieldEqOneOf("id", deletedIds.stream().toArray()) + .execute(); - // Add new parts - command - .split() - .filter(part -> part.getId() == null) - .map(part -> TransactionJpa.builder() - // todo change to native BigDecimal later on - .amount(BigDecimal.valueOf(part.getAmount())) - .description(part.getDescription()) - .account(entityManager.getById(AccountJpa.class, part.getAccount().getId())) - .journal(transaction) - .build()) - .forEach(entityPart -> { - transaction.getTransactions().add(entityPart); - entityManager.persist(entityPart); - }); + // Add new parts + command.split() + .filter(part -> part.getId() == null) + .map(part -> TransactionJpa.builder() + // todo change to native BigDecimal later on + .amount(BigDecimal.valueOf(part.getAmount())) + .description(part.getDescription()) + .account(entityManager.getById( + AccountJpa.class, part.getAccount().getId())) + .journal(transaction) + .build()) + .forEach(entityPart -> { + transaction.getTransactions().add(entityPart); + entityManager.persist(entityPart); + }); - // Update existing parts - command.split().filter(part -> Objects.nonNull(part.getId())).forEach(part -> entityManager - .update(TransactionJpa.class) - .set("amount", part.getAmount()) - .fieldEq("id", part.getId()) - .execute()); - } + // Update existing parts + command.split().filter(part -> Objects.nonNull(part.getId())).forEach(part -> entityManager + .update(TransactionJpa.class) + .set("amount", part.getAmount()) + .fieldEq("id", part.getId()) + .execute()); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TagTransactionHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TagTransactionHandler.java index 0ac4a58a..5d90b31b 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TagTransactionHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TagTransactionHandler.java @@ -7,51 +7,55 @@ import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.transaction.TagTransactionCommand; import com.jongsoft.finance.security.AuthenticationFacade; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.util.Objects; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Objects; + @Singleton @RequiresJpa @Transactional public class TagTransactionHandler implements CommandHandler { - private final Logger log = LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - private final AuthenticationFacade authenticationFacade; - - @Inject - public TagTransactionHandler( - ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { - this.entityManager = entityManager; - this.authenticationFacade = authenticationFacade; - } - - @Override - @BusinessEventListener - public void handle(TagTransactionCommand command) { - log.info("[{}] - Processing transaction tagging event", command.id()); - - var transaction = entityManager.getById(TransactionJournal.class, command.id()); - transaction.getTags().clear(); - - command.tags().map(this::tag).filter(Objects::nonNull).forEach(tag -> transaction - .getTags() - .add(tag)); - - entityManager.persist(transaction); - } - - private TagJpa tag(String name) { - return entityManager - .from(TagJpa.class) - .fieldEq("name", name) - .fieldEq("user.username", authenticationFacade.authenticated()) - .singleResult() - .getOrThrow(() -> new IllegalArgumentException("tag not found")); - } + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + + @Inject + public TagTransactionHandler( + ReactiveEntityManager entityManager, AuthenticationFacade authenticationFacade) { + this.entityManager = entityManager; + this.authenticationFacade = authenticationFacade; + } + + @Override + @BusinessEventListener + public void handle(TagTransactionCommand command) { + log.info("[{}] - Processing transaction tagging event", command.id()); + + var transaction = entityManager.getById(TransactionJournal.class, command.id()); + transaction.getTags().clear(); + + command.tags().map(this::tag).filter(Objects::nonNull).forEach(tag -> transaction + .getTags() + .add(tag)); + + entityManager.persist(transaction); + } + + private TagJpa tag(String name) { + return entityManager + .from(TagJpa.class) + .fieldEq("name", name) + .fieldEq("user.username", authenticationFacade.authenticated()) + .singleResult() + .getOrThrow(() -> new IllegalArgumentException("tag not found")); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionFilterCommand.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionFilterCommand.java index 4e66a987..5745fdc3 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionFilterCommand.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionFilterCommand.java @@ -10,144 +10,138 @@ import com.jongsoft.lang.Collections; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.time.Range; + import java.time.LocalDate; import java.util.Arrays; import java.util.List; import java.util.function.Function; public class TransactionFilterCommand extends JpaFilterBuilder - implements TransactionProvider.FilterCommand { - - private static final Function, List> ID_REDUCER = - input -> Collections.List(input).map(AggregateBase::getId).toJava(); - - public TransactionFilterCommand() { - query().fieldNull("deleted").condition(Expressions.fieldNull("t", "deleted")); - orderAscending = false; - orderBy = "date"; - } - - @Override - public void user(String username) { - query().fieldEq("user.username", username); - } - - @Override - public TransactionProvider.FilterCommand accounts(Sequence value) { - query() - .condition(Expressions.fieldCondition( - "t", "account.id", FieldEquation.IN, ID_REDUCER.apply(value))); - return this; - } - - @Override - public TransactionProvider.FilterCommand categories(Sequence value) { - query().fieldEqOneOf("category.id", ID_REDUCER.apply(value).toArray()); - return this; - } - - @Override - public TransactionProvider.FilterCommand contracts(Sequence value) { - query().fieldEqOneOf("contract.id", ID_REDUCER.apply(value).toArray()); - return this; - } - - @Override - public TransactionProvider.FilterCommand expenses(Sequence value) { - query().fieldEqOneOf("budget.id", ID_REDUCER.apply(value).toArray()); - return this; - } - - @Override - public TransactionProvider.FilterCommand name(String value, boolean exact) { - query().whereExists(subQuery -> { - subQuery.fieldNull("deleted").from("transactions"); - if (exact) { - subQuery.fieldEq("account.name", value); - } else { - subQuery.fieldLike("account.name", value.toLowerCase()); - } - }); - return this; - } - - @Override - public TransactionProvider.FilterCommand description(String value, boolean exact) { - query() - .condition(Expressions.or( - Expressions.fieldLike("e", "description", value.toLowerCase()), - Expressions.fieldLike("t", "description", value.toLowerCase()))); - - return this; - } - - @Override - public TransactionProvider.FilterCommand range(Range range) { - query() - .condition(Expressions.and( - Expressions.fieldCondition("e", "date", FieldEquation.GTE, range.from()), - Expressions.fieldCondition("e", "date", FieldEquation.LT, range.until()))); - return this; - } - - @Override - public TransactionProvider.FilterCommand importSlug(String value) { - query().fieldEq("batchImport.slug", value); - return this; - } - - @Override - public TransactionProvider.FilterCommand currency(String currency) { - query().fieldEq("currency.code", currency); - return this; - } - - @Override - public TransactionProvider.FilterCommand onlyIncome(boolean onlyIncome) { - query() - .condition(Expressions.fieldCondition( - "t", "amount", onlyIncome ? FieldEquation.GTE : FieldEquation.LTE, 0)); - return this; - } - - @Override - public TransactionProvider.FilterCommand ownAccounts() { - var types = Arrays.stream(SystemAccountTypes.values()) - .map(SystemAccountTypes::label) - .toArray(); - query() - .condition(Expressions.fieldCondition( - "t", "account.type.label", FieldEquation.NIN, Arrays.asList(types))) - .whereExists(subQuery -> subQuery - .from("transactions") - .fieldEqParentField("journal.id", "id") - .fieldNull("deleted") - .fieldEqOneOf("account.type.label", types)); - return this; - } - - @Override - public TransactionProvider.FilterCommand transfers() { - var types = Arrays.stream(SystemAccountTypes.values()) - .map(SystemAccountTypes::label) - .toArray(); - - query().whereNotExists(subQuery -> subQuery - .from("transactions") - .fieldNull("deleted") - .fieldEqOneOf("account.type.label", types)); - return this; - } - - @Override - public TransactionProvider.FilterCommand page(int value, int pageSize) { - this.limitRows = pageSize; - this.skipRows = value * pageSize; - return this; - } - - @Override - public Class entityType() { - return TransactionJournal.class; - } + implements TransactionProvider.FilterCommand { + + private static final Function, List> ID_REDUCER = + input -> Collections.List(input).map(AggregateBase::getId).toJava(); + + public TransactionFilterCommand() { + query().fieldNull("deleted").condition(Expressions.fieldNull("t", "deleted")); + orderAscending = false; + orderBy = "date"; + } + + @Override + public void user(String username) { + query().fieldEq("user.username", username); + } + + @Override + public TransactionProvider.FilterCommand accounts(Sequence value) { + query().condition(Expressions.fieldCondition( + "t", "account.id", FieldEquation.IN, ID_REDUCER.apply(value))); + return this; + } + + @Override + public TransactionProvider.FilterCommand categories(Sequence value) { + query().fieldEqOneOf("category.id", ID_REDUCER.apply(value).toArray()); + return this; + } + + @Override + public TransactionProvider.FilterCommand contracts(Sequence value) { + query().fieldEqOneOf("contract.id", ID_REDUCER.apply(value).toArray()); + return this; + } + + @Override + public TransactionProvider.FilterCommand expenses(Sequence value) { + query().fieldEqOneOf("budget.id", ID_REDUCER.apply(value).toArray()); + return this; + } + + @Override + public TransactionProvider.FilterCommand name(String value, boolean exact) { + query().whereExists(subQuery -> { + subQuery.fieldNull("deleted").from("transactions"); + if (exact) { + subQuery.fieldEq("account.name", value); + } else { + subQuery.fieldLike("account.name", value.toLowerCase()); + } + }); + return this; + } + + @Override + public TransactionProvider.FilterCommand description(String value, boolean exact) { + query().condition(Expressions.or( + Expressions.fieldLike("e", "description", value.toLowerCase()), + Expressions.fieldLike("t", "description", value.toLowerCase()))); + + return this; + } + + @Override + public TransactionProvider.FilterCommand range(Range range) { + query().condition(Expressions.and( + Expressions.fieldCondition("e", "date", FieldEquation.GTE, range.from()), + Expressions.fieldCondition("e", "date", FieldEquation.LT, range.until()))); + return this; + } + + @Override + public TransactionProvider.FilterCommand importSlug(String value) { + query().fieldEq("batchImport.slug", value); + return this; + } + + @Override + public TransactionProvider.FilterCommand currency(String currency) { + query().fieldEq("currency.code", currency); + return this; + } + + @Override + public TransactionProvider.FilterCommand onlyIncome(boolean onlyIncome) { + query().condition(Expressions.fieldCondition( + "t", "amount", onlyIncome ? FieldEquation.GTE : FieldEquation.LTE, 0)); + return this; + } + + @Override + public TransactionProvider.FilterCommand ownAccounts() { + var types = Arrays.stream(SystemAccountTypes.values()) + .map(SystemAccountTypes::label) + .toArray(); + query().condition(Expressions.fieldCondition( + "t", "account.type.label", FieldEquation.NIN, Arrays.asList(types))) + .whereExists(subQuery -> subQuery.from("transactions") + .fieldEqParentField("journal.id", "id") + .fieldNull("deleted") + .fieldEqOneOf("account.type.label", types)); + return this; + } + + @Override + public TransactionProvider.FilterCommand transfers() { + var types = Arrays.stream(SystemAccountTypes.values()) + .map(SystemAccountTypes::label) + .toArray(); + + query().whereNotExists(subQuery -> subQuery.from("transactions") + .fieldNull("deleted") + .fieldEqOneOf("account.type.label", types)); + return this; + } + + @Override + public TransactionProvider.FilterCommand page(int value, int pageSize) { + this.limitRows = pageSize; + this.skipRows = value * pageSize; + return this; + } + + @Override + public Class entityType() { + return TransactionJournal.class; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJournal.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJournal.java index f5c81777..3b31b9f4 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJournal.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJournal.java @@ -10,110 +10,114 @@ import com.jongsoft.finance.jpa.importer.entity.ImportJpa; import com.jongsoft.finance.jpa.tag.TagJpa; import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; + import jakarta.persistence.*; -import java.time.LocalDate; -import java.util.Date; -import java.util.Set; + import lombok.Builder; import lombok.Getter; + import org.hibernate.annotations.Where; +import java.time.LocalDate; +import java.util.Date; +import java.util.Set; + @Entity @Getter @Table(name = "transaction_journal") public class TransactionJournal extends AuditedJpa { - @Column(name = "t_date", nullable = false, columnDefinition = "DATE") - private LocalDate date; - - @Column(name = "interest_date", columnDefinition = "DATE") - private LocalDate interestDate; - - @Column(name = "book_date", columnDefinition = "DATE") - private LocalDate bookDate; - - private String description; - - @Enumerated(value = EnumType.STRING) - private TransactionType type; - - @Enumerated(value = EnumType.STRING) - private FailureCode failureCode; - - @ManyToOne - @JoinColumn(nullable = false, updatable = false) - private UserAccountJpa user; - - @ManyToOne - @JoinColumn - private CategoryJpa category; - - @ManyToOne - @JoinColumn - private ExpenseJpa budget; - - @ManyToOne - @JoinColumn - private ContractJpa contract; - - @ManyToOne - @JoinColumn - private ImportJpa batchImport; - - @ManyToOne - @JoinColumn - private CurrencyJpa currency; - - @JoinTable( - name = "transaction_tag", - joinColumns = @JoinColumn(name = "transaction_id"), - inverseJoinColumns = @JoinColumn(name = "tag_id")) - @ManyToMany(fetch = FetchType.EAGER) - private Set tags; - - @Where(clause = "deleted is null") - @OneToMany(mappedBy = "journal", fetch = FetchType.EAGER, orphanRemoval = true) - private Set transactions; - - public TransactionJournal() { - super(); - } - - @Builder - protected TransactionJournal( - Long id, - Date created, - Date updated, - Date deleted, - LocalDate date, - LocalDate bookDate, - LocalDate interestDate, - String description, - TransactionType type, - FailureCode failureCode, - UserAccountJpa user, - CategoryJpa category, - ExpenseJpa budget, - ContractJpa contract, - ImportJpa batchImport, - CurrencyJpa currency, - Set tags, - Set transactions) { - super(id, created, updated, deleted); - - this.date = date; - this.bookDate = bookDate; - this.interestDate = interestDate; - this.description = description; - this.type = type; - this.failureCode = failureCode; - this.user = user; - this.category = category; - this.budget = budget; - this.contract = contract; - this.batchImport = batchImport; - this.currency = currency; - this.tags = tags; - this.transactions = transactions; - } + @Column(name = "t_date", nullable = false, columnDefinition = "DATE") + private LocalDate date; + + @Column(name = "interest_date", columnDefinition = "DATE") + private LocalDate interestDate; + + @Column(name = "book_date", columnDefinition = "DATE") + private LocalDate bookDate; + + private String description; + + @Enumerated(value = EnumType.STRING) + private TransactionType type; + + @Enumerated(value = EnumType.STRING) + private FailureCode failureCode; + + @ManyToOne + @JoinColumn(nullable = false, updatable = false) + private UserAccountJpa user; + + @ManyToOne + @JoinColumn + private CategoryJpa category; + + @ManyToOne + @JoinColumn + private ExpenseJpa budget; + + @ManyToOne + @JoinColumn + private ContractJpa contract; + + @ManyToOne + @JoinColumn + private ImportJpa batchImport; + + @ManyToOne + @JoinColumn + private CurrencyJpa currency; + + @JoinTable( + name = "transaction_tag", + joinColumns = @JoinColumn(name = "transaction_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + @ManyToMany(fetch = FetchType.EAGER) + private Set tags; + + @Where(clause = "deleted is null") + @OneToMany(mappedBy = "journal", fetch = FetchType.EAGER, orphanRemoval = true) + private Set transactions; + + public TransactionJournal() { + super(); + } + + @Builder + protected TransactionJournal( + Long id, + Date created, + Date updated, + Date deleted, + LocalDate date, + LocalDate bookDate, + LocalDate interestDate, + String description, + TransactionType type, + FailureCode failureCode, + UserAccountJpa user, + CategoryJpa category, + ExpenseJpa budget, + ContractJpa contract, + ImportJpa batchImport, + CurrencyJpa currency, + Set tags, + Set transactions) { + super(id, created, updated, deleted); + + this.date = date; + this.bookDate = bookDate; + this.interestDate = interestDate; + this.description = description; + this.type = type; + this.failureCode = failureCode; + this.user = user; + this.category = category; + this.budget = budget; + this.contract = contract; + this.batchImport = batchImport; + this.currency = currency; + this.tags = tags; + this.transactions = transactions; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJpa.java index 86694c4c..cb00255e 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionJpa.java @@ -2,15 +2,18 @@ import com.jongsoft.finance.jpa.account.AccountJpa; import com.jongsoft.finance.jpa.core.entity.AuditedJpa; + import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.math.BigDecimal; -import java.util.Date; + import lombok.Builder; import lombok.Getter; +import java.math.BigDecimal; +import java.util.Date; + /** * A TransactionJpa is a part of a transaction journal. Usually a transaction journal contains two * transactions, being: @@ -28,34 +31,34 @@ @Table(name = "transaction_part") public class TransactionJpa extends AuditedJpa { - @ManyToOne - @JoinColumn(nullable = false) - private AccountJpa account; - - @ManyToOne - @JoinColumn(nullable = false, updatable = false) - private TransactionJournal journal; - - private BigDecimal amount; - private String description; - - public TransactionJpa() {} - - @Builder - protected TransactionJpa( - Long id, - Date created, - Date updated, - Date deleted, - AccountJpa account, - TransactionJournal journal, - BigDecimal amount, - String description) { - super(id, created, updated, deleted); - - this.account = account; - this.journal = journal; - this.amount = amount; - this.description = description; - } + @ManyToOne + @JoinColumn(nullable = false) + private AccountJpa account; + + @ManyToOne + @JoinColumn(nullable = false, updatable = false) + private TransactionJournal journal; + + private BigDecimal amount; + private String description; + + public TransactionJpa() {} + + @Builder + protected TransactionJpa( + Long id, + Date created, + Date updated, + Date deleted, + AccountJpa account, + TransactionJournal journal, + BigDecimal amount, + String description) { + super(id, created, updated, deleted); + + this.account = account; + this.journal = journal; + this.amount = amount; + this.description = description; + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpa.java index 28357057..8b3d5aec 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpa.java @@ -18,16 +18,20 @@ import com.jongsoft.lang.Control; import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.math.BigDecimal; import java.time.LocalDate; import java.util.Comparator; import java.util.Objects; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @ReadOnly @Singleton @@ -35,197 +39,202 @@ @Named("transactionProvider") public class TransactionProviderJpa implements TransactionProvider { - private final Logger log = LoggerFactory.getLogger(TransactionProviderJpa.class); + private final Logger log = LoggerFactory.getLogger(TransactionProviderJpa.class); - private final AuthenticationFacade authenticationFacade; - private final ReactiveEntityManager entityManager; + private final AuthenticationFacade authenticationFacade; + private final ReactiveEntityManager entityManager; - @Inject - public TransactionProviderJpa( - AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { - this.authenticationFacade = authenticationFacade; - this.entityManager = entityManager; - } + @Inject + public TransactionProviderJpa( + AuthenticationFacade authenticationFacade, ReactiveEntityManager entityManager) { + this.authenticationFacade = authenticationFacade; + this.entityManager = entityManager; + } - @Override - public Optional first(FilterCommand filter) { - log.trace("Transaction locate first with filter: {}", filter); + @Override + public Optional first(FilterCommand filter) { + log.trace("Transaction locate first with filter: {}", filter); + + if (filter instanceof TransactionFilterCommand delegate) { + delegate.page(0, 1); + delegate.user(authenticationFacade.authenticated()); + + var results = entityManager + .from(delegate) + .join("transactions t") + .orderBy("date", true) + .paged() + .content() + .map(this::convert); + + if (results.isEmpty()) { + return Control.Option(); + } + + return Control.Option(results.head()); + } + throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); + } - if (filter instanceof TransactionFilterCommand delegate) { - delegate.page(0, 1); - delegate.user(authenticationFacade.authenticated()); + @Override + public Optional lookup(long id) { + return entityManager + .from(TransactionJournal.class) + .fieldEq("user.username", authenticationFacade.authenticated()) + .fieldEq("id", id) + .singleResult() + .map(this::convert); + } - var results = entityManager - .from(delegate) - .join("transactions t") - .orderBy("date", true) - .paged() - .content() - .map(this::convert); + @Override + public ResultPage lookup(FilterCommand filter) { + log.trace("Transactions lookup with filter: {}", filter); - if (results.isEmpty()) { - return Control.Option(); - } + if (filter instanceof TransactionFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); - return Control.Option(results.head()); - } - throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); - } - - @Override - public Optional lookup(long id) { - return entityManager - .from(TransactionJournal.class) - .fieldEq("user.username", authenticationFacade.authenticated()) - .fieldEq("id", id) - .singleResult() - .map(this::convert); - } - - @Override - public ResultPage lookup(FilterCommand filter) { - log.trace("Transactions lookup with filter: {}", filter); - - if (filter instanceof TransactionFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager.from(delegate).join("transactions t").paged().map(this::convert); + return entityManager.from(delegate).join("transactions t").paged().map(this::convert); + } + + throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); } - throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); - } + @Override + public Sequence daily(FilterCommand filter) { + log.trace("Transactions daily sum with filter: {}", filter); - @Override - public Sequence daily(FilterCommand filter) { - log.trace("Transactions daily sum with filter: {}", filter); + if (filter instanceof TransactionFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); - if (filter instanceof TransactionFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); + return entityManager + .from(delegate) + .join("transactions t") + .groupBy("date") + .orderBy("date", true) + .project(DailySummaryImpl.class, "new DailySummaryImpl(e.date, sum(t.amount))") + .map(DailySummary.class::cast) + .collect(ReactiveEntityManager.sequenceCollector()); + } - return entityManager - .from(delegate) - .join("transactions t") - .groupBy("date") - .orderBy("date", true) - .project(DailySummaryImpl.class, "new DailySummaryImpl(e.date, sum(t.amount))") - .map(DailySummary.class::cast) - .collect(ReactiveEntityManager.sequenceCollector()); + throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); } - throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); - } - - @Override - public Sequence monthly(FilterCommand filter) { - log.trace("Transactions monthly sum with filter: {}", filter); - - if (filter instanceof TransactionFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); - - return entityManager - .from(delegate) - .join("transactions t") - // reset the order by statement, otherwise exceptions with the group by will - // happen - .orderBy(null, false) - .groupBy(Expressions.field("year(e.date)"), Expressions.field("month(e.date)")) - .project( - DailySummaryImpl.class, - "new DailySummaryImpl(year(e.date), month(e.date), 1, sum(t.amount))") - .sorted(Comparator.comparing(DailySummaryImpl::day)) - .map(DailySummary.class::cast) - .collect(ReactiveEntityManager.sequenceCollector()); + @Override + public Sequence monthly(FilterCommand filter) { + log.trace("Transactions monthly sum with filter: {}", filter); + + if (filter instanceof TransactionFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); + + return entityManager + .from(delegate) + .join("transactions t") + // reset the order by statement, otherwise exceptions with the group by will + // happen + .orderBy(null, false) + .groupBy(Expressions.field("year(e.date)"), Expressions.field("month(e.date)")) + .project( + DailySummaryImpl.class, + "new DailySummaryImpl(year(e.date), month(e.date), 1, sum(t.amount))") + .sorted(Comparator.comparing(DailySummaryImpl::day)) + .map(DailySummary.class::cast) + .collect(ReactiveEntityManager.sequenceCollector()); + } + + throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); } - throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); - } + @Override + public Optional balance(FilterCommand filter) { + log.trace("Transaction balance with filter: {}", filter.toString()); + + if (filter instanceof TransactionFilterCommand delegate) { + delegate.user(authenticationFacade.authenticated()); - @Override - public Optional balance(FilterCommand filter) { - log.trace("Transaction balance with filter: {}", filter.toString()); + return entityManager + .from(delegate) + .join("transactions t") + .orderBy(null, false) + .projectSingleValue(BigDecimal.class, "sum(t.amount)"); + } - if (filter instanceof TransactionFilterCommand delegate) { - delegate.user(authenticationFacade.authenticated()); + throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); + } - return entityManager - .from(delegate) - .join("transactions t") - .orderBy(null, false) - .projectSingleValue(BigDecimal.class, "sum(t.amount)"); + @Override + public Sequence similar( + EntityRef from, EntityRef to, double amount, LocalDate date) { + return entityManager + .from(TransactionJournal.class) + .joinFetch("transactions") + .joinFetch("currency") + .joinFetch("tags") + .fieldEq("user.username", authenticationFacade.authenticated()) + .whereExists(fromQuery -> fromQuery + .from("transactions") + .fieldEq("account.id", from.getId()) + .fieldEqOneOf("amount", amount, -amount) + .fieldNull("deleted")) + .whereExists(toQuery -> toQuery.from("transactions") + .fieldEq("account.id", to.getId()) + .fieldEqOneOf("amount", amount, -amount) + .fieldNull("deleted")) + .stream() + .map(this::convert) + .collect(com.jongsoft.lang.collection.support.Collections.collector( + com.jongsoft.lang.Collections::List)); } - throw new IllegalStateException("Cannot use non JPA filter on TransactionProviderJpa"); - } - - @Override - public Sequence similar( - EntityRef from, EntityRef to, double amount, LocalDate date) { - return entityManager - .from(TransactionJournal.class) - .joinFetch("transactions") - .joinFetch("currency") - .joinFetch("tags") - .fieldEq("user.username", authenticationFacade.authenticated()) - .whereExists(fromQuery -> fromQuery - .from("transactions") - .fieldEq("account.id", from.getId()) - .fieldEqOneOf("amount", amount, -amount) - .fieldNull("deleted")) - .whereExists(toQuery -> toQuery - .from("transactions") - .fieldEq("account.id", to.getId()) - .fieldEqOneOf("amount", amount, -amount) - .fieldNull("deleted")) - .stream() - .map(this::convert) - .collect(com.jongsoft.lang.collection.support.Collections.collector( - com.jongsoft.lang.Collections::List)); - } - - protected Transaction convert(TransactionJournal source) { - if (source == null) { - return null; + protected Transaction convert(TransactionJournal source) { + if (source == null) { + return null; + } + + var parts = Collections.List(source.getTransactions()) + .filter(entity -> Objects.isNull(entity.getDeleted())) + .map(this::convertPart); + + return Transaction.builder() + .id(source.getId()) + .created(source.getCreated()) + .updated(source.getUpdated()) + .date(source.getDate()) + .bookDate(source.getBookDate()) + .interestDate(source.getInterestDate()) + .failureCode(source.getFailureCode()) + .budget(Control.Option(source.getBudget()) + .map(ExpenseJpa::getName) + .getOrSupply(() -> null)) + .category(Control.Option(source.getCategory()) + .map(CategoryJpa::getLabel) + .getOrSupply(() -> null)) + .currency(source.getCurrency().getCode()) + .importSlug(Control.Option(source.getBatchImport()) + .map(ImportJpa::getSlug) + .getOrSupply(() -> null)) + .description(source.getDescription()) + .contract(Control.Option(source.getContract()) + .map(ContractJpa::getName) + .getOrSupply(() -> null)) + .tags(Control.Option(source.getTags()) + .map(tags -> Collections.List(tags).map(TagJpa::getName)) + .getOrSupply(Collections::List)) + .transactions(parts) + .deleted(source.getDeleted() != null) + .build(); } - var parts = Collections.List(source.getTransactions()) - .filter(entity -> Objects.isNull(entity.getDeleted())) - .map(this::convertPart); - - return Transaction.builder() - .id(source.getId()) - .created(source.getCreated()) - .updated(source.getUpdated()) - .date(source.getDate()) - .bookDate(source.getBookDate()) - .interestDate(source.getInterestDate()) - .failureCode(source.getFailureCode()) - .budget(Control.Option(source.getBudget()).map(ExpenseJpa::getName).getOrSupply(() -> null)) - .category( - Control.Option(source.getCategory()).map(CategoryJpa::getLabel).getOrSupply(() -> null)) - .currency(source.getCurrency().getCode()) - .importSlug( - Control.Option(source.getBatchImport()).map(ImportJpa::getSlug).getOrSupply(() -> null)) - .description(source.getDescription()) - .contract( - Control.Option(source.getContract()).map(ContractJpa::getName).getOrSupply(() -> null)) - .tags(Control.Option(source.getTags()) - .map(tags -> Collections.List(tags).map(TagJpa::getName)) - .getOrSupply(Collections::List)) - .transactions(parts) - .build(); - } - - private Transaction.Part convertPart(TransactionJpa transaction) { - return Transaction.Part.builder() - .id(transaction.getId()) - .account(Account.builder() - .id(transaction.getAccount().getId()) - .name(transaction.getAccount().getName()) - .type(transaction.getAccount().getType().getLabel()) - .imageFileToken(transaction.getAccount().getImageFileToken()) - .build()) - .amount(transaction.getAmount().doubleValue()) - .description(transaction.getDescription()) - .build(); - } + private Transaction.Part convertPart(TransactionJpa transaction) { + return Transaction.Part.builder() + .id(transaction.getId()) + .account(Account.builder() + .id(transaction.getAccount().getId()) + .name(transaction.getAccount().getName()) + .type(transaction.getAccount().getType().getLabel()) + .imageFileToken(transaction.getAccount().getImageFileToken()) + .build()) + .amount(transaction.getAmount().doubleValue()) + .description(transaction.getDescription()) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeMultiFactorHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeMultiFactorHandler.java index 07571330..02b053af 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeMultiFactorHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeMultiFactorHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.ChangeMultiFactorCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,22 +18,22 @@ @Transactional public class ChangeMultiFactorHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public ChangeMultiFactorHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public ChangeMultiFactorHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(ChangeMultiFactorCommand command) { - log.info("[{}] - Updating multi factor setting", command.username()); + @Override + @BusinessEventListener + public void handle(ChangeMultiFactorCommand command) { + log.info("[{}] - Updating multi factor setting", command.username()); - entityManager - .update(UserAccountJpa.class) - .set("twoFactorEnabled", command.enabled()) - .fieldEq("username", command.username().email()) - .execute(); - } + entityManager + .update(UserAccountJpa.class) + .set("twoFactorEnabled", command.enabled()) + .fieldEq("username", command.username().email()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangePasswordHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangePasswordHandler.java index 758e81d0..f529f109 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangePasswordHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangePasswordHandler.java @@ -5,9 +5,12 @@ import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.ChangePasswordCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,22 +18,22 @@ @Transactional public class ChangePasswordHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public ChangePasswordHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public ChangePasswordHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(ChangePasswordCommand command) { - log.info("[{}] - Updating password for user", command.username()); + @Override + @BusinessEventListener + public void handle(ChangePasswordCommand command) { + log.info("[{}] - Updating password for user", command.username()); - entityManager - .update(UserAccountJpa.class) - .set("password", command.password()) - .fieldEq("username", command.username().email()) - .execute(); - } + entityManager + .update(UserAccountJpa.class) + .set("password", command.password()) + .fieldEq("username", command.username().email()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeUserSettingHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeUserSettingHandler.java index cc461e21..daebaa67 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeUserSettingHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/ChangeUserSettingHandler.java @@ -5,38 +5,42 @@ import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.ChangeUserSettingCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.util.Currency; + import lombok.extern.slf4j.Slf4j; +import java.util.Currency; + @Slf4j @Singleton @Transactional public class ChangeUserSettingHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public ChangeUserSettingHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public ChangeUserSettingHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(ChangeUserSettingCommand command) { - log.info("[{}] - Updating user setting {}", command.username(), command.type()); + @Override + @BusinessEventListener + public void handle(ChangeUserSettingCommand command) { + log.info("[{}] - Updating user setting {}", command.username(), command.type()); - var query = entityManager - .update(UserAccountJpa.class) - .fieldEq("username", command.username().email()); + var query = entityManager + .update(UserAccountJpa.class) + .fieldEq("username", command.username().email()); - switch (command.type()) { - case THEME -> query.set("theme", command.value()); - case CURRENCY -> query.set("currency", Currency.getInstance(command.value())); - default -> {} + switch (command.type()) { + case THEME -> query.set("theme", command.value()); + case CURRENCY -> query.set("currency", Currency.getInstance(command.value())); + default -> {} + } + query.execute(); } - query.execute(); - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateExternalUserHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateExternalUserHandler.java index e6970847..76712884 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateExternalUserHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateExternalUserHandler.java @@ -6,55 +6,59 @@ import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.CreateExternalUserCommand; + import dev.samstevens.totp.secret.SecretGenerator; + import jakarta.inject.Singleton; import jakarta.transaction.Transactional; + import org.slf4j.Logger; @Singleton @Transactional class CreateExternalUserHandler implements CommandHandler { - private final Logger log = org.slf4j.LoggerFactory.getLogger(this.getClass()); - - private final ReactiveEntityManager entityManager; - private final SecretGenerator secretGenerator; - - public CreateExternalUserHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - secretGenerator = new dev.samstevens.totp.secret.DefaultSecretGenerator(); - } - - @Override - @BusinessEventListener - public void handle(CreateExternalUserCommand command) { - if (validateUserExists(command.username())) { - log.debug("[{}] - External user already exists, skipping creation.", command.username()); - return; - } + private final Logger log = org.slf4j.LoggerFactory.getLogger(this.getClass()); + + private final ReactiveEntityManager entityManager; + private final SecretGenerator secretGenerator; - log.info("[{}] - Creating external user", command.username()); - var builder = UserAccountJpa.builder() - .username(command.username()) - .password("") - .theme("light") - .twoFactorSecret(secretGenerator.generate()); - - for (var role : command.roles()) { - entityManager - .from(RoleJpa.class) - .fieldEq("name", role) - .singleResult() - .ifPresent(builder::role); + public CreateExternalUserHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + secretGenerator = new dev.samstevens.totp.secret.DefaultSecretGenerator(); } - entityManager.persist(builder.build()); - } + @Override + @BusinessEventListener + public void handle(CreateExternalUserCommand command) { + if (validateUserExists(command.username())) { + log.debug( + "[{}] - External user already exists, skipping creation.", command.username()); + return; + } - private synchronized boolean validateUserExists(String username) { - return entityManager - .from(UserAccountJpa.class) - .fieldEq("username", username) - .singleResult() - .isPresent(); - } + log.info("[{}] - Creating external user", command.username()); + var builder = UserAccountJpa.builder() + .username(command.username()) + .password("") + .theme("light") + .twoFactorSecret(secretGenerator.generate()); + + for (var role : command.roles()) { + entityManager + .from(RoleJpa.class) + .fieldEq("name", role) + .singleResult() + .ifPresent(builder::role); + } + + entityManager.persist(builder.build()); + } + + private synchronized boolean validateUserExists(String username) { + return entityManager + .from(UserAccountJpa.class) + .fieldEq("username", username) + .singleResult() + .isPresent(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateUserHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateUserHandler.java index e7bff653..d6eed035 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateUserHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateUserHandler.java @@ -6,42 +6,48 @@ import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.CreateUserCommand; + import dev.samstevens.totp.secret.DefaultSecretGenerator; import dev.samstevens.totp.secret.SecretGenerator; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.util.Arrays; import java.util.HashSet; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @Transactional public class CreateUserHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - private final SecretGenerator secretGenerator; - - @Inject - public CreateUserHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - secretGenerator = new DefaultSecretGenerator(); - } - - @Override - @BusinessEventListener - public void handle(CreateUserCommand command) { - var entity = UserAccountJpa.builder() - .username(command.username()) - .password(command.password()) - .twoFactorSecret(secretGenerator.generate()) - .theme("light") - .roles(new HashSet<>(Arrays.asList( - RoleJpa.builder().id(1L).name("admin").build(), - RoleJpa.builder().id(2L).name("accountant").build()))) - .build(); - - entityManager.persist(entity); - } + private final ReactiveEntityManager entityManager; + private final SecretGenerator secretGenerator; + + @Inject + public CreateUserHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + secretGenerator = new DefaultSecretGenerator(); + } + + @Override + @BusinessEventListener + public void handle(CreateUserCommand command) { + log.info("[{}] - Processing user create event", command.username()); + var entity = UserAccountJpa.builder() + .username(command.username()) + .password(command.password()) + .twoFactorSecret(secretGenerator.generate()) + .theme("light") + .roles(new HashSet<>(Arrays.asList( + RoleJpa.builder().id(1L).name("admin").build(), + RoleJpa.builder().id(2L).name("accountant").build()))) + .build(); + + entityManager.persist(entity); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RegisterTokenHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RegisterTokenHandler.java index 331dfddc..e795d43e 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RegisterTokenHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RegisterTokenHandler.java @@ -6,9 +6,12 @@ import com.jongsoft.finance.jpa.user.entity.UserAccountJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.RegisterTokenCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,30 +19,30 @@ @Transactional public class RegisterTokenHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; + private final ReactiveEntityManager entityManager; - @Inject - public RegisterTokenHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } + @Inject + public RegisterTokenHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } - @Override - @BusinessEventListener - public void handle(RegisterTokenCommand command) { - log.info("[{}] - Registering new security token.", command.username()); + @Override + @BusinessEventListener + public void handle(RegisterTokenCommand command) { + log.info("[{}] - Registering new security token.", command.username()); - var userAccountJpa = entityManager - .from(UserAccountJpa.class) - .fieldEq("username", command.username()) - .singleResult() - .get(); + var userAccountJpa = entityManager + .from(UserAccountJpa.class) + .fieldEq("username", command.username()) + .singleResult() + .get(); - var refreshJpa = AccountTokenJpa.builder() - .user(userAccountJpa) - .refreshToken(command.refreshToken()) - .expires(command.expireDate()) - .build(); + var refreshJpa = AccountTokenJpa.builder() + .user(userAccountJpa) + .refreshToken(command.refreshToken()) + .expires(command.expireDate()) + .build(); - entityManager.persist(refreshJpa); - } + entityManager.persist(refreshJpa); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RevokeTokenHandler.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RevokeTokenHandler.java index 2f8792eb..0e246b48 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RevokeTokenHandler.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/RevokeTokenHandler.java @@ -5,34 +5,38 @@ import com.jongsoft.finance.jpa.user.entity.AccountTokenJpa; import com.jongsoft.finance.messaging.CommandHandler; import com.jongsoft.finance.messaging.commands.user.RevokeTokenCommand; + import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.time.LocalDateTime; + import lombok.extern.slf4j.Slf4j; +import java.time.LocalDateTime; + @Slf4j @Singleton @Transactional public class RevokeTokenHandler implements CommandHandler { - private final ReactiveEntityManager entityManager; - - @Inject - public RevokeTokenHandler(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - @BusinessEventListener - public void handle(RevokeTokenCommand command) { - log.info("[{}] - Revoking security token.", command.token()); - - entityManager - .update(AccountTokenJpa.class) - .set("expires", LocalDateTime.now()) - .fieldEq("refreshToken", command.token()) - .fieldGtOrEq("expires", LocalDateTime.now()) - .execute(); - } + private final ReactiveEntityManager entityManager; + + @Inject + public RevokeTokenHandler(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @BusinessEventListener + public void handle(RevokeTokenCommand command) { + log.info("[{}] - Revoking security token.", command.token()); + + entityManager + .update(AccountTokenJpa.class) + .set("expires", LocalDateTime.now()) + .fieldEq("refreshToken", command.token()) + .fieldGtOrEq("expires", LocalDateTime.now()) + .execute(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/UserProviderJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/UserProviderJpa.java index b2d4c619..2e90e63f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/UserProviderJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/UserProviderJpa.java @@ -15,10 +15,16 @@ import com.jongsoft.lang.collection.Sequence; import com.jongsoft.lang.collection.support.Collections; import com.jongsoft.lang.control.Optional; + import io.micronaut.transaction.annotation.ReadOnly; + import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.time.LocalDateTime; import java.util.Currency; @@ -28,96 +34,99 @@ @Named("userProvider") public class UserProviderJpa implements UserProvider { - private final ReactiveEntityManager entityManager; - - @Inject - public UserProviderJpa(ReactiveEntityManager entityManager) { - this.entityManager = entityManager; - } - - @Override - public Sequence lookup() { - return entityManager.from(UserAccountJpa.class).joinFetch("roles").stream() - .map(this::convert) - .collect(Collections.collector(com.jongsoft.lang.Collections::List)); - } - - @Override - public Optional lookup(long id) { - return entityManager - .from(UserAccountJpa.class) - .joinFetch("roles") - .fieldEq("id", id) - .singleResult() - .map(this::convert); - } - - @Override - public boolean available(UserIdentifier username) { - return entityManager - .from(UserAccountJpa.class) - .fieldEq("username", username.email()) - .projectSingleValue(Long.class, "count(1)") - .getOrSupply(() -> 0L) - == 0; - } - - @Override - public Optional lookup(UserIdentifier username) { - return entityManager - .from(UserAccountJpa.class) - .fieldEq("username", username.email()) - .singleResult() - .map(this::convert); - } - - @Override - public Optional refreshToken(String refreshToken) { - return entityManager - .from(AccountTokenJpa.class) - .fieldEq("refreshToken", refreshToken) - .fieldGtOrEq("expires", LocalDateTime.now()) - .projectSingleValue(UserAccountJpa.class, "e.user") - .map(this::convert); - } - - @Override - public Sequence tokens(UserIdentifier username) { - return entityManager - .from(AccountTokenJpa.class) - .fieldEq("user.username", username.email()) - .fieldGtOrEq("expires", LocalDateTime.now()) - .stream() - .map(this::convert) - .collect(Collections.collector(com.jongsoft.lang.Collections::List)); - } - - protected SessionToken convert(AccountTokenJpa source) { - return SessionToken.builder() - .id(source.getId()) - .description(source.getDescription()) - .token(source.getRefreshToken()) - .validity(Dates.range(source.getCreated(), source.getExpires())) - .build(); - } - - protected UserAccount convert(UserAccountJpa source) { - if (source == null) { - return null; + private final Logger logger; + private final ReactiveEntityManager entityManager; + + @Inject + public UserProviderJpa(ReactiveEntityManager entityManager) { + this.entityManager = entityManager; + this.logger = LoggerFactory.getLogger(UserProvider.class); + } + + @Override + public Sequence lookup() { + return entityManager.from(UserAccountJpa.class).joinFetch("roles").stream() + .map(this::convert) + .collect(Collections.collector(com.jongsoft.lang.Collections::List)); + } + + @Override + public Optional lookup(long id) { + return entityManager + .from(UserAccountJpa.class) + .joinFetch("roles") + .fieldEq("id", id) + .singleResult() + .map(this::convert); + } + + @Override + public boolean available(UserIdentifier username) { + return entityManager + .from(UserAccountJpa.class) + .fieldEq("username", username.email()) + .projectSingleValue(Long.class, "count(1)") + .getOrSupply(() -> 0L) + == 0; } - return UserAccount.builder() - .id(source.getId()) - .username(new UserIdentifier(source.getUsername())) - .password(source.getPassword()) - .primaryCurrency( - Control.Option(source.getCurrency()).getOrSupply(() -> Currency.getInstance("EUR"))) - .secret(source.getTwoFactorSecret()) - .theme(source.getTheme()) - .twoFactorEnabled(source.isTwoFactorEnabled()) - .roles(source.getRoles().stream() - .map(role -> new Role(role.getName())) - .collect(Collectors.toList())) - .build(); - } + @Override + public Optional lookup(UserIdentifier username) { + logger.debug("Locating user {} in the system.", username.email()); + return entityManager + .from(UserAccountJpa.class) + .fieldEq("username", username.email()) + .singleResult() + .map(this::convert); + } + + @Override + public Optional refreshToken(String refreshToken) { + return entityManager + .from(AccountTokenJpa.class) + .fieldEq("refreshToken", refreshToken) + .fieldGtOrEq("expires", LocalDateTime.now()) + .projectSingleValue(UserAccountJpa.class, "e.user") + .map(this::convert); + } + + @Override + public Sequence tokens(UserIdentifier username) { + return entityManager + .from(AccountTokenJpa.class) + .fieldEq("user.username", username.email()) + .fieldGtOrEq("expires", LocalDateTime.now()) + .stream() + .map(this::convert) + .collect(Collections.collector(com.jongsoft.lang.Collections::List)); + } + + protected SessionToken convert(AccountTokenJpa source) { + return SessionToken.builder() + .id(source.getId()) + .description(source.getDescription()) + .token(source.getRefreshToken()) + .validity(Dates.range(source.getCreated(), source.getExpires())) + .build(); + } + + protected UserAccount convert(UserAccountJpa source) { + if (source == null) { + return null; + } + + return UserAccount.builder() + .id(source.getId()) + .username(new UserIdentifier(source.getUsername())) + .password(source.getPassword()) + .primaryCurrency(Control.Option(source.getCurrency()) + .getOrSupply(() -> Currency.getInstance("EUR"))) + .secret(source.getTwoFactorSecret()) + .theme(source.getTheme()) + .twoFactorEnabled(source.isTwoFactorEnabled()) + .roles(source.getRoles().stream() + .map(role -> new Role(role.getName())) + .collect(Collectors.toList())) + .build(); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/AccountTokenJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/AccountTokenJpa.java index 86767ae6..1391d0dc 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/AccountTokenJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/AccountTokenJpa.java @@ -1,59 +1,62 @@ package com.jongsoft.finance.jpa.user.entity; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.*; -import java.time.LocalDateTime; + import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @Getter @Entity @Table(name = "user_account_token") public class AccountTokenJpa extends EntityJpa { - @ManyToOne - @JoinColumn - private UserAccountJpa user; - - @Column(name = "description") - private String description; - - @Column(name = "refresh_token") - private String refreshToken; - - @Column(name = "created") - private LocalDateTime created; - - @Column(name = "expires") - private LocalDateTime expires; - - public AccountTokenJpa() { - super(); - } - - @Builder - public AccountTokenJpa( - Long id, - UserAccountJpa user, - String refreshToken, - LocalDateTime expires, - String description) { - super(id); - this.user = user; - this.refreshToken = refreshToken; - this.expires = expires; - this.description = description; - } - - @PreUpdate - @PrePersist - void initialize() { - if (created == null) { - created = LocalDateTime.now(); + @ManyToOne + @JoinColumn + private UserAccountJpa user; + + @Column(name = "description") + private String description; + + @Column(name = "refresh_token") + private String refreshToken; + + @Column(name = "created") + private LocalDateTime created; + + @Column(name = "expires") + private LocalDateTime expires; + + public AccountTokenJpa() { + super(); } - if (description == null) { - description = "Pledger.io Web login"; + @Builder + public AccountTokenJpa( + Long id, + UserAccountJpa user, + String refreshToken, + LocalDateTime expires, + String description) { + super(id); + this.user = user; + this.refreshToken = refreshToken; + this.expires = expires; + this.description = description; + } + + @PreUpdate + @PrePersist + void initialize() { + if (created == null) { + created = LocalDateTime.now(); + } + + if (description == null) { + description = "Pledger.io Web login"; + } } - } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/RoleJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/RoleJpa.java index 23a11387..db23ca8f 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/RoleJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/RoleJpa.java @@ -1,45 +1,48 @@ package com.jongsoft.finance.jpa.user.entity; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.ManyToMany; import jakarta.persistence.Table; -import java.util.Objects; -import java.util.Set; + import lombok.Builder; import lombok.Getter; +import java.util.Objects; +import java.util.Set; + @Getter @Entity @Table(name = "role") public class RoleJpa extends EntityJpa { - private String name; - - @ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY) - private Set users; + private String name; - public RoleJpa() {} + @ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY) + private Set users; - @Builder - public RoleJpa(Long id, String name, Set users) { - super(id); - this.name = name; - this.users = users; - } + public RoleJpa() {} - @Override - public boolean equals(Object o) { - if (o instanceof RoleJpa other) { - return Objects.equals(other.name, name); + @Builder + public RoleJpa(Long id, String name, Set users) { + super(id); + this.name = name; + this.users = users; } - return false; - } + @Override + public boolean equals(Object o) { + if (o instanceof RoleJpa other) { + return Objects.equals(other.name, name); + } - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), name); - } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), name); + } } diff --git a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/UserAccountJpa.java b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/UserAccountJpa.java index b9925701..f019a728 100644 --- a/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/UserAccountJpa.java +++ b/jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/UserAccountJpa.java @@ -1,61 +1,67 @@ package com.jongsoft.finance.jpa.user.entity; import com.jongsoft.finance.jpa.core.entity.EntityJpa; + import jakarta.persistence.*; -import java.util.Currency; -import java.util.HashSet; -import java.util.Set; + import lombok.Builder; import lombok.Getter; import lombok.Singular; +import java.util.Currency; +import java.util.HashSet; +import java.util.Set; + @Getter @Entity @Table(name = "user_account") public class UserAccountJpa extends EntityJpa { - @Column(name = "username", unique = true, nullable = false) - private String username; - - @Column(name = "password", nullable = false) - private String password; - - private boolean twoFactorEnabled; - private String twoFactorSecret; - - private String theme; - - private Currency currency; - - @Lob - @Column - private byte[] gravatar; - - @JoinTable(name = "user_roles") - @ManyToMany(fetch = FetchType.LAZY) - private Set roles = new HashSet<>(); - - public UserAccountJpa() { - super(); - } - - @Builder - private UserAccountJpa( - String username, - String password, - boolean twoFactorEnabled, - String twoFactorSecret, - String theme, - Currency currency, - byte[] gravatar, - @Singular Set roles) { - this.username = username; - this.password = password; - this.twoFactorEnabled = twoFactorEnabled; - this.twoFactorSecret = twoFactorSecret; - this.theme = theme; - this.currency = currency; - this.gravatar = gravatar; - this.roles = roles; - } + @Column(name = "username", unique = true, nullable = false) + private String username; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "two_factor_enabled") + private boolean twoFactorEnabled; + + @Column(name = "two_factor_secret") + private String twoFactorSecret; + + private String theme; + + private Currency currency; + + @Lob + @Column + private byte[] gravatar; + + @JoinTable(name = "user_roles") + @ManyToMany(fetch = FetchType.LAZY) + private Set roles = new HashSet<>(); + + public UserAccountJpa() { + super(); + } + + @Builder + private UserAccountJpa( + String username, + String password, + boolean twoFactorEnabled, + String twoFactorSecret, + String theme, + Currency currency, + byte[] gravatar, + @Singular Set roles) { + this.username = username; + this.password = password; + this.twoFactorEnabled = twoFactorEnabled; + this.twoFactorSecret = twoFactorSecret; + this.theme = theme; + this.currency = currency; + this.gravatar = gravatar; + this.roles = roles; + } } diff --git a/jpa-repository/src/main/java/db/migration/RandomizedTransactions.java b/jpa-repository/src/main/java/db/migration/RandomizedTransactions.java index 0fa7496f..7f94f29a 100644 --- a/jpa-repository/src/main/java/db/migration/RandomizedTransactions.java +++ b/jpa-repository/src/main/java/db/migration/RandomizedTransactions.java @@ -1,12 +1,13 @@ package db.migration; +import org.slf4j.Logger; + import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; import java.time.LocalDate; import java.util.Random; -import org.slf4j.Logger; /** * This class is used to generate randomized transactions for the Pledger application. It should @@ -14,109 +15,114 @@ */ public class RandomizedTransactions { - private static final Logger log = org.slf4j.LoggerFactory.getLogger(RandomizedTransactions.class); - - static String url = - "jdbc:h2:mem:Pledger;DB_CLOSE_DELAY=-1;MODE=MariaDB"; // replace with your database url - static String user = "fintrack"; // replace with your database user - static String password = "fintrack"; // replace with your database password - - static String loremIpsum = - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" - + " incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis" - + " nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." - + " Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu" - + " fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in" - + " culpa qui officia deserunt mollit anim id est laborum."; - - static Random rand = new Random(); - - public static void create( - int source, - int destination, - int transactionsPerMonth, - double lower, - double upper, - String description, - String category) { - var transactionSql = - "INSERT INTO transaction_journal(user_id, created, updated, t_date, description," - + " category_id, type, currency_id) VALUES (1, ?, ?, ?, ?, (SELECT id FROM" - + " category WHERE label = ?), 'CREDIT', 1)"; - var partSql = - """ + private static final Logger log = + org.slf4j.LoggerFactory.getLogger(RandomizedTransactions.class); + + static String url = + "jdbc:h2:mem:Pledger;DB_CLOSE_DELAY=-1;MODE=MariaDB"; // replace with your database url + static String user = "pledger"; // replace with your database user + static String password = "pledger"; // replace with your database password + + static String loremIpsum = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" + + " incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis" + + " nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + + " Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu" + + " fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in" + + " culpa qui officia deserunt mollit anim id est laborum."; + + static Random rand = new Random(); + + public static void create( + int source, + int destination, + int transactionsPerMonth, + double lower, + double upper, + String description, + String category) { + var transactionSql = + "INSERT INTO transaction_journal(user_id, created, updated, t_date, description," + + " category_id, type, currency_id) VALUES (1, ?, ?, ?, ?, (SELECT id FROM" + + " category WHERE label = ?), 'CREDIT', 1)"; + var partSql = + """ INSERT INTO transaction_part(journal_id, created, updated, account_id, description, amount) VALUES ( (SELECT id FROM transaction_journal WHERE t_date = ? AND description = ? AND type = 'CREDIT' AND user_id = 1 limit 1), ?,?,?,?,?)"""; - try (Connection conn = DriverManager.getConnection(url, user, password)) { - try (PreparedStatement journalStatement = conn.prepareStatement(transactionSql); - PreparedStatement partStatement = conn.prepareStatement(partSql)) { - for (int year = 2016; year <= LocalDate.now().getYear(); year++) { - log.info("Creating transactions for year {} and description {}", year, description); - for (int month = 1; month <= 12; month++) { - var lastDayOfMonth = LocalDate.of(year, month, 1).lengthOfMonth(); - var updatedDescription = description.replaceAll( - "\\{month}", - LocalDate.of(year, month, 1) - .format(java.time.format.DateTimeFormatter.ofPattern("MMMM"))); - - for (int i = 0; i < transactionsPerMonth; i++) { - int day = rand.nextInt(lastDayOfMonth) + 1; - double amount = rand.nextDouble(lower, upper); - - String date = String.format("%d-%02d-%02d", year, month, day); - - // create the transaction - journalStatement.setString(1, date); - journalStatement.setString(2, date); - journalStatement.setString(3, date); - journalStatement.setString(4, updatedDescription); - journalStatement.setString(5, category); - journalStatement.executeUpdate(); - - // create the transaction parts - partStatement.setString(1, date); - partStatement.setString(2, updatedDescription); - partStatement.setString(3, date); - partStatement.setString(4, date); - partStatement.setInt(5, source); - partStatement.setString(6, updatedDescription); - partStatement.setDouble(7, -amount); - partStatement.executeUpdate(); - partStatement.setInt(5, destination); - partStatement.setDouble(7, amount); - partStatement.executeUpdate(); + try (Connection conn = DriverManager.getConnection(url, user, password)) { + try (PreparedStatement journalStatement = conn.prepareStatement(transactionSql); + PreparedStatement partStatement = conn.prepareStatement(partSql)) { + for (int year = 2016; year <= LocalDate.now().getYear(); year++) { + log.info( + "Creating transactions for year {} and description {}", + year, + description); + for (int month = 1; month <= 12; month++) { + var lastDayOfMonth = LocalDate.of(year, month, 1).lengthOfMonth(); + var updatedDescription = description.replaceAll( + "\\{month}", + LocalDate.of(year, month, 1) + .format(java.time.format.DateTimeFormatter.ofPattern( + "MMMM"))); + + for (int i = 0; i < transactionsPerMonth; i++) { + int day = rand.nextInt(lastDayOfMonth) + 1; + double amount = rand.nextDouble(lower, upper); + + String date = String.format("%d-%02d-%02d", year, month, day); + + // create the transaction + journalStatement.setString(1, date); + journalStatement.setString(2, date); + journalStatement.setString(3, date); + journalStatement.setString(4, updatedDescription); + journalStatement.setString(5, category); + journalStatement.executeUpdate(); + + // create the transaction parts + partStatement.setString(1, date); + partStatement.setString(2, updatedDescription); + partStatement.setString(3, date); + partStatement.setString(4, date); + partStatement.setInt(5, source); + partStatement.setString(6, updatedDescription); + partStatement.setDouble(7, -amount); + partStatement.executeUpdate(); + partStatement.setInt(5, destination); + partStatement.setDouble(7, amount); + partStatement.executeUpdate(); + } + } + } } - } + } catch (SQLException e) { + log.error("Failed to create randomized transaction: {}", e.getMessage()); } - } - } catch (SQLException e) { - log.error("Failed to create randomized transaction: {}", e.getMessage()); } - } - - public static void createContract( - String name, int companyId, String start, String end, double amount) { - var contractSql = - "INSERT INTO contract(user_id, name, company_id, start_date, end_date, description)" - + " VALUES (1, ?, ?, ?, ?, ?)"; - - try (Connection conn = DriverManager.getConnection(url, user, password)) { - log.info("Creating contract for company {} and name {}", companyId, name); - try (PreparedStatement contractStatement = conn.prepareStatement(contractSql)) { - contractStatement.setString(1, name); - contractStatement.setInt(2, companyId); - contractStatement.setString(3, start); - contractStatement.setString(4, end); - contractStatement.setString(5, loremIpsum); - contractStatement.executeUpdate(); - } - - // update the transaction journal set the contract id by the company id - var updateSql = - """ + + public static void createContract( + String name, int companyId, String start, String end, double amount) { + var contractSql = + "INSERT INTO contract(user_id, name, company_id, start_date, end_date, description)" + + " VALUES (1, ?, ?, ?, ?, ?)"; + + try (Connection conn = DriverManager.getConnection(url, user, password)) { + log.info("Creating contract for company {} and name {}", companyId, name); + try (PreparedStatement contractStatement = conn.prepareStatement(contractSql)) { + contractStatement.setString(1, name); + contractStatement.setInt(2, companyId); + contractStatement.setString(3, start); + contractStatement.setString(4, end); + contractStatement.setString(5, loremIpsum); + contractStatement.executeUpdate(); + } + + // update the transaction journal set the contract id by the company id + var updateSql = + """ UPDATE transaction_journal SET contract_id = (SELECT id FROM contract WHERE company_id = ? and name = ?) WHERE exists ( @@ -125,15 +131,15 @@ WHERE exists ( where transaction_part.journal_id = transaction_journal.id and transaction_part.account_id = ?)"""; - try (PreparedStatement updateStatement = conn.prepareStatement(updateSql)) { - updateStatement.setInt(1, companyId); - updateStatement.setString(2, name); - updateStatement.setInt(3, companyId); - updateStatement.executeUpdate(); - log.debug("Updated transaction journal with contract id"); - } - } catch (SQLException e) { - log.error("Failed to create contract: {}", e.getMessage()); + try (PreparedStatement updateStatement = conn.prepareStatement(updateSql)) { + updateStatement.setInt(1, companyId); + updateStatement.setString(2, name); + updateStatement.setInt(3, companyId); + updateStatement.executeUpdate(); + log.debug("Updated transaction journal with contract id"); + } + } catch (SQLException e) { + log.error("Failed to create contract: {}", e.getMessage()); + } } - } } diff --git a/jpa-repository/src/main/java/db/migration/V20200429151821__MigrateEncryptedStorage.java b/jpa-repository/src/main/java/db/migration/V20200429151821__MigrateEncryptedStorage.java index 422aeae3..846af95a 100644 --- a/jpa-repository/src/main/java/db/migration/V20200429151821__MigrateEncryptedStorage.java +++ b/jpa-repository/src/main/java/db/migration/V20200429151821__MigrateEncryptedStorage.java @@ -1,6 +1,7 @@ package db.migration; import lombok.extern.slf4j.Slf4j; + import org.flywaydb.core.api.migration.BaseJavaMigration; import org.flywaydb.core.api.migration.Context; import org.flywaydb.core.internal.jdbc.JdbcTemplate; @@ -8,59 +9,59 @@ @Slf4j public class V20200429151821__MigrateEncryptedStorage extends BaseJavaMigration { - private JdbcTemplate jdbcTemplate; - private String userSecret; + private JdbcTemplate jdbcTemplate; + private String userSecret; - // private final String securitySalt; - // private final String storageLocation; + // private final String securitySalt; + // private final String storageLocation; - public V20200429151821__MigrateEncryptedStorage() { - // this.securitySalt = - // String.valueOf(Hex.encode(securitySalt.getBytes(StandardCharsets.UTF_8))); - // this.storageLocation = storageLocation; - } + public V20200429151821__MigrateEncryptedStorage() { + // this.securitySalt = + // String.valueOf(Hex.encode(securitySalt.getBytes(StandardCharsets.UTF_8))); + // this.storageLocation = storageLocation; + } - @Override - public void migrate(Context context) throws Exception { - // jdbcTemplate = new JdbcTemplate(context.getConnection()); + @Override + public void migrate(Context context) throws Exception { + // jdbcTemplate = new JdbcTemplate(context.getConnection()); - // jdbcTemplate.query("select id, username, two_factor_secret from user_account", - // this::processUser); - } + // jdbcTemplate.query("select id, username, two_factor_secret from user_account", + // this::processUser); + } - // private synchronized void processUser(ResultSet rs) throws SQLException { - // var id = rs.getLong("id"); - // var username = rs.getString("username"); - // userSecret = rs.getString("two_factor_secret"); - // - // log.info("Start encryption of files for {}", username); - // - // jdbcTemplate.query("select id, file_code from import_config where user_id = " + id, - // this::encrypt); - // jdbcTemplate.query("select id, file_code from import where user_id = " + id, - // this::encrypt); - // jdbcTemplate.query("select id, file_token as file_code from contract where user_id = " - // + - // id, this::encrypt); - // } - // - // private void encrypt(ResultSet rs) throws SQLException { - // try { - // final Path filePath = Paths.get(this.storageLocation + "/upload/" + - // rs.getString("file_code")); - // byte[] content = Files.readString(filePath).getBytes(StandardCharsets.UTF_8); - // - // var encryptor = Encryptors.standard(userSecret, securitySalt); - // var encrypted = encryptor.encrypt(content); - // - // try (var output = new FileOutputStream(new File(this.storageLocation + "/upload/" - // + - // rs.getString("file_code")), false)) { - // output.write(encrypted); - // } - // } catch (IOException e) { - // log.warn("Skipping file {} for encryption migration", rs.getString("file_code")); - // } - // } + // private synchronized void processUser(ResultSet rs) throws SQLException { + // var id = rs.getLong("id"); + // var username = rs.getString("username"); + // userSecret = rs.getString("two_factor_secret"); + // + // log.info("Start encryption of files for {}", username); + // + // jdbcTemplate.query("select id, file_code from import_config where user_id = " + id, + // this::encrypt); + // jdbcTemplate.query("select id, file_code from import where user_id = " + id, + // this::encrypt); + // jdbcTemplate.query("select id, file_token as file_code from contract where user_id = " + // + + // id, this::encrypt); + // } + // + // private void encrypt(ResultSet rs) throws SQLException { + // try { + // final Path filePath = Paths.get(this.storageLocation + "/upload/" + + // rs.getString("file_code")); + // byte[] content = Files.readString(filePath).getBytes(StandardCharsets.UTF_8); + // + // var encryptor = Encryptors.standard(userSecret, securitySalt); + // var encrypted = encryptor.encrypt(content); + // + // try (var output = new FileOutputStream(new File(this.storageLocation + "/upload/" + // + + // rs.getString("file_code")), false)) { + // output.write(encrypted); + // } + // } catch (IOException e) { + // log.warn("Skipping file {} for encryption migration", rs.getString("file_code")); + // } + // } } diff --git a/jpa-repository/src/main/java/db/migration/V20200430171321__MigrateToEncryptedDatabase.java b/jpa-repository/src/main/java/db/migration/V20200430171321__MigrateToEncryptedDatabase.java index 9973c1a5..1f50b3d8 100644 --- a/jpa-repository/src/main/java/db/migration/V20200430171321__MigrateToEncryptedDatabase.java +++ b/jpa-repository/src/main/java/db/migration/V20200430171321__MigrateToEncryptedDatabase.java @@ -1,6 +1,7 @@ package db.migration; import lombok.extern.slf4j.Slf4j; + import org.flywaydb.core.api.migration.BaseJavaMigration; import org.flywaydb.core.api.migration.Context; import org.flywaydb.core.internal.jdbc.JdbcTemplate; @@ -8,56 +9,56 @@ @Slf4j public class V20200430171321__MigrateToEncryptedDatabase extends BaseJavaMigration { - private JdbcTemplate jdbcTemplate; - - // private final TextEncryptor encryptor; - - public V20200430171321__MigrateToEncryptedDatabase() { - // this.encryptor = Encryptors.queryableText(securityKey, - // "46415339383033346a77464153723938346a776166616d6466"); - } - - @Override - public void migrate(Context context) throws Exception { - // jdbcTemplate = new JdbcTemplate(context.getConnection()); - - // jdbcTemplate.query("select id, username from user_account", this::processUser); - } - - // private synchronized void processUser(ResultSet rs) throws SQLException { - // var id = rs.getLong("id"); - // var username = rs.getString("username"); - // - // log.info("Start encryption of database tables for {}", username); - // - // jdbcTemplate.query("select id, name, iban, bic, number from account where user_id = " - // + - // id, this::encryptAccount); - // } - // - // private void encryptAccount(ResultSet rs) throws SQLException { - // var name = API.Option(rs.getString("name")).map(encryptor::encrypt).getOrSupply(() -> - // null); - // var iban = API.Option(rs.getString("iban")).map(encryptor::encrypt).getOrSupply(() -> - // null); - // var bic = API.Option(rs.getString("bic")).map(encryptor::encrypt).getOrSupply(() -> - // null); - // var number = API.Option(rs.getString("number")).map(encryptor::encrypt).getOrSupply(() - // -> null); - // - // var sql = "update account set name = '" + name + "'"; - // if (iban != null) { - // sql += ", iban = '"+ iban +"'"; - // } - // if (bic != null) { - // sql += ", bic = '"+ bic +"'"; - // } - // if (number != null) { - // sql += ", number = '"+ number +"'"; - // } - // sql += " where id = " + rs.getLong("id"); - // - // jdbcTemplate.update(sql); - // } + private JdbcTemplate jdbcTemplate; + + // private final TextEncryptor encryptor; + + public V20200430171321__MigrateToEncryptedDatabase() { + // this.encryptor = Encryptors.queryableText(securityKey, + // "46415339383033346a77464153723938346a776166616d6466"); + } + + @Override + public void migrate(Context context) throws Exception { + // jdbcTemplate = new JdbcTemplate(context.getConnection()); + + // jdbcTemplate.query("select id, username from user_account", this::processUser); + } + + // private synchronized void processUser(ResultSet rs) throws SQLException { + // var id = rs.getLong("id"); + // var username = rs.getString("username"); + // + // log.info("Start encryption of database tables for {}", username); + // + // jdbcTemplate.query("select id, name, iban, bic, number from account where user_id = " + // + + // id, this::encryptAccount); + // } + // + // private void encryptAccount(ResultSet rs) throws SQLException { + // var name = API.Option(rs.getString("name")).map(encryptor::encrypt).getOrSupply(() -> + // null); + // var iban = API.Option(rs.getString("iban")).map(encryptor::encrypt).getOrSupply(() -> + // null); + // var bic = API.Option(rs.getString("bic")).map(encryptor::encrypt).getOrSupply(() -> + // null); + // var number = API.Option(rs.getString("number")).map(encryptor::encrypt).getOrSupply(() + // -> null); + // + // var sql = "update account set name = '" + name + "'"; + // if (iban != null) { + // sql += ", iban = '"+ iban +"'"; + // } + // if (bic != null) { + // sql += ", bic = '"+ bic +"'"; + // } + // if (number != null) { + // sql += ", number = '"+ number +"'"; + // } + // sql += " where id = " + rs.getLong("id"); + // + // jdbcTemplate.update(sql); + // } } diff --git a/jpa-repository/src/main/java/db/migration/V20200503171321__MigrateToDecryptDatabase.java b/jpa-repository/src/main/java/db/migration/V20200503171321__MigrateToDecryptDatabase.java index 0b2566ca..f5e15ea9 100644 --- a/jpa-repository/src/main/java/db/migration/V20200503171321__MigrateToDecryptDatabase.java +++ b/jpa-repository/src/main/java/db/migration/V20200503171321__MigrateToDecryptDatabase.java @@ -1,6 +1,7 @@ package db.migration; import lombok.extern.slf4j.Slf4j; + import org.flywaydb.core.api.migration.BaseJavaMigration; import org.flywaydb.core.api.migration.Context; import org.flywaydb.core.internal.jdbc.JdbcTemplate; @@ -8,71 +9,71 @@ @Slf4j public class V20200503171321__MigrateToDecryptDatabase extends BaseJavaMigration { - private JdbcTemplate jdbcTemplate; + private JdbcTemplate jdbcTemplate; - // private final TextEncryptor encryptor; + // private final TextEncryptor encryptor; - public V20200503171321__MigrateToDecryptDatabase() { - // this.encryptor = Encryptors.queryableText(securityKey, - // "46415339383033346a77464153723938346a776166616d6466"); - } + public V20200503171321__MigrateToDecryptDatabase() { + // this.encryptor = Encryptors.queryableText(securityKey, + // "46415339383033346a77464153723938346a776166616d6466"); + } - @Override - public void migrate(Context context) throws Exception { - // jdbcTemplate = new JdbcTemplate(context.getConnection()); + @Override + public void migrate(Context context) throws Exception { + // jdbcTemplate = new JdbcTemplate(context.getConnection()); - // jdbcTemplate.query("select id, username from user_account", this::processUser); - } + // jdbcTemplate.query("select id, username from user_account", this::processUser); + } - // private synchronized void processUser(ResultSet rs) throws SQLException { - // var id = rs.getLong("id"); - // var username = rs.getString("username"); - // - // log.info("Start encryption of database tables for {}", username); - // - // jdbcTemplate.query("select id, name, iban, bic, number from account where user_id = " - // + - // id, this::encryptAccount); - // } - // - // private void encryptAccount(ResultSet rs) throws SQLException { - // var name = API.Option(rs.getString("name")).map(encryptor::decrypt).getOrSupply(() -> - // null); - // var iban = API.Option(rs.getString("iban")).map(encryptor::decrypt).getOrSupply(() -> - // null); - // var bic = API.Option(rs.getString("bic")).map(encryptor::decrypt).getOrSupply(() -> - // null); - // var number = API.Option(rs.getString("number")).map(encryptor::decrypt).getOrSupply(() - // -> null); - // - // var sql = "update account set name = ?"; - // if (iban != null) { - // sql += ", iban = ?"; - // } - // if (bic != null) { - // sql += ", bic = ?"; - // } - // if (number != null) { - // sql += ", number = ?"; - // } - // sql += " where id = " + rs.getLong("id"); - // - // jdbcTemplate.update(sql, preparedStatement -> { - // var index = 1; - // preparedStatement.setString(index, name); - // if (iban != null) { - // index++; - // preparedStatement.setString(index, iban); - // } - // if (bic != null) { - // index++; - // preparedStatement.setString(index, bic); - // } - // if (number != null) { - // index++; - // preparedStatement.setString(index, number); - // } - // }); - // } + // private synchronized void processUser(ResultSet rs) throws SQLException { + // var id = rs.getLong("id"); + // var username = rs.getString("username"); + // + // log.info("Start encryption of database tables for {}", username); + // + // jdbcTemplate.query("select id, name, iban, bic, number from account where user_id = " + // + + // id, this::encryptAccount); + // } + // + // private void encryptAccount(ResultSet rs) throws SQLException { + // var name = API.Option(rs.getString("name")).map(encryptor::decrypt).getOrSupply(() -> + // null); + // var iban = API.Option(rs.getString("iban")).map(encryptor::decrypt).getOrSupply(() -> + // null); + // var bic = API.Option(rs.getString("bic")).map(encryptor::decrypt).getOrSupply(() -> + // null); + // var number = API.Option(rs.getString("number")).map(encryptor::decrypt).getOrSupply(() + // -> null); + // + // var sql = "update account set name = ?"; + // if (iban != null) { + // sql += ", iban = ?"; + // } + // if (bic != null) { + // sql += ", bic = ?"; + // } + // if (number != null) { + // sql += ", number = ?"; + // } + // sql += " where id = " + rs.getLong("id"); + // + // jdbcTemplate.update(sql, preparedStatement -> { + // var index = 1; + // preparedStatement.setString(index, name); + // if (iban != null) { + // index++; + // preparedStatement.setString(index, iban); + // } + // if (bic != null) { + // index++; + // preparedStatement.setString(index, bic); + // } + // if (number != null) { + // index++; + // preparedStatement.setString(index, number); + // } + // }); + // } } diff --git a/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/native-image.properties b/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/native-image.properties new file mode 100644 index 00000000..fb0cfcad --- /dev/null +++ b/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/native-image.properties @@ -0,0 +1 @@ +EnableURLProtocols=resource,jar,file diff --git a/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/resource-config.json b/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/resource-config.json index 228dce8d..434994b6 100644 --- a/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/resource-config.json +++ b/jpa-repository/src/main/resources/META-INF/native-image/com.jongsoft.finance/jpa-repository/resource-config.json @@ -1,6 +1,9 @@ { "resources": { "includes": [ + { + "pattern": "application-.*\\.properties" + }, { "pattern": "db/camunda/h2/.*" }, @@ -15,4 +18,4 @@ } ] } -} \ No newline at end of file +} diff --git a/jpa-repository/src/main/resources/application-demo.properties b/jpa-repository/src/main/resources/application-demo.properties new file mode 100644 index 00000000..c2eca789 --- /dev/null +++ b/jpa-repository/src/main/resources/application-demo.properties @@ -0,0 +1,13 @@ +# Datasource configuration +datasources.default.url=jdbc:h2:mem:Pledger;DB_CLOSE_DELAY=-1;MODE=MariaDB +datasources.default.driverClassName=org.h2.Driver +datasources.default.username=${DATABASE_USER:pledger} +datasources.default.password=${DATABASE_PASSWORD:pledger} +datasources.default.historyLevel=none +datasources.default.dialect=mysql + +# Flyway configuration +flyway.datasources.default.locations[0]=classpath:db/camunda/h2 +flyway.datasources.default.locations[1]=classpath:db/migration/mysql +flyway.datasources.default.locations[2]=classpath:db/sample +flyway.datasources.default.fail-on-missing-locations=true diff --git a/jpa-repository/src/main/resources/application-demo.yml b/jpa-repository/src/main/resources/application-demo.yml deleted file mode 100644 index 85335929..00000000 --- a/jpa-repository/src/main/resources/application-demo.yml +++ /dev/null @@ -1,14 +0,0 @@ -datasources: - default: - url: jdbc:h2:mem:Pledger;DB_CLOSE_DELAY=-1;MODE=MariaDB - driverClassName: org.h2.Driver - username: ${DATABASE_USER:fintrack} - password: ${DATABASE_PASSWORD:fintrack} - historyLevel: none - dialect: mysql - -flyway: - datasources: - default: - locations: ["classpath:db/camunda/h2", "classpath:db/migration/mysql", "classpath:db/sample"] - fail-on-missing-locations: true diff --git a/jpa-repository/src/main/resources/application-h2.properties b/jpa-repository/src/main/resources/application-h2.properties new file mode 100644 index 00000000..45466f77 --- /dev/null +++ b/jpa-repository/src/main/resources/application-h2.properties @@ -0,0 +1,10 @@ +# Datasource configuration +datasources.default.url=jdbc:h2:file:${micronaut.application.storage.location:-}/db/personal_finance_db;DB_CLOSE_DELAY=1000;MODE=MariaDB;IGNORECASE=TRUE +datasources.default.driverClassName=org.h2.Driver +datasources.default.username=${DATABASE_USER:fintrack} +datasources.default.password=${DATABASE_PASSWORD:fintrack} +datasources.default.dialect=mysql + + # Flyway configuration +flyway.datasources.default.locations[0]=classpath:db/camunda/h2 +flyway.datasources.default.locations[1]=classpath:db/migration/mysql diff --git a/jpa-repository/src/main/resources/application-h2.yml b/jpa-repository/src/main/resources/application-h2.yml deleted file mode 100644 index a67d96ff..00000000 --- a/jpa-repository/src/main/resources/application-h2.yml +++ /dev/null @@ -1,12 +0,0 @@ -datasources: - default: - url: jdbc:h2:file:${micronaut.application.storage.location:-}/db/personal_finance_db;DB_CLOSE_DELAY=1000;MODE=MariaDB;IGNORECASE=TRUE - driverClassName: org.h2.Driver - username: ${DATABASE_USER:fintrack} - password: ${DATABASE_PASSWORD:fintrack} - dialect: mysql - -flyway: - datasources: - default: - locations: ["classpath:db/camunda/h2", "classpath:db/migration/mysql"] diff --git a/jpa-repository/src/main/resources/application-jpa.properties b/jpa-repository/src/main/resources/application-jpa.properties new file mode 100644 index 00000000..1f336a8d --- /dev/null +++ b/jpa-repository/src/main/resources/application-jpa.properties @@ -0,0 +1,6 @@ +# JPA configuration +jpa.default.compile-time-hibernate-proxies=true +jpa.default.properties.jdbc.time_zone=UTC +jpa.default.properties.hibernate.hbm2ddl.auto=none +jpa.default.properties.hibernate.physical_naming_strategy=com.jongsoft.finance.jpa.DefaultNamingStrategy +jpa.default.properties.hibernate.show_sql=false diff --git a/jpa-repository/src/main/resources/application-mysql.properties b/jpa-repository/src/main/resources/application-mysql.properties new file mode 100644 index 00000000..69b425a1 --- /dev/null +++ b/jpa-repository/src/main/resources/application-mysql.properties @@ -0,0 +1,14 @@ +# Datasource configuration +datasources.default.url=jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT:3306}/${DATABASE_SCHEMA:fintrack}?serverTimezone=UTC +datasources.default.driverClassName=com.mysql.cj.jdbc.Driver +datasources.default.username=${DATABASE_USER:fintrack} +datasources.default.password=${DATABASE_PASSWORD:fintrack} +datasources.default.dialect=mysql + + # JPA configuration +jpa.default.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect + + # Flyway configuration +flyway.datasources.default.locations[0]=classpath:db/camunda/mysql +flyway.datasources.default.locations[1]=classpath:db/migration/mysql +flyway.datasources.default.validate-on-migrate=false diff --git a/jpa-repository/src/main/resources/application-mysql.yml b/jpa-repository/src/main/resources/application-mysql.yml deleted file mode 100644 index d267f52f..00000000 --- a/jpa-repository/src/main/resources/application-mysql.yml +++ /dev/null @@ -1,18 +0,0 @@ -datasources: - default: - url: jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT:3306}/${DATABASE_SCHEMA:fintrack}?serverTimezone=UTC - driverClassName: com.mysql.cj.jdbc.Driver - username: ${DATABASE_USER:fintrack} - password: ${DATABASE_PASSWORD:fintrack} - dialect: mysql - -jpa: - default: - properties: - hibernate: - dialect: org.hibernate.dialect.MySQLDialect -flyway: - datasources: - default: - locations: ["classpath:db/camunda/mysql", "classpath:db/migration/mysql"] - validate-on-migrate: false diff --git a/jpa-repository/src/main/resources/application-psql.properties b/jpa-repository/src/main/resources/application-psql.properties new file mode 100644 index 00000000..f53f2418 --- /dev/null +++ b/jpa-repository/src/main/resources/application-psql.properties @@ -0,0 +1,17 @@ +# Datasource configuration +datasources.default.url=jdbc:postgresql://${DATABASE_HOST:localhost}:${DATABASE_PORT:5432}/${DATABASE_SCHEMA:pledger} +datasources.default.driverClassName=org.postgresql.Driver +datasources.default.username=${DATABASE_USER:pledger} +datasources.default.password=${DATABASE_PASSWORD:pledger} +datasources.default.dialect=postgres + + # JPA configuration +jpa.default.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + + # Flyway configuration +flyway.datasources.default.locations[0]=classpath:db/camunda/psql +flyway.datasources.default.locations[1]=classpath:db/migration/psql +flyway.datasources.default.validate-on-migrate=false + + # Application configuration +application.ai.vectors.storage-type=${VECTOR_TYPE:memory} diff --git a/jpa-repository/src/main/resources/application-psql.yml b/jpa-repository/src/main/resources/application-psql.yml deleted file mode 100644 index 8fa6f50c..00000000 --- a/jpa-repository/src/main/resources/application-psql.yml +++ /dev/null @@ -1,23 +0,0 @@ -datasources: - default: - url: jdbc:postgresql://${DATABASE_HOST:localhost}:${DATABASE_PORT:5432}/${DATABASE_SCHEMA:pledger} - driverClassName: org.postgresql.Driver - username: ${DATABASE_USER:pledger} - password: ${DATABASE_PASSWORD:pledger} - dialect: postgres - -jpa: - default: - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect -flyway: - datasources: - default: - locations: ["classpath:db/camunda/psql", "classpath:db/migration/psql"] - validate-on-migrate: false - -application: - ai: - vectors: - storage-type: ${VECTOR_TYPE:memory} diff --git a/jpa-repository/src/main/resources/db/camunda/h2/V20250927141200__ugprade_7.22_to_7.23.sql b/jpa-repository/src/main/resources/db/camunda/h2/V20250927141200__ugprade_7.22_to_7.23.sql new file mode 100644 index 00000000..c7d927c3 --- /dev/null +++ b/jpa-repository/src/main/resources/db/camunda/h2/V20250927141200__ugprade_7.22_to_7.23.sql @@ -0,0 +1,25 @@ +-- +-- Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH +-- under one or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information regarding copyright +-- ownership. Camunda licenses this file to you under the Apache License, +-- Version 2.0; you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +insert into ACT_GE_SCHEMA_LOG +values ('1200', CURRENT_TIMESTAMP, '7.23.0'); + +alter table ACT_HI_COMMENT + add column REV_ integer not null + default 1; + +alter table ACT_RU_EXECUTION add PROC_DEF_KEY_ varchar(255); diff --git a/jpa-repository/src/main/resources/db/camunda/mysql/V20250927141200__ugprade_7.22_to_7.23.sql b/jpa-repository/src/main/resources/db/camunda/mysql/V20250927141200__ugprade_7.22_to_7.23.sql new file mode 100644 index 00000000..f4a90781 --- /dev/null +++ b/jpa-repository/src/main/resources/db/camunda/mysql/V20250927141200__ugprade_7.22_to_7.23.sql @@ -0,0 +1,25 @@ +-- +-- Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH +-- under one or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information regarding copyright +-- ownership. Camunda licenses this file to you under the Apache License, +-- Version 2.0; you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +insert into ACT_GE_SCHEMA_LOG +values ('1200', CURRENT_TIMESTAMP, '7.23.0'); + +alter table ACT_HI_COMMENT + add column REV_ INTEGER not null + default 1; + +alter table ACT_RU_EXECUTION add column PROC_DEF_KEY_ varchar(255); diff --git a/jpa-repository/src/main/resources/db/camunda/psql/V20250927141200__upgrade_7.22_to_7.23.sql b/jpa-repository/src/main/resources/db/camunda/psql/V20250927141200__upgrade_7.22_to_7.23.sql new file mode 100644 index 00000000..33dea19a --- /dev/null +++ b/jpa-repository/src/main/resources/db/camunda/psql/V20250927141200__upgrade_7.22_to_7.23.sql @@ -0,0 +1,25 @@ +-- +-- Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH +-- under one or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information regarding copyright +-- ownership. Camunda licenses this file to you under the Apache License, +-- Version 2.0; you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +insert into ACT_GE_SCHEMA_LOG +values ('1200', CURRENT_TIMESTAMP, '7.23.0'); + +alter table ACT_HI_COMMENT + add column REV_ integer not null + default 1; + +alter table ACT_RU_EXECUTION add column PROC_DEF_KEY_ varchar(255); diff --git a/jpa-repository/src/main/resources/db/sample/V99990524180200__additional_samples.sql b/jpa-repository/src/main/resources/db/sample/V99990524180200__additional_samples.sql index 336c7c13..f28159e0 100644 --- a/jpa-repository/src/main/resources/db/sample/V99990524180200__additional_samples.sql +++ b/jpa-repository/src/main/resources/db/sample/V99990524180200__additional_samples.sql @@ -1,6 +1,6 @@ INSERT INTO user_account (id, username, password, currency, gravatar, two_factor_enabled, two_factor_secret, theme) VALUES (1, 'sample@e', '$2a$10$yZfinpG8MZtbjfKeNnrwlu4GMJuQLAV1.QnzcJPyrxjVIZMuPLYpi', null, null, false, - 'G5GABYRVECPIDLLG', 'dark'); + 'G5GABYRVECPIDLLG', 'light'); insert into user_roles select 1, id from role; @@ -153,4 +153,4 @@ values ('grocery', 1, 'DESCRIPTION', 'CONTAINS'), insert into rule_change(change_val, rule_id, field) values ('1', 1, 'CATEGORY'), ('1', 1, 'BUDGET'), - ('12', 2, 'CATEGORY'); \ No newline at end of file + ('12', 2, 'CATEGORY'); diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/JpaTestSetup.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/JpaTestSetup.java index d9adff5f..e0e2e11d 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/JpaTestSetup.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/JpaTestSetup.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.jpa; import com.jongsoft.finance.core.Encoder; +import com.jongsoft.finance.security.AuthenticationFacade; import io.micronaut.core.io.IOUtils; import io.micronaut.test.annotation.MockBean; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountEventListenerIT.java index 911c495b..89640515 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountEventListenerIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.messaging.commands.account.*; import com.jongsoft.finance.schedule.Periodicity; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -125,6 +126,7 @@ void handleRegisterSynonym_update() { } @MockBean + @Replaces(AuthenticationFacade.class) AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountProviderJpaIT.java index 6bacbcd1..f4f5576f 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/AccountProviderJpaIT.java @@ -10,6 +10,7 @@ import com.jongsoft.lang.Collections; import com.jongsoft.lang.Dates; import com.jongsoft.lang.collection.Sequence; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -174,6 +175,7 @@ void top() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractEventListenerIT.java index fcdcd2ab..4274e60d 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractEventListenerIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.jpa.contract.ContractJpa; import com.jongsoft.finance.messaging.commands.contract.*; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -96,6 +97,7 @@ void handleContractTerminated() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractProviderJpaIT.java index 5d99cbff..1f5e3327 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/account/ContractProviderJpaIT.java @@ -5,6 +5,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.ContractProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -73,6 +74,7 @@ void search_incorrectUser() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetEventListenerIT.java index e53022c8..ffba7dad 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetEventListenerIT.java @@ -6,6 +6,7 @@ import com.jongsoft.finance.messaging.commands.budget.CreateBudgetCommand; import com.jongsoft.finance.messaging.commands.budget.CreateExpenseCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -82,6 +83,7 @@ void handleExpenseCreatedEvent() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpaIT.java index 38ac420a..da7e0f4c 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/BudgetProviderJpaIT.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.BudgetProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -62,6 +63,7 @@ void first() throws IOException { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpaIT.java index bfbda1c7..6ea407bc 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/ExpenseProviderJpaIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.ExpenseProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -60,6 +61,7 @@ void lookup_byFilter() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandlerTest.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandlerTest.java index 6fbc0d27..5a09d7b2 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandlerTest.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/budget/UpdateExpenseHandlerTest.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.messaging.commands.budget.UpdateExpenseCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -48,7 +49,8 @@ void updateExpense() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } -} \ No newline at end of file +} diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigEventListenerIT.java index a61f1593..6b35018b 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigEventListenerIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.jpa.importer.entity.ImportConfig; import com.jongsoft.finance.messaging.commands.importer.CreateConfigurationCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -49,6 +50,7 @@ void handleCreatedEvent() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigProviderJpaIT.java index 866429b0..70a2a32b 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/CSVConfigProviderJpaIT.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.ImportConfigurationProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -55,6 +56,7 @@ void lookup_nameIncorrectUser() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/ImportProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/ImportProviderJpaIT.java index 13e14f77..78866211 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/ImportProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/importer/ImportProviderJpaIT.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.ImportProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -44,6 +45,7 @@ void lookup() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpaIT.java index d1e6ec62..ccc15f82 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/AnalyzeJobProviderJpaIT.java @@ -7,6 +7,7 @@ import com.jongsoft.finance.messaging.commands.insight.CreateAnalyzeJob; import com.jongsoft.finance.providers.AnalyzeJobProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; @@ -105,6 +106,7 @@ void completeJob() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpaIT.java index 9d3c3187..64596601 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/insight/SpendingInsightProviderJpaIT.java @@ -8,6 +8,7 @@ import com.jongsoft.finance.messaging.commands.insight.CreateSpendingInsight; import com.jongsoft.finance.providers.SpendingInsightProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -132,6 +133,7 @@ void save() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupListenerIT.java index 2a78ebea..c95f415b 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupListenerIT.java @@ -5,6 +5,7 @@ import com.jongsoft.finance.messaging.commands.rule.RenameRuleGroupCommand; import com.jongsoft.finance.messaging.commands.rule.ReorderRuleGroupCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -63,6 +64,7 @@ void handleSortedEvent() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpaIT.java index d5796831..5a27bf7e 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleGroupProviderJpaIT.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.TransactionRuleGroupProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -43,6 +44,7 @@ void lookup_name() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleListenerIT.java index 44d34761..afc85e3e 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleListenerIT.java @@ -6,7 +6,9 @@ import com.jongsoft.finance.messaging.commands.rule.ChangeConditionCommand; import com.jongsoft.finance.messaging.commands.rule.ChangeRuleCommand; import com.jongsoft.finance.messaging.commands.rule.ReorderRuleCommand; +import com.jongsoft.finance.messaging.commands.rule.RuleRemovedCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -68,7 +70,17 @@ void handleSortedEvent() { Assertions.assertThat(check.getSort()).isEqualTo(2); } + @Test + void removeRule() { + eventPublisher.publishEvent( + new RuleRemovedCommand(2L)); + + var check = entityManager.find(RuleJpa.class, 2L); + Assertions.assertThat(check.isArchived()).isTrue(); + } + @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpaIT.java index 33496cd4..d6d738a2 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/rule/TransactionRuleProviderJpaIT.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.TransactionRuleProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -46,6 +47,7 @@ void lookup_group() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagEventListenerIT.java index f3cbb834..11940e3a 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagEventListenerIT.java @@ -5,6 +5,7 @@ import com.jongsoft.finance.messaging.commands.tag.CreateTagCommand; import com.jongsoft.finance.messaging.commands.transaction.DeleteTagCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -59,6 +60,7 @@ void handleTagDeleted() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagProviderJpaIT.java index df6be128..6405268b 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TagProviderJpaIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.TagProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -57,6 +58,7 @@ void lookup_search() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionEventListenerIT.java index bd6712d3..c91b979c 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionEventListenerIT.java @@ -8,6 +8,7 @@ import com.jongsoft.finance.messaging.commands.transaction.*; import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Collections; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -194,6 +195,7 @@ void handleDeleteEvent() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpaIT.java index cd2d7c54..fc7b9e61 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionProviderJpaIT.java @@ -6,6 +6,7 @@ import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.finance.security.AuthenticationFacade; import com.jongsoft.lang.Collections; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -100,6 +101,7 @@ void similar() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleEventListenerIT.java index 2b03cb8d..47095b33 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleEventListenerIT.java @@ -8,6 +8,7 @@ import com.jongsoft.finance.messaging.commands.schedule.*; import com.jongsoft.finance.schedule.Periodicity; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -113,6 +114,7 @@ void handleReschedule() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleProviderJpaIT.java index 626f2e8f..4f6e5c85 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/transaction/TransactionScheduleProviderJpaIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.providers.TransactionScheduleProvider; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -49,6 +50,7 @@ void lookup_filter() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryEventListenerIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryEventListenerIT.java index 73498807..52cbba49 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryEventListenerIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryEventListenerIT.java @@ -6,6 +6,7 @@ import com.jongsoft.finance.messaging.commands.category.DeleteCategoryCommand; import com.jongsoft.finance.messaging.commands.category.RenameCategoryCommand; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; @@ -70,6 +71,7 @@ void handleRemovedEvent() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryProviderJpaIT.java b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryProviderJpaIT.java index 4157804d..d450133e 100644 --- a/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryProviderJpaIT.java +++ b/jpa-repository/src/test/java/com/jongsoft/finance/jpa/user/CategoryProviderJpaIT.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.jpa.JpaTestSetup; import com.jongsoft.finance.jpa.category.CategoryProviderJpa; import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; import io.micronaut.test.annotation.MockBean; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; @@ -67,6 +68,7 @@ void lookup_byFilter() { } @MockBean + @Replaces AuthenticationFacade authenticationFacade() { return Mockito.mock(AuthenticationFacade.class); } diff --git a/learning/learning-module-llm/build.gradle.kts b/learning/learning-module-llm/build.gradle.kts index bad84e6b..3f8372d9 100644 --- a/learning/learning-module-llm/build.gradle.kts +++ b/learning/learning-module-llm/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(mn.micronaut.context) implementation(mn.micronaut.data.tx) + implementation(mn.micronaut.micrometer.core) + implementation(libs.lang) implementation(llm.bundles.langchain4j) runtimeOnly(mn.snakeyaml) diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiEnabled.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiEnabled.java index 4af2130b..c21264ef 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiEnabled.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiEnabled.java @@ -1,7 +1,9 @@ package com.jongsoft.finance.llm; import io.micronaut.context.annotation.Requires; + import jakarta.inject.Qualifier; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -10,13 +12,13 @@ @Requires(env = "ai") public @interface AiEnabled { - @Qualifier - @Retention(RetentionPolicy.RUNTIME) - @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) - @interface ClassificationAgent {} + @Qualifier + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) + @interface ClassificationAgent {} - @Qualifier - @Retention(RetentionPolicy.RUNTIME) - @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) - @interface AiExecutor {} + @Qualifier + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) + @interface AiExecutor {} } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiSuggestionEngine.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiSuggestionEngine.java index 208a2603..d56ad66d 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiSuggestionEngine.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/AiSuggestionEngine.java @@ -9,87 +9,97 @@ import com.jongsoft.finance.llm.agent.ClassificationAgent; import com.jongsoft.finance.llm.agent.TransactionExtractorAgent; import com.jongsoft.finance.llm.stores.ClassificationEmbeddingStore; + +import io.micrometer.core.annotation.Timed; import io.micronaut.context.annotation.Primary; + import jakarta.inject.Singleton; + +import org.slf4j.Logger; + import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Optional; import java.util.UUID; -import org.slf4j.Logger; @Primary @Singleton @AiEnabled class AiSuggestionEngine implements SuggestionEngine { - private final Logger log = getLogger(AiSuggestionEngine.class); - - private final ClassificationEmbeddingStore embeddingStore; - private final ClassificationAgent classificationAgent; - private final TransactionExtractorAgent transactionExtractorAgent; - - public AiSuggestionEngine( - ClassificationEmbeddingStore embeddingStore, - ClassificationAgent classificationAgent, - TransactionExtractorAgent transactionExtractorAgent) { - this.embeddingStore = embeddingStore; - this.classificationAgent = classificationAgent; - this.transactionExtractorAgent = transactionExtractorAgent; - } - - @Override - public SuggestionResult makeSuggestions(SuggestionInput transactionInput) { - log.debug("Starting classification on {}.", transactionInput); - var nullSafeInput = new SuggestionInput( - transactionInput.date(), - Optional.ofNullable(transactionInput.description()).orElse(""), - Optional.ofNullable(transactionInput.fromAccount()).orElse(""), - Optional.ofNullable(transactionInput.toAccount()).orElse(""), - transactionInput.amount()); - - var suggestions = - embeddingStore.classify(nullSafeInput).orElseGet(() -> fallbackToLLM(nullSafeInput)); - - log.trace("Finished classification with suggestions {}.", suggestions); - return suggestions; - } - - @Override - public Optional extractTransaction(String transactionInput) { - var extracted = transactionExtractorAgent.extractTransaction( - UUID.randomUUID(), LocalDate.now(), transactionInput); - return Optional.of(new TransactionResult( - extracted.type(), - extracted.date(), - Optional.ofNullable(extracted.fromAccount()) - .map(e -> new TransactionResult.AccountResult( - Optional.ofNullable(e.id()).orElse(-1L), e.name())) - .orElse(null), - Optional.ofNullable(extracted.toAccount()) - .map(e -> new TransactionResult.AccountResult( - Optional.ofNullable(e.id()).orElse(-1L), e.name())) - .orElse(null), - extracted.description(), - extracted.amount())); - } - - private SuggestionResult fallbackToLLM(SuggestionInput transactionInput) { - log.debug("No embedding found for the input, falling back to LLM."); - var suggestion = classificationAgent.classifyTransaction( - UUID.randomUUID(), - transactionInput.description(), - transactionInput.fromAccount(), - transactionInput.toAccount(), - transactionInput.amount(), - translateDate(transactionInput)); - return new SuggestionResult(suggestion.category(), suggestion.subCategory(), suggestion.tags()); - } - - private String translateDate(SuggestionInput transactionInput) { - if (transactionInput.date() != null) { - return transactionInput.date().format(DateTimeFormatter.BASIC_ISO_DATE); + private final Logger log = getLogger(AiSuggestionEngine.class); + + private final ClassificationEmbeddingStore embeddingStore; + private final ClassificationAgent classificationAgent; + private final TransactionExtractorAgent transactionExtractorAgent; + + public AiSuggestionEngine( + ClassificationEmbeddingStore embeddingStore, + ClassificationAgent classificationAgent, + TransactionExtractorAgent transactionExtractorAgent) { + this.embeddingStore = embeddingStore; + this.classificationAgent = classificationAgent; + this.transactionExtractorAgent = transactionExtractorAgent; } - return ""; - } + @Override + @Timed("learning.language-model.suggest") + public SuggestionResult makeSuggestions(SuggestionInput transactionInput) { + log.debug("Starting classification on {}.", transactionInput); + + var nullSafeInput = new SuggestionInput( + transactionInput.date(), + Optional.ofNullable(transactionInput.description()).orElse(""), + Optional.ofNullable(transactionInput.fromAccount()).orElse(""), + Optional.ofNullable(transactionInput.toAccount()).orElse(""), + transactionInput.amount()); + + var suggestions = embeddingStore + .classify(nullSafeInput) + .orElseGet(() -> fallbackToLLM(nullSafeInput)); + + log.trace("Finished classification with suggestions {}.", suggestions); + return suggestions; + } + + @Override + @Timed("learning.language-model.extract") + public Optional extractTransaction(String transactionInput) { + var extracted = transactionExtractorAgent.extractTransaction( + UUID.randomUUID(), LocalDate.now(), transactionInput); + return Optional.of(new TransactionResult( + extracted.type(), + extracted.date(), + Optional.ofNullable(extracted.fromAccount()) + .map(e -> new TransactionResult.AccountResult( + Optional.ofNullable(e.id()).orElse(-1L), e.name())) + .orElse(null), + Optional.ofNullable(extracted.toAccount()) + .map(e -> new TransactionResult.AccountResult( + Optional.ofNullable(e.id()).orElse(-1L), e.name())) + .orElse(null), + extracted.description(), + extracted.amount())); + } + + private SuggestionResult fallbackToLLM(SuggestionInput transactionInput) { + log.debug("No embedding found for the input, falling back to LLM."); + var suggestion = classificationAgent.classifyTransaction( + UUID.randomUUID(), + transactionInput.description(), + transactionInput.fromAccount(), + transactionInput.toAccount(), + transactionInput.amount(), + translateDate(transactionInput)); + return new SuggestionResult( + suggestion.category(), suggestion.subCategory(), suggestion.tags()); + } + + private String translateDate(SuggestionInput transactionInput) { + if (transactionInput.date() != null) { + return transactionInput.date().format(DateTimeFormatter.BASIC_ISO_DATE); + } + + return ""; + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/LanguageModelStarter.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/LanguageModelStarter.java index ab3351a8..7a987a82 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/LanguageModelStarter.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/LanguageModelStarter.java @@ -3,85 +3,89 @@ import com.jongsoft.finance.llm.agent.ClassificationAgent; import com.jongsoft.finance.llm.agent.TransactionExtractorAgent; import com.jongsoft.finance.llm.tools.AiTool; + import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.rag.RetrievalAugmentor; import dev.langchain4j.service.AiServices; import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; + import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @Factory @AiEnabled class LanguageModelStarter { - private static final Logger log = LoggerFactory.getLogger(LanguageModelStarter.class); - - @Bean - public ClassificationAgent transactionSupportAgent( - ChatModel model, - ToolSupplier aiTools, - @AiEnabled.ClassificationAgent @Nullable RetrievalAugmentor retrievalAugmentor) { - log.info("Setting up transaction support chat agent."); - var aiBuilder = AiServices.builder(ClassificationAgent.class) - .chatModel(model) - .chatMemoryProvider(chatMemoryProvider()); - - if (aiTools.getTools().length > 0) { - aiBuilder.tools(aiTools.getTools()); + private static final Logger log = LoggerFactory.getLogger(LanguageModelStarter.class); + + @Bean + public ClassificationAgent transactionSupportAgent( + ChatModel model, + ToolSupplier aiTools, + @AiEnabled.ClassificationAgent @Nullable RetrievalAugmentor retrievalAugmentor) { + log.info("Setting up transaction support chat agent."); + var aiBuilder = AiServices.builder(ClassificationAgent.class) + .chatModel(model) + .chatMemoryProvider(chatMemoryProvider()); + + if (aiTools.getTools().length > 0) { + aiBuilder.tools(aiTools.getTools()); + } + Optional.ofNullable(retrievalAugmentor).ifPresent(aiBuilder::retrievalAugmentor); + + return aiBuilder.build(); } - Optional.ofNullable(retrievalAugmentor).ifPresent(aiBuilder::retrievalAugmentor); - return aiBuilder.build(); - } + @Bean + public TransactionExtractorAgent transactionExtractorAgent( + ChatModel model, ToolSupplier aiTools) { + log.info("Setting up transaction extractor chat agent."); + var aiBuilder = AiServices.builder(TransactionExtractorAgent.class) + .chatModel(model) + .chatMemoryProvider(chatMemoryProvider()); - @Bean - public TransactionExtractorAgent transactionExtractorAgent( - ChatModel model, ToolSupplier aiTools) { - log.info("Setting up transaction extractor chat agent."); - var aiBuilder = AiServices.builder(TransactionExtractorAgent.class) - .chatModel(model) - .chatMemoryProvider(chatMemoryProvider()); + if (aiTools.getTools().length > 0) { + aiBuilder.tools(aiTools.getTools()); + } - if (aiTools.getTools().length > 0) { - aiBuilder.tools(aiTools.getTools()); + return aiBuilder.build(); } - return aiBuilder.build(); - } - - @Bean - @AiEnabled.AiExecutor - public ExecutorService executorService() { - return Executors.newScheduledThreadPool(5); - } - - private ChatMemoryProvider chatMemoryProvider() { - return memoryId -> MessageWindowChatMemory.builder() - .id(memoryId) - .maxMessages(10) - .chatMemoryStore(new InMemoryChatMemoryStore()) - .build(); - } - - @Bean - @AiEnabled.ClassificationAgent - @Requires(property = "application.ai.engine", value = "open-ai") - Optional classificationAugmenter() { - return Optional.empty(); - } - - @Bean - @Requires(property = "application.ai.engine", value = "open-ai") - ToolSupplier toolSupplier(List knownTools) { - return () -> knownTools.toArray(new AiTool[0]); - } + @Bean + @AiEnabled.AiExecutor + public ExecutorService executorService() { + return Executors.newScheduledThreadPool(5); + } + + private ChatMemoryProvider chatMemoryProvider() { + return memoryId -> MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .chatMemoryStore(new InMemoryChatMemoryStore()) + .build(); + } + + @Bean + @AiEnabled.ClassificationAgent + @Requires(property = "application.ai.engine", value = "open-ai") + Optional classificationAugmenter() { + return Optional.empty(); + } + + @Bean + @Requires(property = "application.ai.engine", value = "open-ai") + ToolSupplier toolSupplier(List knownTools) { + return () -> knownTools.toArray(new AiTool[0]); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/ToolSupplier.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/ToolSupplier.java index 14325aaa..a79fd4cb 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/ToolSupplier.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/ToolSupplier.java @@ -2,5 +2,5 @@ @FunctionalInterface public interface ToolSupplier { - Object[] getTools(); + Object[] getTools(); } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/ClassificationAgent.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/ClassificationAgent.java index f1cb5246..74765913 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/ClassificationAgent.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/ClassificationAgent.java @@ -1,14 +1,16 @@ package com.jongsoft.finance.llm.agent; import com.jongsoft.finance.llm.dto.ClassificationDTO; + import dev.langchain4j.service.MemoryId; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; import dev.langchain4j.service.V; + import java.util.UUID; @SystemMessage({ - """ + """ You are a financial transaction classification assistant. Your task is to analyze and classify transactions based on the following input fields: - description: Free-text transaction description - from: Sender or originating entity @@ -42,8 +44,8 @@ }) public interface ClassificationAgent { - @UserMessage({ - """ + @UserMessage({ + """ Please classify the following financial transaction using ONLY the predefined categories, subcategories, and tags. IMPORTANT: You must first retrieve the valid categories, subcategories, and tags using the available tools before attempting classification. @@ -57,12 +59,12 @@ public interface ClassificationAgent { - Date: {{date}} Remember to use ONLY terms explicitly retrieved from the system's predefined classifications.""" - }) - ClassificationDTO classifyTransaction( - @MemoryId UUID chat, - @V("description") String description, - @V("from") String from, - @V("to") String to, - @V("amount") double amount, - @V("date") String date); + }) + ClassificationDTO classifyTransaction( + @MemoryId UUID chat, + @V("description") String description, + @V("from") String from, + @V("to") String to, + @V("amount") double amount, + @V("date") String date); } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/TransactionExtractorAgent.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/TransactionExtractorAgent.java index 4ee9ff62..8dfa835a 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/TransactionExtractorAgent.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/agent/TransactionExtractorAgent.java @@ -1,15 +1,17 @@ package com.jongsoft.finance.llm.agent; import com.jongsoft.finance.llm.dto.TransactionDTO; + import dev.langchain4j.service.MemoryId; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; import dev.langchain4j.service.V; + import java.time.LocalDate; import java.util.UUID; @SystemMessage({ - """ + """ You are a highly accurate and detail-oriented AI assistant specialized in extracting financial transaction information from natural language text. Your job is to identify and extract structured data about transactions mentioned in the input. @@ -39,13 +41,13 @@ Construct and return a JSON object (or array of objects, if multiple transaction }) public interface TransactionExtractorAgent { - @UserMessage({ - """ + @UserMessage({ + """ Please extract the transaction details from the following text and return them in the format of a TransactionDTO as described. Only include fields that can be confidently identified. The date of today is {{date}}. Here's the text: {{input}}""" - }) - TransactionDTO extractTransaction( - @MemoryId UUID chat, @V("date") LocalDate date, @V("input") String input); + }) + TransactionDTO extractTransaction( + @MemoryId UUID chat, @V("date") LocalDate date, @V("input") String input); } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/augmenters/ClassificationAugmenter.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/augmenters/ClassificationAugmenter.java index 4e98b988..07948ecf 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/augmenters/ClassificationAugmenter.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/augmenters/ClassificationAugmenter.java @@ -8,14 +8,17 @@ import com.jongsoft.finance.providers.BudgetProvider; import com.jongsoft.finance.providers.CategoryProvider; import com.jongsoft.finance.providers.TagProvider; + import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.rag.AugmentationRequest; import dev.langchain4j.rag.AugmentationResult; import dev.langchain4j.rag.RetrievalAugmentor; import dev.langchain4j.rag.query.Metadata; + +import org.slf4j.Logger; + import java.time.LocalDate; import java.util.stream.Collectors; -import org.slf4j.Logger; /** * A class that implements the RetrievalAugmentor interface to provide additional classification @@ -23,65 +26,68 @@ * CategoryProvider, and TagProvider to augment the user messages based on certain conditions. */ public class ClassificationAugmenter implements RetrievalAugmentor { - private final Logger logger = getLogger(getClass()); - - private final BudgetProvider budgetProvider; - private final CategoryProvider categoryProvider; - private final TagProvider tagProvider; - - public ClassificationAugmenter( - BudgetProvider budgetProvider, CategoryProvider categoryProvider, TagProvider tagProvider) { - this.budgetProvider = budgetProvider; - this.categoryProvider = categoryProvider; - this.tagProvider = tagProvider; - } - - @Override - public AugmentationResult augment(AugmentationRequest augmentationRequest) { - if (augmentationRequest.chatMessage() instanceof UserMessage userMessage) { - return AugmentationResult.builder() - .chatMessage(augment(userMessage, augmentationRequest.metadata())) - .build(); - } + private final Logger logger = getLogger(getClass()); - throw new IllegalStateException("Could not augment a message of type " - + augmentationRequest.chatMessage().getClass().getName()); - } + private final BudgetProvider budgetProvider; + private final CategoryProvider categoryProvider; + private final TagProvider tagProvider; - private UserMessage augment(UserMessage userMessage, Metadata metadata) { - var currentMessage = userMessage.singleText(); + public ClassificationAugmenter( + BudgetProvider budgetProvider, + CategoryProvider categoryProvider, + TagProvider tagProvider) { + this.budgetProvider = budgetProvider; + this.categoryProvider = categoryProvider; + this.tagProvider = tagProvider; + } - String allowedList = ""; - if (currentMessage.contains("Pick the correct category for a transaction on")) { - logger.trace("User message augmentation with available budgets."); - int year = LocalDate.now().getYear(); - int month = LocalDate.now().getMonthValue(); + @Override + public AugmentationResult augment(AugmentationRequest augmentationRequest) { + if (augmentationRequest.chatMessage() instanceof UserMessage userMessage) { + return AugmentationResult.builder() + .chatMessage(augment(userMessage, augmentationRequest.metadata())) + .build(); + } - allowedList = budgetProvider.lookup(year, month).stream() - .flatMap(b -> b.getExpenses().stream()) - .map(Budget.Expense::getName) - .collect(Collectors.joining(",")); + throw new IllegalStateException("Could not augment a message of type " + + augmentationRequest.chatMessage().getClass().getName()); } - if (currentMessage.contains("Pick the correct subcategory for a transaction")) { - logger.trace("User message augmentation with available categories."); - allowedList = categoryProvider.lookup().map(Category::getLabel).stream() - .collect(Collectors.joining(",")); - } + private UserMessage augment(UserMessage userMessage, Metadata metadata) { + var currentMessage = userMessage.singleText(); - if (currentMessage.contains("Pick the correct tags for a transaction on")) { - logger.trace("User message augmentation with available tags."); - allowedList = tagProvider.lookup().map(Tag::name).stream().collect(Collectors.joining(",")); - } + String allowedList = ""; + if (currentMessage.contains("Pick the correct category for a transaction on")) { + logger.trace("User message augmentation with available budgets."); + int year = LocalDate.now().getYear(); + int month = LocalDate.now().getMonthValue(); + + allowedList = budgetProvider.lookup(year, month).stream() + .flatMap(b -> b.getExpenses().stream()) + .map(Budget.Expense::getName) + .collect(Collectors.joining(",")); + } - var updatedRequest = - """ + if (currentMessage.contains("Pick the correct subcategory for a transaction")) { + logger.trace("User message augmentation with available categories."); + allowedList = categoryProvider.lookup().map(Category::getLabel).stream() + .collect(Collectors.joining(",")); + } + + if (currentMessage.contains("Pick the correct tags for a transaction on")) { + logger.trace("User message augmentation with available tags."); + allowedList = + tagProvider.lookup().map(Tag::name).stream().collect(Collectors.joining(",")); + } + + var updatedRequest = + """ %s You must choose from the following options: [%s]. Your response must **only** contain the chosen option in plain text and nothing else. Do not add any explanation, formatting, or extra words.""" - .formatted(currentMessage.split("\n")[0], allowedList); + .formatted(currentMessage.split("\n")[0], allowedList); - return UserMessage.userMessage(updatedRequest); - } + return UserMessage.userMessage(updatedRequest); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/AiConfiguration.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/AiConfiguration.java index 72d62a97..5b97c358 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/AiConfiguration.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/AiConfiguration.java @@ -1,43 +1,44 @@ package com.jongsoft.finance.llm.configuration; import com.jongsoft.finance.llm.AiEnabled; + import io.micronaut.context.annotation.ConfigurationProperties; @AiEnabled @ConfigurationProperties("application.ai") public class AiConfiguration { - private String engine; - private Double temperature; - private final OllamaConfiguration ollama; - private final OpenAiConfiguration openAI; + private String engine; + private Double temperature; + private final OllamaConfiguration ollama; + private final OpenAiConfiguration openAI; - AiConfiguration(OllamaConfiguration ollama, OpenAiConfiguration openAI) { - this.ollama = ollama; - this.openAI = openAI; - } + AiConfiguration(OllamaConfiguration ollama, OpenAiConfiguration openAI) { + this.ollama = ollama; + this.openAI = openAI; + } - public String getEngine() { - return engine; - } + public String getEngine() { + return engine; + } - public void setEngine(String engine) { - this.engine = engine; - } + public void setEngine(String engine) { + this.engine = engine; + } - public Double getTemperature() { - return temperature; - } + public Double getTemperature() { + return temperature; + } - public void setTemperature(Double temperature) { - this.temperature = temperature; - } + public void setTemperature(Double temperature) { + this.temperature = temperature; + } - public OllamaConfiguration getOllama() { - return ollama; - } + public OllamaConfiguration getOllama() { + return ollama; + } - public OpenAiConfiguration getOpenAI() { - return openAI; - } + public OpenAiConfiguration getOpenAI() { + return openAI; + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OllamaConfiguration.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OllamaConfiguration.java index 9c30a310..f9ac6bbf 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OllamaConfiguration.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OllamaConfiguration.java @@ -5,31 +5,31 @@ @ConfigurationProperties("application.ai.ollama") public class OllamaConfiguration { - private String uri; - private String model; - private boolean forceAugmentation; + private String uri; + private String model; + private boolean forceAugmentation; - public String getUri() { - return uri; - } + public String getUri() { + return uri; + } - public void setUri(String uri) { - this.uri = uri; - } + public void setUri(String uri) { + this.uri = uri; + } - public String getModel() { - return model; - } + public String getModel() { + return model; + } - public void setModel(String model) { - this.model = model; - } + public void setModel(String model) { + this.model = model; + } - public boolean isForceAugmentation() { - return forceAugmentation; - } + public boolean isForceAugmentation() { + return forceAugmentation; + } - public void setForceAugmentation(boolean forceAugmentation) { - this.forceAugmentation = forceAugmentation; - } + public void setForceAugmentation(boolean forceAugmentation) { + this.forceAugmentation = forceAugmentation; + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OpenAiConfiguration.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OpenAiConfiguration.java index 56acbb57..f76e0737 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OpenAiConfiguration.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/configuration/OpenAiConfiguration.java @@ -5,22 +5,22 @@ @ConfigurationProperties("application.ai.openai") public class OpenAiConfiguration { - private String key; - private String model; + private String key; + private String model; - public String getKey() { - return key; - } + public String getKey() { + return key; + } - public void setKey(String key) { - this.key = key; - } + public void setKey(String key) { + this.key = key; + } - public String getModel() { - return model; - } + public String getModel() { + return model; + } - public void setModel(String model) { - this.model = model; - } + public void setModel(String model) { + this.model = model; + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/AccountDTO.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/AccountDTO.java index d02fff8b..c74f03c8 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/AccountDTO.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/AccountDTO.java @@ -4,6 +4,6 @@ @Description("A financial account that can be used in transactions.") public record AccountDTO( - @Description("The unique identifier of the account.") Long id, - @Description("The name of the account.") String name, - @Description("The type of the account, leave blank at all times.") String type) {} + @Description("The unique identifier of the account.") Long id, + @Description("The name of the account.") String name, + @Description("The type of the account, leave blank at all times.") String type) {} diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/TransactionDTO.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/TransactionDTO.java index 045a595a..4a54988a 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/TransactionDTO.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/dto/TransactionDTO.java @@ -1,18 +1,20 @@ package com.jongsoft.finance.llm.dto; import com.jongsoft.finance.core.TransactionType; + import dev.langchain4j.model.output.structured.Description; + import java.time.LocalDate; @Description("The entity for deposits, withdrawals or transfers of money.") public record TransactionDTO( - @Description("The account that made the payment.") AccountDTO fromAccount, - @Description("The account that received the payment.") AccountDTO toAccount, - @Description("Write a short description for the transaction, excludes dates and amounts." - + " Be creative.") - String description, - @Description("The date of the transaction, in YYYY-MM-DD format.") LocalDate date, - @Description("The amount of the transaction, cannot be a negative number.") double amount, - @Description("The type of the transaction, debit for income, credit for expenses or" - + " transfer for transfers between my own accounts.") - TransactionType type) {} + @Description("The account that made the payment.") AccountDTO fromAccount, + @Description("The account that received the payment.") AccountDTO toAccount, + @Description("Write a short description for the transaction, excludes dates and amounts." + + " Be creative.") + String description, + @Description("The date of the transaction, in YYYY-MM-DD format.") LocalDate date, + @Description("The amount of the transaction, cannot be a negative number.") double amount, + @Description("The type of the transaction, debit for income, credit for expenses or" + + " transfer for transfers between my own accounts.") + TransactionType type) {} diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OllamaModelSetup.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OllamaModelSetup.java index 494cde65..c6172b8d 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OllamaModelSetup.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OllamaModelSetup.java @@ -8,6 +8,7 @@ import com.jongsoft.finance.providers.BudgetProvider; import com.jongsoft.finance.providers.CategoryProvider; import com.jongsoft.finance.providers.TagProvider; + import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.ollama.OllamaChatModel; @@ -16,81 +17,87 @@ import dev.langchain4j.model.ollama.OllamaModels; import dev.langchain4j.rag.AugmentationResult; import dev.langchain4j.rag.RetrievalAugmentor; + import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Requires; -import java.util.List; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; + @Factory @AiEnabled @Requires(property = "application.ai.engine", value = "ollama") class OllamaModelSetup { - private static final Logger log = LoggerFactory.getLogger(OllamaModelSetup.class); + private static final Logger log = LoggerFactory.getLogger(OllamaModelSetup.class); + + private final AiConfiguration configuration; + private OllamaModelCard chosenModel; - private final AiConfiguration configuration; - private OllamaModelCard chosenModel; + OllamaModelSetup(AiConfiguration configuration) { + this.configuration = configuration; + retrieveModelInfo(); + } - OllamaModelSetup(AiConfiguration configuration) { - this.configuration = configuration; - retrieveModelInfo(); - } + @Bean + ToolSupplier toolSupplier(List knownTools) { + if (configuration.getOllama().isForceAugmentation() || noToolSupport()) { + return () -> new Object[0]; + } - @Bean - ToolSupplier toolSupplier(List knownTools) { - if (configuration.getOllama().isForceAugmentation() || noToolSupport()) { - return () -> new Object[0]; + log.debug("Setting up Ai tools to be used with Ollama."); + return () -> knownTools.toArray(new AiTool[0]); } - log.debug("Setting up Ai tools to be used with Ollama."); - return () -> knownTools.toArray(new AiTool[0]); - } - - @Bean - @AiEnabled.ClassificationAgent - RetrievalAugmentor classificationAugmenter( - BudgetProvider budgetProvider, CategoryProvider categoryProvider, TagProvider tagProvider) { - if (configuration.getOllama().isForceAugmentation() || noToolSupport()) { - log.debug("Creating a classification augmenter since tools are not supported."); - return new ClassificationAugmenter(budgetProvider, categoryProvider, tagProvider); + @Bean + @AiEnabled.ClassificationAgent + RetrievalAugmentor classificationAugmenter( + BudgetProvider budgetProvider, + CategoryProvider categoryProvider, + TagProvider tagProvider) { + if (configuration.getOllama().isForceAugmentation() || noToolSupport()) { + log.debug("Creating a classification augmenter since tools are not supported."); + return new ClassificationAugmenter(budgetProvider, categoryProvider, tagProvider); + } + + return (userMessage) -> AugmentationResult.builder() + .chatMessage(userMessage.chatMessage()) + .build(); } - return (userMessage) -> - AugmentationResult.builder().chatMessage(userMessage.chatMessage()).build(); - } - - @Bean - ChatModel ollamaLanguageModel() { - log.info( - "Creating Ollama chat model with name {}, and temperature {}.", - configuration.getOllama().getModel(), - configuration.getTemperature()); - return OllamaChatModel.builder() - .modelName(configuration.getOllama().getModel()) - .baseUrl(configuration.getOllama().getUri()) - .temperature(configuration.getTemperature()) - .build(); - } - - @Bean - EmbeddingModel embeddingModel() { - return OllamaEmbeddingModel.builder() - .baseUrl(configuration.getOllama().getUri()) - .modelName(configuration.getOllama().getModel()) - .build(); - } - - private boolean noToolSupport() { - return !chosenModel.getTemplate().contains(".Tools"); - } - - private void retrieveModelInfo() { - var modelsResponse = OllamaModels.builder() - .baseUrl(configuration.getOllama().getUri()) - .build() - .modelCard(configuration.getOllama().getModel()); - - chosenModel = modelsResponse.content(); - } + @Bean + ChatModel ollamaLanguageModel() { + log.info( + "Creating Ollama chat model with name {}, and temperature {}.", + configuration.getOllama().getModel(), + configuration.getTemperature()); + return OllamaChatModel.builder() + .modelName(configuration.getOllama().getModel()) + .baseUrl(configuration.getOllama().getUri()) + .temperature(configuration.getTemperature()) + .build(); + } + + @Bean + EmbeddingModel embeddingModel() { + return OllamaEmbeddingModel.builder() + .baseUrl(configuration.getOllama().getUri()) + .modelName(configuration.getOllama().getModel()) + .build(); + } + + private boolean noToolSupport() { + return !chosenModel.getTemplate().contains(".Tools"); + } + + private void retrieveModelInfo() { + var modelsResponse = OllamaModels.builder() + .baseUrl(configuration.getOllama().getUri()) + .build() + .modelCard(configuration.getOllama().getModel()); + + chosenModel = modelsResponse.content(); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OpenAISetup.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OpenAISetup.java index 59cb2769..e7e888d5 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OpenAISetup.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/models/OpenAISetup.java @@ -2,13 +2,16 @@ import com.jongsoft.finance.llm.AiEnabled; import com.jongsoft.finance.llm.configuration.AiConfiguration; + import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; + import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Requires; + import org.slf4j.Logger; @AiEnabled @@ -16,30 +19,30 @@ @Requires(property = "application.ai.engine", value = "open-ai") public class OpenAISetup { - private final Logger log = org.slf4j.LoggerFactory.getLogger(OpenAISetup.class); - private final AiConfiguration configuration; - - public OpenAISetup(AiConfiguration configuration) { - this.configuration = configuration; - } - - @Bean - ChatModel openaiLanguageModel() { - log.info( - "Creating OpenAI chat model with name config: {}.", - configuration.getOpenAI().getModel()); - return OpenAiChatModel.builder() - .modelName(configuration.getOpenAI().getModel()) - .apiKey(configuration.getOpenAI().getKey()) - .temperature(configuration.getTemperature()) - .build(); - } - - @Bean - EmbeddingModel embeddingModel() { - return OpenAiEmbeddingModel.builder() - .modelName(configuration.getOpenAI().getModel()) - .apiKey(configuration.getOpenAI().getKey()) - .build(); - } + private final Logger log = org.slf4j.LoggerFactory.getLogger(OpenAISetup.class); + private final AiConfiguration configuration; + + public OpenAISetup(AiConfiguration configuration) { + this.configuration = configuration; + } + + @Bean + ChatModel openaiLanguageModel() { + log.info( + "Creating OpenAI chat model with name config: {}.", + configuration.getOpenAI().getModel()); + return OpenAiChatModel.builder() + .modelName(configuration.getOpenAI().getModel()) + .apiKey(configuration.getOpenAI().getKey()) + .temperature(configuration.getTemperature()) + .build(); + } + + @Bean + EmbeddingModel embeddingModel() { + return OpenAiEmbeddingModel.builder() + .modelName(configuration.getOpenAI().getModel()) + .apiKey(configuration.getOpenAI().getKey()) + .build(); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/ClassificationEmbeddingStore.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/ClassificationEmbeddingStore.java index 18c1e3a2..0e744ca2 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/ClassificationEmbeddingStore.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/ClassificationEmbeddingStore.java @@ -11,129 +11,152 @@ import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.finance.security.CurrentUserProvider; import com.jongsoft.lang.Control; + import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingSearchRequest; import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder; + +import io.micrometer.core.annotation.Timed; import io.micronaut.context.event.ShutdownEvent; import io.micronaut.context.event.StartupEvent; import io.micronaut.runtime.event.annotation.EventListener; + import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.Arrays; import java.util.Map; import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @Singleton @AiEnabled public class ClassificationEmbeddingStore { - private final Logger logger = LoggerFactory.getLogger(ClassificationEmbeddingStore.class); - - private final PledgerEmbeddingStore embeddingStore; - private final EmbeddingModel embeddingModel; - private final EmbeddingStoreFiller embeddingStoreFiller; - - private final CurrentUserProvider currentUserProvider; - private final TransactionProvider transactionProvider; - - ClassificationEmbeddingStore( - @AiEnabled.ClassificationAgent PledgerEmbeddingStore embeddingStore, - EmbeddingStoreFiller embeddingStoreFiller, - TransactionProvider transactionProvider, - CurrentUserProvider currentUserProvider) { - this.embeddingStore = embeddingStore; - this.embeddingStoreFiller = embeddingStoreFiller; - this.transactionProvider = transactionProvider; - this.currentUserProvider = currentUserProvider; - this.embeddingModel = new AllMiniLmL6V2EmbeddingModel(); - } - - public Optional classify(SuggestionInput input) { - var textSegment = TextSegment.from(input.description()); - - var searchRequest = EmbeddingSearchRequest.builder() - .filter(MetadataFilterBuilder.metadataKey("user") - .isEqualTo(currentUserProvider.currentUser().getUsername().email()) - .and( - // Filter applied to only match something with - // filled category, budget or - // tags - MetadataFilterBuilder.metadataKey("category") - .isNotEqualTo("") - .or(MetadataFilterBuilder.metadataKey("budget").isNotEqualTo("")) - .or(MetadataFilterBuilder.metadataKey("tags").isNotEqualTo("")))) - .queryEmbedding(embeddingModel.embed(textSegment).content()) - .maxResults(1) - .minScore(.8) - .build(); - - var hits = embeddingStore.embeddingStore().search(searchRequest).matches(); - if (hits.isEmpty()) { - return Optional.empty(); + private final Logger logger = LoggerFactory.getLogger(ClassificationEmbeddingStore.class); + + private final PledgerEmbeddingStore embeddingStore; + private final EmbeddingModel embeddingModel; + private final EmbeddingStoreFiller embeddingStoreFiller; + + private final CurrentUserProvider currentUserProvider; + private final TransactionProvider transactionProvider; + + ClassificationEmbeddingStore( + @AiEnabled.ClassificationAgent PledgerEmbeddingStore embeddingStore, + EmbeddingStoreFiller embeddingStoreFiller, + TransactionProvider transactionProvider, + CurrentUserProvider currentUserProvider) { + this.embeddingStore = embeddingStore; + this.embeddingStoreFiller = embeddingStoreFiller; + this.transactionProvider = transactionProvider; + this.currentUserProvider = currentUserProvider; + this.embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + } + + @Timed( + value = "learning.language-model.classify", + extraTags = {"perform-classify"}) + public Optional classify(SuggestionInput input) { + var textSegment = TextSegment.from(input.description()); + + var searchRequest = EmbeddingSearchRequest.builder() + .filter(MetadataFilterBuilder.metadataKey("user") + .isEqualTo( + currentUserProvider.currentUser().getUsername().email()) + .and( + // Filter applied to only match something with + // filled category, budget or + // tags + MetadataFilterBuilder.metadataKey("category") + .isNotEqualTo("") + .or(MetadataFilterBuilder.metadataKey("budget") + .isNotEqualTo("")) + .or(MetadataFilterBuilder.metadataKey("tags") + .isNotEqualTo("")))) + .queryEmbedding(embeddingModel.embed(textSegment).content()) + .maxResults(1) + .minScore(.8) + .build(); + + var hits = embeddingStore.embeddingStore().search(searchRequest).matches(); + if (hits.isEmpty()) { + return Optional.empty(); + } + + var firstHit = hits.getFirst(); + return Optional.of(convert(firstHit.embedded().metadata())); + } + + @EventListener + void handleStartup(StartupEvent startupEvent) { + logger.info("Initializing classification embedding store."); + if (embeddingStore.shouldInitialize()) { + embeddingStoreFiller.consumeTransactions(this::updateClassifications); + } + } + + @EventListener + void handleShutdown(ShutdownEvent shutdownEvent) { + logger.info("Shutting down classification embedding store."); + embeddingStore.close(); } - var firstHit = hits.getFirst(); - return Optional.of(convert(firstHit.embedded().metadata())); - } + @EventListener + @Timed( + value = "learning.language-model.classify", + extraTags = {"transaction-update"}) + void handleClassificationChanged(LinkTransactionCommand command) { + updateClassifications(transactionProvider.lookup(command.id()).get()); + } + + @EventListener + @Timed( + value = "learning.language-model.classify", + extraTags = {"transaction-create"}) + void handleTransactionAdded(TransactionCreated transactionCreated) { + updateClassifications( + transactionProvider.lookup(transactionCreated.transactionId()).get()); + } + + private SuggestionResult convert(Metadata metadata) { + return new SuggestionResult( + metadata.getString("budget"), + metadata.getString("category"), + Arrays.asList(metadata.getString("tags").split(";"))); + } - @EventListener - void handleStartup(StartupEvent startupEvent) { - logger.info("Initializing classification embedding store."); - if (embeddingStore.shouldInitialize()) { - embeddingStoreFiller.consumeTransactions(this::updateClassifications); + private void updateClassifications(Transaction transaction) { + logger.trace("Updating categorisation for transaction {}", transaction.getId()); + + var tags = transaction.getTags().isEmpty() + ? "" + : transaction.getTags().reduce((left, right) -> left + ";" + right); + var metadata = Map.of( + "id", transaction.getId().toString(), + "user", currentUserProvider.currentUser().getUsername().email(), + "category", Control.Option(transaction.getCategory()).getOrSupply(() -> ""), + "budget", Control.Option(transaction.getBudget()).getOrSupply(() -> ""), + "tags", tags); + var textSegment = + TextSegment.textSegment(transaction.getDescription(), Metadata.from(metadata)); + + embeddingStore + .embeddingStore() + .removeAll(MetadataFilterBuilder.metadataKey("id") + .isEqualTo(transaction.getId().toString()) + .and(MetadataFilterBuilder.metadataKey("user") + .isEqualTo(currentUserProvider + .currentUser() + .getUsername() + .email()))); + + embeddingStore + .embeddingStore() + .add(embeddingModel.embed(textSegment).content(), textSegment); } - } - - @EventListener - void handleShutdown(ShutdownEvent shutdownEvent) { - logger.info("Shutting down classification embedding store."); - embeddingStore.close(); - } - - @EventListener - void handleClassificationChanged(LinkTransactionCommand command) { - updateClassifications(transactionProvider.lookup(command.id()).get()); - } - - @EventListener - void handleTransactionAdded(TransactionCreated transactionCreated) { - updateClassifications( - transactionProvider.lookup(transactionCreated.transactionId()).get()); - } - - private SuggestionResult convert(Metadata metadata) { - return new SuggestionResult( - metadata.getString("budget"), - metadata.getString("category"), - Arrays.asList(metadata.getString("tags").split(";"))); - } - - private void updateClassifications(Transaction transaction) { - logger.trace("Updating categorisation for transaction {}", transaction.getId()); - - var tags = transaction.getTags().isEmpty() - ? "" - : transaction.getTags().reduce((left, right) -> left + ";" + right); - var metadata = Map.of( - "id", transaction.getId().toString(), - "user", currentUserProvider.currentUser().getUsername().email(), - "category", Control.Option(transaction.getCategory()).getOrSupply(() -> ""), - "budget", Control.Option(transaction.getBudget()).getOrSupply(() -> ""), - "tags", tags); - var textSegment = - TextSegment.textSegment(transaction.getDescription(), Metadata.from(metadata)); - - embeddingStore - .embeddingStore() - .removeAll(MetadataFilterBuilder.metadataKey("id") - .isEqualTo(transaction.getId().toString()) - .and(MetadataFilterBuilder.metadataKey("user") - .isEqualTo(currentUserProvider.currentUser().getUsername().email()))); - - embeddingStore.embeddingStore().add(embeddingModel.embed(textSegment).content(), textSegment); - } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/LlmEmbeddingFactory.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/LlmEmbeddingFactory.java index a5835617..3414037b 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/LlmEmbeddingFactory.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/stores/LlmEmbeddingFactory.java @@ -3,6 +3,7 @@ import com.jongsoft.finance.learning.stores.EmbeddingStoreFactory; import com.jongsoft.finance.learning.stores.PledgerEmbeddingStore; import com.jongsoft.finance.llm.AiEnabled; + import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; @@ -10,15 +11,15 @@ @AiEnabled class LlmEmbeddingFactory { - private final EmbeddingStoreFactory embeddingStoreFactory; + private final EmbeddingStoreFactory embeddingStoreFactory; - LlmEmbeddingFactory(EmbeddingStoreFactory embeddingStoreFactory) { - this.embeddingStoreFactory = embeddingStoreFactory; - } + LlmEmbeddingFactory(EmbeddingStoreFactory embeddingStoreFactory) { + this.embeddingStoreFactory = embeddingStoreFactory; + } - @Bean - @AiEnabled.ClassificationAgent - public PledgerEmbeddingStore classificationInMemory() { - return embeddingStoreFactory.createEmbeddingStore("classification"); - } + @Bean + @AiEnabled.ClassificationAgent + public PledgerEmbeddingStore classificationInMemory() { + return embeddingStoreFactory.createEmbeddingStore("classification"); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/AccountLookupTool.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/AccountLookupTool.java index 98919fc0..67b912cd 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/AccountLookupTool.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/AccountLookupTool.java @@ -5,42 +5,45 @@ import com.jongsoft.finance.llm.AiEnabled; import com.jongsoft.finance.llm.dto.AccountDTO; import com.jongsoft.finance.providers.AccountProvider; + import dev.langchain4j.agent.tool.Tool; + import jakarta.inject.Singleton; + import org.slf4j.Logger; @Singleton @AiEnabled public class AccountLookupTool implements AiTool { - private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(AccountLookupTool.class); - private static final AccountDTO UNKNOWN_ACCOUNT = new AccountDTO(-1L, "Unknown", ""); + private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(AccountLookupTool.class); + private static final AccountDTO UNKNOWN_ACCOUNT = new AccountDTO(-1L, "Unknown", ""); - private final AccountProvider accountProvider; - private final FilterFactory filterFactory; + private final AccountProvider accountProvider; + private final FilterFactory filterFactory; - public AccountLookupTool(AccountProvider accountProvider, FilterFactory filterFactory) { - this.accountProvider = accountProvider; - this.filterFactory = filterFactory; - } + public AccountLookupTool(AccountProvider accountProvider, FilterFactory filterFactory) { + this.accountProvider = accountProvider; + this.filterFactory = filterFactory; + } - @Tool("Fetch account information for a given account name.") - public AccountDTO lookup(String accountName) { - LOGGER.debug("Ai tool looking up account information for {}.", accountName); + @Tool("Fetch account information for a given account name.") + public AccountDTO lookup(String accountName) { + LOGGER.debug("Ai tool looking up account information for {}.", accountName); - var filter = filterFactory.account().name(accountName, false).page(0, 1); + var filter = filterFactory.account().name(accountName, false).page(0, 1); - var result = accountProvider.lookup(filter).content().map(this::convert); - if (result.isEmpty()) { - return accountProvider.synonymOf(accountName).map(this::convert).getOrSupply(() -> { - LOGGER.trace("Ai tool could not find account information for {}.", accountName); - return UNKNOWN_ACCOUNT; - }); - } + var result = accountProvider.lookup(filter).content().map(this::convert); + if (result.isEmpty()) { + return accountProvider.synonymOf(accountName).map(this::convert).getOrSupply(() -> { + LOGGER.trace("Ai tool could not find account information for {}.", accountName); + return UNKNOWN_ACCOUNT; + }); + } - return result.head(); - } + return result.head(); + } - private AccountDTO convert(Account account) { - return new AccountDTO(account.getId(), account.getName(), account.getType()); - } + private AccountDTO convert(Account account) { + return new AccountDTO(account.getId(), account.getName(), account.getType()); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/BudgetClassificationTool.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/BudgetClassificationTool.java index be5ee711..c113816f 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/BudgetClassificationTool.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/BudgetClassificationTool.java @@ -3,42 +3,46 @@ import com.jongsoft.finance.domain.user.Budget; import com.jongsoft.finance.llm.AiEnabled; import com.jongsoft.finance.providers.BudgetProvider; + import dev.langchain4j.agent.tool.Tool; + import jakarta.inject.Singleton; + +import org.slf4j.Logger; + import java.time.LocalDate; import java.util.List; import java.util.stream.Stream; -import org.slf4j.Logger; @Singleton @AiEnabled public class BudgetClassificationTool implements AiTool { - private final Logger logger = org.slf4j.LoggerFactory.getLogger(BudgetClassificationTool.class); + private final Logger logger = org.slf4j.LoggerFactory.getLogger(BudgetClassificationTool.class); - private final BudgetProvider budgetProvider; + private final BudgetProvider budgetProvider; - BudgetClassificationTool(BudgetProvider budgetProvider) { - this.budgetProvider = budgetProvider; - } + BudgetClassificationTool(BudgetProvider budgetProvider) { + this.budgetProvider = budgetProvider; + } - @Tool( - """ + @Tool( + """ This tool returns the full list of known categories that can be used when classifying financial transactions. Use this tool to retrieve or confirm the set of valid categories. Do not use any category that is not included in the output of this tool. To view subcategories or tags, use the appropriate tools designed for those purposes.""") - public List listKnownCategories() { - logger.trace("Ai tool fetching available budgets."); - int year = LocalDate.now().getYear(); - int month = LocalDate.now().getMonthValue(); - - return budgetProvider - .lookup(year, month) - .map(b -> b.getExpenses().stream().map(Budget.Expense::getName)) - .getOrSupply(Stream::of) - .toList(); - } + public List listKnownCategories() { + logger.trace("Ai tool fetching available budgets."); + int year = LocalDate.now().getYear(); + int month = LocalDate.now().getMonthValue(); + + return budgetProvider + .lookup(year, month) + .map(b -> b.getExpenses().stream().map(Budget.Expense::getName)) + .getOrSupply(Stream::of) + .toList(); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/CategoryClassificationTool.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/CategoryClassificationTool.java index e19ec080..9992435c 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/CategoryClassificationTool.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/CategoryClassificationTool.java @@ -3,32 +3,37 @@ import com.jongsoft.finance.domain.user.Category; import com.jongsoft.finance.llm.AiEnabled; import com.jongsoft.finance.providers.CategoryProvider; + import dev.langchain4j.agent.tool.Tool; + import jakarta.inject.Singleton; -import java.util.List; + import org.slf4j.Logger; +import java.util.List; + @Singleton @AiEnabled public class CategoryClassificationTool implements AiTool { - private final Logger logger = org.slf4j.LoggerFactory.getLogger(CategoryClassificationTool.class); - private final CategoryProvider categoryProvider; + private final Logger logger = + org.slf4j.LoggerFactory.getLogger(CategoryClassificationTool.class); + private final CategoryProvider categoryProvider; - CategoryClassificationTool(CategoryProvider categoryProvider) { - this.categoryProvider = categoryProvider; - } + CategoryClassificationTool(CategoryProvider categoryProvider) { + this.categoryProvider = categoryProvider; + } - @Tool( - """ + @Tool( + """ This tool returns the full list of known subcategories that can be used when classifying financial transactions. Use this tool to retrieve or confirm the set of valid subcategories. Do not use any subcategory that is not included in the output of this tool. This list contains all subcategories and is independent of any specific category.""") - public List listKnownSubCategories() { - logger.trace("Ai tool fetching available categories."); - return categoryProvider.lookup().map(Category::getLabel).toJava(); - } + public List listKnownSubCategories() { + logger.trace("Ai tool fetching available categories."); + return categoryProvider.lookup().map(Category::getLabel).toJava(); + } } diff --git a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/TagClassificationTool.java b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/TagClassificationTool.java index 61214a40..fdfd8588 100644 --- a/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/TagClassificationTool.java +++ b/learning/learning-module-llm/src/main/java/com/jongsoft/finance/llm/tools/TagClassificationTool.java @@ -3,33 +3,37 @@ import com.jongsoft.finance.domain.transaction.Tag; import com.jongsoft.finance.llm.AiEnabled; import com.jongsoft.finance.providers.TagProvider; + import dev.langchain4j.agent.tool.Tool; + import jakarta.inject.Singleton; -import java.util.List; + import org.slf4j.Logger; +import java.util.List; + @Singleton @AiEnabled public class TagClassificationTool implements AiTool { - private final Logger logger = org.slf4j.LoggerFactory.getLogger(TagClassificationTool.class); + private final Logger logger = org.slf4j.LoggerFactory.getLogger(TagClassificationTool.class); - private final TagProvider tagProvider; + private final TagProvider tagProvider; - public TagClassificationTool(TagProvider tagProvider) { - this.tagProvider = tagProvider; - } + public TagClassificationTool(TagProvider tagProvider) { + this.tagProvider = tagProvider; + } - @Tool( - """ + @Tool( + """ This tool returns the full list of known tags that can be used when classifying financial transactions. Use this tool to retrieve or confirm the set of valid tags. Do not use any tag that is not included in the output of this tool. Tags provide additional context for classification but are independent of categories and subcategories.""") - public List listKnownTags() { - logger.trace("Ai tool fetching available tags."); - return tagProvider.lookup().map(Tag::name).toJava(); - } + public List listKnownTags() { + logger.trace("Ai tool fetching available tags."); + return tagProvider.lookup().map(Tag::name).toJava(); + } } diff --git a/learning/learning-module-llm/src/main/resources/application-ai.yaml b/learning/learning-module-llm/src/main/resources/application-ai.yaml index a382bd64..f0514efd 100644 --- a/learning/learning-module-llm/src/main/resources/application-ai.yaml +++ b/learning/learning-module-llm/src/main/resources/application-ai.yaml @@ -4,7 +4,7 @@ application: temperature: 0.6 ollama: - model: ${AI_MODEL:`qwen2.5-coder:1.5b`} + model: ${AI_MODEL:`qwen3:4b`} uri: http://localhost:11434 force-augmentation: ${AI_AUGMENTER_ENABLED:true} openai: diff --git a/learning/learning-module-llm/src/test/resources/application-test.yaml b/learning/learning-module-llm/src/test/resources/application-test.yaml index 2bc5f744..f4616766 100644 --- a/learning/learning-module-llm/src/test/resources/application-test.yaml +++ b/learning/learning-module-llm/src/test/resources/application-test.yaml @@ -3,7 +3,7 @@ micronaut.application.storage.location: ./build/ application: ai: ollama: - model: llama3.2 + model: qwen3:1.7b force-augmentation: false vectors: storage: ${micronaut.application.storage.location}/vectors diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/automation/ScheduleTerminateContractHandler.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/automation/ScheduleTerminateContractHandler.java index 42cbfa37..bb65d5ca 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/automation/ScheduleTerminateContractHandler.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/automation/ScheduleTerminateContractHandler.java @@ -8,8 +8,10 @@ import com.jongsoft.finance.messaging.commands.contract.TerminateContractCommand; import com.jongsoft.finance.providers.TransactionScheduleProvider; import com.jongsoft.lang.Collections; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,19 +25,22 @@ @RequiredArgsConstructor(onConstructor_ = @Inject) public class ScheduleTerminateContractHandler implements CommandHandler { - private final TransactionScheduleProvider transactionScheduleProvider; - private final FilterFactory filterFactory; + private final TransactionScheduleProvider transactionScheduleProvider; + private final FilterFactory filterFactory; - @Override - @BusinessEventListener - public void handle(TerminateContractCommand command) { - log.info("[{}] - Terminating any transaction schedule for contract.", command.id()); + @Override + @BusinessEventListener + public void handle(TerminateContractCommand command) { + log.info("[{}] - Terminating any transaction schedule for contract.", command.id()); - var filter = filterFactory - .schedule() - .contract(Collections.List(new EntityRef(command.id()))) - .activeOnly(); + var filter = filterFactory + .schedule() + .contract(Collections.List(new EntityRef(command.id()))) + .activeOnly(); - transactionScheduleProvider.lookup(filter).content().forEach(ScheduledTransaction::terminate); - } + transactionScheduleProvider + .lookup(filter) + .content() + .forEach(ScheduledTransaction::terminate); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleDataSet.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleDataSet.java index 724d40ba..24550bdb 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleDataSet.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleDataSet.java @@ -1,13 +1,14 @@ package com.jongsoft.finance.rule; import com.jongsoft.finance.core.RuleColumn; + import java.util.HashMap; import java.util.Map; public class RuleDataSet extends HashMap implements Map { - @SuppressWarnings("unchecked") - public T getCasted(RuleColumn key) { - return (T) super.get(key); - } + @SuppressWarnings("unchecked") + public T getCasted(RuleColumn key) { + return (T) super.get(key); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleEngine.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleEngine.java index 016bb536..6b72bcaf 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleEngine.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleEngine.java @@ -8,20 +8,20 @@ */ public interface RuleEngine { - /** - * Runs all rules against the input data set and returns the output data set. - * - * @param input The input data set. - * @return The output data set. - */ - RuleDataSet run(RuleDataSet input); + /** + * Runs all rules against the input data set and returns the output data set. + * + * @param input The input data set. + * @return The output data set. + */ + RuleDataSet run(RuleDataSet input); - /** - * Runs the specified rule against the input data set and returns the output data set. - * - * @param input The input data set. - * @param rule The rule to run. - * @return The output data set. - */ - RuleDataSet run(RuleDataSet input, TransactionRule rule); + /** + * Runs the specified rule against the input data set and returns the output data set. + * + * @param input The input data set. + * @param rule The rule to run. + * @return The output data set. + */ + RuleDataSet run(RuleDataSet input, TransactionRule rule); } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleSuggestionEngine.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleSuggestionEngine.java index ad5350fc..06182690 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleSuggestionEngine.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/RuleSuggestionEngine.java @@ -4,36 +4,38 @@ import com.jongsoft.finance.learning.SuggestionEngine; import com.jongsoft.finance.learning.SuggestionInput; import com.jongsoft.finance.learning.SuggestionResult; + import jakarta.inject.Singleton; + import java.util.List; @Singleton class RuleSuggestionEngine implements SuggestionEngine { - private final RuleEngine ruleEngine; - - RuleSuggestionEngine(RuleEngine ruleEngine) { - this.ruleEngine = ruleEngine; - } + private final RuleEngine ruleEngine; - @Override - public SuggestionResult makeSuggestions(SuggestionInput transactionInput) { - var ruleDataset = new RuleDataSet(); - if (transactionInput.description() != null) { - ruleDataset.put(RuleColumn.DESCRIPTION, transactionInput.description()); + RuleSuggestionEngine(RuleEngine ruleEngine) { + this.ruleEngine = ruleEngine; } - if (transactionInput.fromAccount() != null) { - ruleDataset.put(RuleColumn.SOURCE_ACCOUNT, transactionInput.fromAccount()); - } - if (transactionInput.toAccount() != null) { - ruleDataset.put(RuleColumn.TO_ACCOUNT, transactionInput.toAccount()); + + @Override + public SuggestionResult makeSuggestions(SuggestionInput transactionInput) { + var ruleDataset = new RuleDataSet(); + if (transactionInput.description() != null) { + ruleDataset.put(RuleColumn.DESCRIPTION, transactionInput.description()); + } + if (transactionInput.fromAccount() != null) { + ruleDataset.put(RuleColumn.SOURCE_ACCOUNT, transactionInput.fromAccount()); + } + if (transactionInput.toAccount() != null) { + ruleDataset.put(RuleColumn.TO_ACCOUNT, transactionInput.toAccount()); + } + ruleDataset.put(RuleColumn.AMOUNT, transactionInput.amount()); + + var ruleOutput = ruleEngine.run(ruleDataset); + return new SuggestionResult( + ruleOutput.getCasted(RuleColumn.BUDGET), + ruleOutput.getCasted(RuleColumn.CATEGORY), + List.of()); } - ruleDataset.put(RuleColumn.AMOUNT, transactionInput.amount()); - - var ruleOutput = ruleEngine.run(ruleDataset); - return new SuggestionResult( - ruleOutput.getCasted(RuleColumn.BUDGET), - ruleOutput.getCasted(RuleColumn.CATEGORY), - List.of()); - } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/impl/RuleEngineImpl.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/impl/RuleEngineImpl.java index 4fd62e08..99e3d59b 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/impl/RuleEngineImpl.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/impl/RuleEngineImpl.java @@ -9,72 +9,77 @@ import com.jongsoft.finance.rule.matcher.ConditionMatcher; import com.jongsoft.finance.rule.matcher.NumberMatcher; import com.jongsoft.finance.rule.matcher.StringMatcher; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import java.util.List; @Singleton public class RuleEngineImpl implements RuleEngine { - private final TransactionRuleProvider transactionRuleProvider; - private final List locators; + private final TransactionRuleProvider transactionRuleProvider; + private final List locators; + + @Inject + public RuleEngineImpl( + TransactionRuleProvider transactionRuleProvider, List locators) { + this.transactionRuleProvider = transactionRuleProvider; + this.locators = locators; + } - @Inject - public RuleEngineImpl( - TransactionRuleProvider transactionRuleProvider, List locators) { - this.transactionRuleProvider = transactionRuleProvider; - this.locators = locators; - } + @Override + public RuleDataSet run(RuleDataSet input) { + var outputSet = new RuleDataSet(); - @Override - public RuleDataSet run(RuleDataSet input) { - var outputSet = new RuleDataSet(); + for (TransactionRule rule : transactionRuleProvider.lookup()) { + var workingSet = new RuleDataSet(); + workingSet.putAll(input); + workingSet.putAll(outputSet); - for (TransactionRule rule : transactionRuleProvider.lookup()) { - var workingSet = new RuleDataSet(); - workingSet.putAll(input); - workingSet.putAll(outputSet); + outputSet.putAll(run(workingSet, rule)); + } - outputSet.putAll(run(workingSet, rule)); + return outputSet; } - return outputSet; - } + public RuleDataSet run(RuleDataSet input, TransactionRule rule) { + var matchers = rule.getConditions().map(condition -> locateMatcher(condition.getField()) + .prepare( + condition.getOperation(), + condition.getCondition(), + input.get(condition.getField()))); + + boolean matches; + if (rule.isRestrictive()) { + matches = matchers.all(ConditionMatcher::matches); + } else { + matches = matchers.exists(ConditionMatcher::matches); + } - public RuleDataSet run(RuleDataSet input, TransactionRule rule) { - var matchers = rule.getConditions().map(condition -> locateMatcher(condition.getField()) - .prepare( - condition.getOperation(), condition.getCondition(), input.get(condition.getField()))); + RuleDataSet output = new RuleDataSet(); + if (matches) { + for (TransactionRule.Change change : rule.getChanges()) { + var located = findLocator(change.getField()) + .locate(change.getField(), change.getChange()); + output.put(change.getField(), located); + } + } - boolean matches; - if (rule.isRestrictive()) { - matches = matchers.all(ConditionMatcher::matches); - } else { - matches = matchers.exists(ConditionMatcher::matches); + return output; } - RuleDataSet output = new RuleDataSet(); - if (matches) { - for (TransactionRule.Change change : rule.getChanges()) { - var located = findLocator(change.getField()).locate(change.getField(), change.getChange()); - output.put(change.getField(), located); - } + ConditionMatcher locateMatcher(RuleColumn column) { + return switch (column) { + case AMOUNT -> new NumberMatcher(); + default -> new StringMatcher(); + }; } - return output; - } - - ConditionMatcher locateMatcher(RuleColumn column) { - return switch (column) { - case AMOUNT -> new NumberMatcher(); - default -> new StringMatcher(); - }; - } - - ChangeLocator findLocator(RuleColumn column) { - return locators.stream() - .filter(locator -> locator.supports(column)) - .findFirst() - .orElseThrow(); - } + ChangeLocator findLocator(RuleColumn column) { + return locators.stream() + .filter(locator -> locator.supports(column)) + .findFirst() + .orElseThrow(); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/AccountLocator.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/AccountLocator.java index 923868b9..219cae02 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/AccountLocator.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/AccountLocator.java @@ -2,31 +2,33 @@ import com.jongsoft.finance.core.RuleColumn; import com.jongsoft.finance.providers.AccountProvider; + import jakarta.inject.Singleton; + import java.util.List; @Singleton public class AccountLocator implements ChangeLocator { - private static final List SUPPORTED = List.of( - RuleColumn.SOURCE_ACCOUNT, - RuleColumn.TO_ACCOUNT, - RuleColumn.CHANGE_TRANSFER_FROM, - RuleColumn.CHANGE_TRANSFER_TO); + private static final List SUPPORTED = List.of( + RuleColumn.SOURCE_ACCOUNT, + RuleColumn.TO_ACCOUNT, + RuleColumn.CHANGE_TRANSFER_FROM, + RuleColumn.CHANGE_TRANSFER_TO); - private final AccountProvider accountProvider; + private final AccountProvider accountProvider; - public AccountLocator(AccountProvider accountProvider) { - this.accountProvider = accountProvider; - } + public AccountLocator(AccountProvider accountProvider) { + this.accountProvider = accountProvider; + } - @Override - public Object locate(RuleColumn column, String change) { - return accountProvider.lookup(Long.parseLong(change)).get(); - } + @Override + public Object locate(RuleColumn column, String change) { + return accountProvider.lookup(Long.parseLong(change)).get(); + } - @Override - public boolean supports(RuleColumn column) { - return SUPPORTED.contains(column); - } + @Override + public boolean supports(RuleColumn column) { + return SUPPORTED.contains(column); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/ChangeLocator.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/ChangeLocator.java index c847458e..bebe753f 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/ChangeLocator.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/ChangeLocator.java @@ -5,20 +5,20 @@ /** Locates the entity that will be used to update the rule data set. */ public interface ChangeLocator { - /** - * Locates the entity that will be used to update the rule data set. - * - * @param column The column that is being updated. - * @param change The change that is being applied. - * @return The object that will be used to update the rule data set. - */ - Object locate(RuleColumn column, String change); + /** + * Locates the entity that will be used to update the rule data set. + * + * @param column The column that is being updated. + * @param change The change that is being applied. + * @return The object that will be used to update the rule data set. + */ + Object locate(RuleColumn column, String change); - /** - * Determines if this locator supports the given column. - * - * @param column The column to check. - * @return True if this locator supports the given column. - */ - boolean supports(RuleColumn column); + /** + * Determines if this locator supports the given column. + * + * @param column The column to check. + * @return True if this locator supports the given column. + */ + boolean supports(RuleColumn column); } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/NoopLocator.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/NoopLocator.java index 4ea9c006..cc696878 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/NoopLocator.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/NoopLocator.java @@ -1,21 +1,23 @@ package com.jongsoft.finance.rule.locator; import com.jongsoft.finance.core.RuleColumn; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + import lombok.RequiredArgsConstructor; @Singleton @RequiredArgsConstructor(onConstructor_ = @Inject) public class NoopLocator implements ChangeLocator { - @Override - public Object locate(RuleColumn column, String change) { - return change; - } + @Override + public Object locate(RuleColumn column, String change) { + return change; + } - @Override - public boolean supports(RuleColumn column) { - return RuleColumn.TAGS.equals(column); - } + @Override + public boolean supports(RuleColumn column) { + return RuleColumn.TAGS.equals(column); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/RelationLocator.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/RelationLocator.java index 781de5f5..18498fef 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/RelationLocator.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/locator/RelationLocator.java @@ -5,46 +5,49 @@ import com.jongsoft.finance.domain.core.EntityRef; import com.jongsoft.finance.domain.user.Category; import com.jongsoft.finance.providers.DataProvider; + import io.micronaut.context.ApplicationContext; + import jakarta.inject.Singleton; + import java.util.List; @Singleton public class RelationLocator implements ChangeLocator { - private static final List SUPPORTED_COLUMNS = - List.of(RuleColumn.CATEGORY, RuleColumn.BUDGET, RuleColumn.CONTRACT); - - private final ApplicationContext applicationContext; + private static final List SUPPORTED_COLUMNS = + List.of(RuleColumn.CATEGORY, RuleColumn.BUDGET, RuleColumn.CONTRACT); - public RelationLocator(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } + private final ApplicationContext applicationContext; - @Override - public Object locate(RuleColumn column, String change) { - Class genericType = - switch (column) { - case CATEGORY -> Category.class; - case BUDGET -> EntityRef.NamedEntity.class; - case CONTRACT -> Contract.class; - default -> throw new IllegalArgumentException("Unsupported type"); - }; - - var dataProvider = applicationContext.getBeansOfType(DataProvider.class).stream() - .filter(bean -> bean.supports(genericType)) - .findFirst(); - - if (dataProvider.isPresent()) { - var entity = dataProvider.get().lookup(Long.parseLong(change)); - return entity.get().toString(); + public RelationLocator(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; } - throw new IllegalArgumentException("Unsupported type " + genericType.getSimpleName()); - } + @Override + public Object locate(RuleColumn column, String change) { + Class genericType = + switch (column) { + case CATEGORY -> Category.class; + case BUDGET -> EntityRef.NamedEntity.class; + case CONTRACT -> Contract.class; + default -> throw new IllegalArgumentException("Unsupported type"); + }; + + var dataProvider = applicationContext.getBeansOfType(DataProvider.class).stream() + .filter(bean -> bean.supports(genericType)) + .findFirst(); + + if (dataProvider.isPresent()) { + var entity = dataProvider.get().lookup(Long.parseLong(change)); + return entity.get().toString(); + } + + throw new IllegalArgumentException("Unsupported type " + genericType.getSimpleName()); + } - @Override - public boolean supports(RuleColumn column) { - return SUPPORTED_COLUMNS.contains(column); - } + @Override + public boolean supports(RuleColumn column) { + return SUPPORTED_COLUMNS.contains(column); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/ConditionMatcher.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/ConditionMatcher.java index 9e3fb27d..d802eac5 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/ConditionMatcher.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/ConditionMatcher.java @@ -5,20 +5,20 @@ /** A matcher that can be used to compare a value against an expectation. */ public interface ConditionMatcher { - /** - * Prepare the matcher for execution. - * - * @param operation The operation to perform. - * @param expectation The expectation to compare against. - * @param actual The actual value to compare. - * @return The matcher. - */ - ConditionMatcher prepare(RuleOperation operation, String expectation, Object actual); + /** + * Prepare the matcher for execution. + * + * @param operation The operation to perform. + * @param expectation The expectation to compare against. + * @param actual The actual value to compare. + * @return The matcher. + */ + ConditionMatcher prepare(RuleOperation operation, String expectation, Object actual); - /** - * Execute the matcher. - * - * @return True if the matcher matches, false otherwise. - */ - boolean matches(); + /** + * Execute the matcher. + * + * @return True if the matcher matches, false otherwise. + */ + boolean matches(); } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/NumberMatcher.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/NumberMatcher.java index ed3ee3ea..914976d9 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/NumberMatcher.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/NumberMatcher.java @@ -1,33 +1,34 @@ package com.jongsoft.finance.rule.matcher; import com.jongsoft.finance.core.RuleOperation; + import java.util.function.Supplier; public class NumberMatcher implements ConditionMatcher { - private Supplier innerMatcher; + private Supplier innerMatcher; - @Override - public ConditionMatcher prepare(RuleOperation operation, String expectation, Object actual) { - var checkAmount = Double.parseDouble(expectation); - var castedActual = (Double) actual; - if (castedActual == null) { - innerMatcher = () -> false; - return this; - } + @Override + public ConditionMatcher prepare(RuleOperation operation, String expectation, Object actual) { + var checkAmount = Double.parseDouble(expectation); + var castedActual = (Double) actual; + if (castedActual == null) { + innerMatcher = () -> false; + return this; + } - innerMatcher = switch (operation) { - case LESS_THAN -> () -> castedActual < checkAmount; - case MORE_THAN -> () -> castedActual > checkAmount; - case EQUALS -> () -> castedActual == checkAmount; - default -> () -> false; - }; + innerMatcher = switch (operation) { + case LESS_THAN -> () -> castedActual < checkAmount; + case MORE_THAN -> () -> castedActual > checkAmount; + case EQUALS -> () -> castedActual == checkAmount; + default -> () -> false; + }; - return this; - } + return this; + } - @Override - public boolean matches() { - return innerMatcher.get(); - } + @Override + public boolean matches() { + return innerMatcher.get(); + } } diff --git a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/StringMatcher.java b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/StringMatcher.java index 697e66f9..4b3771d0 100644 --- a/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/StringMatcher.java +++ b/learning/learning-module-rules/src/main/java/com/jongsoft/finance/rule/matcher/StringMatcher.java @@ -1,34 +1,35 @@ package com.jongsoft.finance.rule.matcher; import com.jongsoft.finance.core.RuleOperation; + import java.util.function.Supplier; public class StringMatcher implements ConditionMatcher { - private Supplier innerMatcher; + private Supplier innerMatcher; - @Override - public ConditionMatcher prepare(RuleOperation operation, String expectation, Object actual) { - boolean nonNull = expectation != null && actual != null; + @Override + public ConditionMatcher prepare(RuleOperation operation, String expectation, Object actual) { + boolean nonNull = expectation != null && actual != null; - innerMatcher = () -> false; - if (nonNull) { - var castedActual = actual.toString().toLowerCase(); - var loweredExpectation = expectation.toLowerCase(); + innerMatcher = () -> false; + if (nonNull) { + var castedActual = actual.toString().toLowerCase(); + var loweredExpectation = expectation.toLowerCase(); - innerMatcher = switch (operation) { - case EQUALS -> () -> loweredExpectation.equals(castedActual); - case CONTAINS -> () -> castedActual.contains(loweredExpectation); - case STARTS_WITH -> () -> castedActual.startsWith(loweredExpectation); - default -> () -> false; - }; - } + innerMatcher = switch (operation) { + case EQUALS -> () -> loweredExpectation.equals(castedActual); + case CONTAINS -> () -> castedActual.contains(loweredExpectation); + case STARTS_WITH -> () -> castedActual.startsWith(loweredExpectation); + default -> () -> false; + }; + } - return this; - } + return this; + } - @Override - public boolean matches() { - return innerMatcher.get(); - } + @Override + public boolean matches() { + return innerMatcher.get(); + } } diff --git a/learning/learning-module-spending-patterns/build.gradle.kts b/learning/learning-module-spending-patterns/build.gradle.kts index af40f8b8..577cb803 100644 --- a/learning/learning-module-spending-patterns/build.gradle.kts +++ b/learning/learning-module-spending-patterns/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(mn.micronaut.data.tx.hibernate) implementation(mn.micronaut.data.jpa) implementation(mn.micronaut.data.jdbc) + implementation(mn.micronaut.micrometer.core) implementation(mn.micronaut.runtime) implementation(mn.validation) diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/Detector.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/Detector.java index ab31615b..c3ad1146 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/Detector.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/Detector.java @@ -2,16 +2,17 @@ import com.jongsoft.finance.domain.insight.Insight; import com.jongsoft.finance.domain.transaction.Transaction; + import java.time.YearMonth; import java.util.List; public interface Detector { - void updateBaseline(YearMonth forMonth); + void updateBaseline(YearMonth forMonth); - void analysisCompleted(); + void analysisCompleted(); - List detect(Transaction transaction); + List detect(Transaction transaction); - boolean readyForAnalysis(); + boolean readyForAnalysis(); } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/PatternVectorStore.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/PatternVectorStore.java index 645c1c70..f2360662 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/PatternVectorStore.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/PatternVectorStore.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.spending; import jakarta.inject.Qualifier; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/config/SpendingStarter.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/config/SpendingStarter.java index 0f3b3af6..b12dd5d4 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/config/SpendingStarter.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/config/SpendingStarter.java @@ -4,6 +4,7 @@ import com.jongsoft.finance.learning.stores.PledgerEmbeddingStore; import com.jongsoft.finance.spending.PatternVectorStore; import com.jongsoft.finance.spending.SpendingAnalyticsEnabled; + import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; @@ -11,15 +12,15 @@ @SpendingAnalyticsEnabled class SpendingStarter { - private final EmbeddingStoreFactory embeddingStoreFactory; + private final EmbeddingStoreFactory embeddingStoreFactory; - SpendingStarter(EmbeddingStoreFactory embeddingStoreFactory) { - this.embeddingStoreFactory = embeddingStoreFactory; - } + SpendingStarter(EmbeddingStoreFactory embeddingStoreFactory) { + this.embeddingStoreFactory = embeddingStoreFactory; + } - @Bean - @PatternVectorStore - PledgerEmbeddingStore patternVectorStore() { - return embeddingStoreFactory.createEmbeddingStore("pattern_detector"); - } + @Bean + @PatternVectorStore + PledgerEmbeddingStore patternVectorStore() { + return embeddingStoreFactory.createEmbeddingStore("pattern_detector"); + } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/AnomalyDetector.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/AnomalyDetector.java index 1cbf1070..0164f280 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/AnomalyDetector.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/AnomalyDetector.java @@ -10,112 +10,116 @@ import com.jongsoft.finance.spending.SpendingAnalyticsEnabled; import com.jongsoft.finance.spending.detector.anomaly.*; import com.jongsoft.lang.Dates; + import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics; + import java.time.LocalDate; import java.time.YearMonth; import java.util.*; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics; @Slf4j @Singleton @SpendingAnalyticsEnabled class AnomalyDetector implements Detector { - private final TransactionProvider transactionProvider; - private final FilterFactory filterFactory; - private final List anomalies; - - private final ThreadLocal userCategoryStatistics = new ThreadLocal<>(); - - AnomalyDetector( - TransactionProvider transactionProvider, - FilterFactory filterFactory, - BudgetProvider budgetProvider) { - this.transactionProvider = transactionProvider; - this.filterFactory = filterFactory; - this.anomalies = List.of( - new UnusualAmount(), - new UnusualFrequency(transactionProvider, filterFactory, budgetProvider), - new SpendingSpike(filterFactory, transactionProvider, budgetProvider), - new UnusualMerchant()); - } - - @Override - public boolean readyForAnalysis() { - return true; - } - - @Override - public void updateBaseline(YearMonth forMonth) { - LocalDate startDate = forMonth.minusMonths(12).atDay(1); - userCategoryStatistics.set(new UserCategoryStatistics( - new UserCategoryStatistics.BudgetStatisticsMap(), - new UserCategoryStatistics.BudgetStatisticsMap(), - new HashMap<>())); - - log.debug("Updating baseline for anomaly detection"); - var transactionsPerBudget = transactionProvider - .lookup(filterFactory - .transaction() - .ownAccounts() - .range(Dates.range(startDate, LocalDate.now()))) - .content() - .stream() - .filter(t -> t.getBudget() != null) - .collect(Collectors.groupingBy(Transaction::getBudget)); - for (var budgetTransactions : transactionsPerBudget.entrySet()) { - var budget = budgetTransactions.getKey(); - var amountPerBudget = userCategoryStatistics - .get() - .amounts() - .computeIfAbsent(budget, ignored -> new DescriptiveStatistics()); - for (var t : budgetTransactions.getValue()) { - amountPerBudget.addValue(t.computeAmount(t.computeTo())); - } - - var frequency = userCategoryStatistics - .get() - .frequencies() - .computeIfAbsent(budget, ignored -> new DescriptiveStatistics()); - Map transactionsByMonth = budgetTransactions.getValue().stream() - .collect(Collectors.groupingBy( - t -> t.getDate().getYear() + "-" + t.getDate().getMonthValue(), - Collectors.counting())); - for (Long count : transactionsByMonth.values()) { - frequency.addValue(count); - } - - Set merchants = budgetTransactions.getValue().stream() - .map(Transaction::computeTo) - .filter(Objects::nonNull) - .map(Account::getName) - .collect(Collectors.toSet()); - userCategoryStatistics.get().typicalMerchants().put(budget, merchants); + private final TransactionProvider transactionProvider; + private final FilterFactory filterFactory; + private final List anomalies; + + private final ThreadLocal userCategoryStatistics = new ThreadLocal<>(); + + AnomalyDetector( + TransactionProvider transactionProvider, + FilterFactory filterFactory, + BudgetProvider budgetProvider) { + this.transactionProvider = transactionProvider; + this.filterFactory = filterFactory; + this.anomalies = List.of( + new UnusualAmount(), + new UnusualFrequency(transactionProvider, filterFactory, budgetProvider), + new SpendingSpike(filterFactory, transactionProvider, budgetProvider), + new UnusualMerchant()); } - log.debug("Baseline update completed"); - } - - @Override - public void analysisCompleted() { - userCategoryStatistics.remove(); - log.debug("Analysis completed. Removed user category statistics."); - } - - @Override - public List detect(Transaction transaction) { - var userStatistics = userCategoryStatistics.get(); - if (userStatistics == null - || transaction.getBudget() == null - || !userStatistics.amounts().containsKey(transaction.getBudget())) { - return List.of(); + + @Override + public boolean readyForAnalysis() { + return true; + } + + @Override + public void updateBaseline(YearMonth forMonth) { + LocalDate startDate = forMonth.minusMonths(12).atDay(1); + userCategoryStatistics.set(new UserCategoryStatistics( + new UserCategoryStatistics.BudgetStatisticsMap(), + new UserCategoryStatistics.BudgetStatisticsMap(), + new HashMap<>())); + + log.debug("Updating baseline for anomaly detection"); + var transactionsPerBudget = transactionProvider + .lookup(filterFactory + .transaction() + .ownAccounts() + .range(Dates.range(startDate, LocalDate.now()))) + .content() + .stream() + .filter(t -> t.getBudget() != null) + .collect(Collectors.groupingBy(Transaction::getBudget)); + for (var budgetTransactions : transactionsPerBudget.entrySet()) { + var budget = budgetTransactions.getKey(); + var amountPerBudget = userCategoryStatistics + .get() + .amounts() + .computeIfAbsent(budget, ignored -> new DescriptiveStatistics()); + for (var t : budgetTransactions.getValue()) { + amountPerBudget.addValue(t.computeAmount(t.computeTo())); + } + + var frequency = userCategoryStatistics + .get() + .frequencies() + .computeIfAbsent(budget, ignored -> new DescriptiveStatistics()); + Map transactionsByMonth = budgetTransactions.getValue().stream() + .collect(Collectors.groupingBy( + t -> t.getDate().getYear() + "-" + t.getDate().getMonthValue(), + Collectors.counting())); + for (Long count : transactionsByMonth.values()) { + frequency.addValue(count); + } + + Set merchants = budgetTransactions.getValue().stream() + .map(Transaction::computeTo) + .filter(Objects::nonNull) + .map(Account::getName) + .collect(Collectors.toSet()); + userCategoryStatistics.get().typicalMerchants().put(budget, merchants); + } + log.debug("Baseline update completed"); + } + + @Override + public void analysisCompleted() { + userCategoryStatistics.remove(); + log.debug("Analysis completed. Removed user category statistics."); } - return anomalies.stream() - .map(anomaly -> anomaly.detect(transaction, userStatistics)) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - } + @Override + public List detect(Transaction transaction) { + var userStatistics = userCategoryStatistics.get(); + if (userStatistics == null + || transaction.getBudget() == null + || !userStatistics.amounts().containsKey(transaction.getBudget())) { + return List.of(); + } + + return anomalies.stream() + .map(anomaly -> anomaly.detect(transaction, userStatistics)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/PatternDetector.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/PatternDetector.java index 3fc8f64e..5e24ce3e 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/PatternDetector.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/PatternDetector.java @@ -15,155 +15,167 @@ import com.jongsoft.finance.spending.detector.pattern.OccurrencePattern; import com.jongsoft.finance.spending.detector.pattern.Pattern; import com.jongsoft.finance.spending.detector.pattern.SeasonalPattern; + import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingSearchRequest; import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder; + import io.micronaut.context.event.ShutdownEvent; import io.micronaut.context.event.StartupEvent; import io.micronaut.core.annotation.Nullable; import io.micronaut.runtime.event.annotation.EventListener; + import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.time.YearMonth; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @SpendingAnalyticsEnabled class PatternDetector implements Detector { - // Threshold for similarity matching - private static final double SIMILARITY_THRESHOLD = 0.9; - private static final int MIN_TRANSACTIONS_FOR_PATTERN = 3; - - private final TransactionProvider transactionProvider; - private final CurrentUserProvider currentUserProvider; - - private final EmbeddingModel embeddingModel; - private final PledgerEmbeddingStore patternVectorStore; - - private final List patterns; - private final EmbeddingStoreFiller embeddingStoreFiller; - - PatternDetector( - @Nullable TransactionProvider transactionProvider, - CurrentUserProvider currentUserProvider, - @PatternVectorStore PledgerEmbeddingStore patternVectorStore, - EmbeddingStoreFiller embeddingStoreFiller) { - this.transactionProvider = transactionProvider; - this.currentUserProvider = currentUserProvider; - this.patternVectorStore = patternVectorStore; - this.embeddingStoreFiller = embeddingStoreFiller; - this.embeddingModel = new AllMiniLmL6V2EmbeddingModel(); - this.patterns = List.of(new OccurrencePattern(), new AmountPattern(), new SeasonalPattern()); - } - - @EventListener - void handleStartup(StartupEvent startupEvent) { - if (patternVectorStore.shouldInitialize()) { - log.debug("Initially filling pattern vector store with transactions."); - embeddingStoreFiller.consumeTransactions(this::indexTransaction); + // Threshold for similarity matching + private static final double SIMILARITY_THRESHOLD = 0.9; + private static final int MIN_TRANSACTIONS_FOR_PATTERN = 3; + + private final TransactionProvider transactionProvider; + private final CurrentUserProvider currentUserProvider; + + private final EmbeddingModel embeddingModel; + private final PledgerEmbeddingStore patternVectorStore; + + private final List patterns; + private final EmbeddingStoreFiller embeddingStoreFiller; + + PatternDetector( + @Nullable TransactionProvider transactionProvider, + CurrentUserProvider currentUserProvider, + @PatternVectorStore PledgerEmbeddingStore patternVectorStore, + EmbeddingStoreFiller embeddingStoreFiller) { + this.transactionProvider = transactionProvider; + this.currentUserProvider = currentUserProvider; + this.patternVectorStore = patternVectorStore; + this.embeddingStoreFiller = embeddingStoreFiller; + this.embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + this.patterns = + List.of(new OccurrencePattern(), new AmountPattern(), new SeasonalPattern()); } - } - - @EventListener - void handleShutdown(ShutdownEvent shutdownEvent) { - log.info("Shutting down pattern detector embedding store."); - patternVectorStore.close(); - } - - @EventListener - void handleClassificationChanged(LinkTransactionCommand command) { - indexTransaction(transactionProvider.lookup(command.id()).get()); - } - - @EventListener - void handleTransactionAdded(TransactionCreated transactionCreated) { - indexTransaction( - transactionProvider.lookup(transactionCreated.transactionId()).get()); - } - - @Override - public boolean readyForAnalysis() { - return embeddingStoreFiller.isDone(); - } - - @Override - public void updateBaseline(YearMonth forMonth) { - // no filling is needed - } - - @Override - public void analysisCompleted() { - // no action needed - } - - @Override - public List detect(Transaction transaction) { - // Skip transactions without a category or budget - if (transaction.getCategory() == null && transaction.getBudget() == null) { - return List.of(); + + @EventListener + void handleStartup(StartupEvent startupEvent) { + if (patternVectorStore.shouldInitialize()) { + log.debug("Initially filling pattern vector store with transactions."); + embeddingStoreFiller.consumeTransactions(this::indexTransaction); + } + } + + @EventListener + void handleShutdown(ShutdownEvent shutdownEvent) { + log.info("Shutting down pattern detector embedding store."); + patternVectorStore.close(); } - var segment = createTextSegment(transaction); - - // Search for similar transactions - var searchRequest = EmbeddingSearchRequest.builder() - .queryEmbedding(embeddingModel.embed(segment).content()) - .filter(MetadataFilterBuilder.metadataKey("user") - .isEqualTo(currentUserProvider.currentUser().getUsername().email()) - .and(MetadataFilterBuilder.metadataKey("date") - .isBetween( - transaction.getDate().minusMonths(3).toString(), - transaction.getDate().toString()))) - .maxResults(150) - .minScore(SIMILARITY_THRESHOLD) - .build(); - - var matches = patternVectorStore.embeddingStore().search(searchRequest).matches(); - - if (matches.size() >= MIN_TRANSACTIONS_FOR_PATTERN) { - return patterns.stream() - .map(p -> p.detect(transaction, matches)) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); + @EventListener + void handleClassificationChanged(LinkTransactionCommand command) { + indexTransaction(transactionProvider.lookup(command.id()).get()); } - return List.of(); - } - - private void indexTransaction(Transaction transaction) { - TextSegment segment = createTextSegment(transaction); - - // Remove any existing entries for this transaction - patternVectorStore - .embeddingStore() - .removeAll(MetadataFilterBuilder.metadataKey("id") - .isEqualTo(transaction.getId().toString()) - .and(MetadataFilterBuilder.metadataKey("user") - .isEqualTo(currentUserProvider.currentUser().getUsername().email()))); - - // Add the transaction to the vector store - patternVectorStore.embeddingStore().add(embeddingModel.embed(segment).content(), segment); - } - - private TextSegment createTextSegment(Transaction transaction) { - Map metadata = new HashMap<>(); - metadata.put("id", transaction.getId().toString()); - metadata.put("user", currentUserProvider.currentUser().getUsername().email()); - metadata.put("date", transaction.getDate().toString()); - metadata.put("amount", String.valueOf(transaction.computeAmount(transaction.computeFrom()))); - metadata.put("budget", transaction.getBudget() != null ? transaction.getBudget() : ""); - - // Create a rich text representation of the transaction - String text = String.format("%s - %s", transaction.getBudget(), transaction.getDescription()); - - return TextSegment.textSegment(text, Metadata.from(metadata)); - } + @EventListener + void handleTransactionAdded(TransactionCreated transactionCreated) { + indexTransaction( + transactionProvider.lookup(transactionCreated.transactionId()).get()); + } + + @Override + public boolean readyForAnalysis() { + return embeddingStoreFiller.isDone(); + } + + @Override + public void updateBaseline(YearMonth forMonth) { + // no filling is needed + } + + @Override + public void analysisCompleted() { + // no action needed + } + + @Override + public List detect(Transaction transaction) { + // Skip transactions without a category or budget + if (transaction.getCategory() == null && transaction.getBudget() == null) { + return List.of(); + } + + var segment = createTextSegment(transaction); + + // Search for similar transactions + var searchRequest = EmbeddingSearchRequest.builder() + .queryEmbedding(embeddingModel.embed(segment).content()) + .filter(MetadataFilterBuilder.metadataKey("user") + .isEqualTo( + currentUserProvider.currentUser().getUsername().email()) + .and(MetadataFilterBuilder.metadataKey("date") + .isBetween( + transaction.getDate().minusMonths(3).toString(), + transaction.getDate().toString()))) + .maxResults(150) + .minScore(SIMILARITY_THRESHOLD) + .build(); + + var matches = patternVectorStore.embeddingStore().search(searchRequest).matches(); + + if (matches.size() >= MIN_TRANSACTIONS_FOR_PATTERN) { + return patterns.stream() + .map(p -> p.detect(transaction, matches)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + } + + return List.of(); + } + + private void indexTransaction(Transaction transaction) { + TextSegment segment = createTextSegment(transaction); + + // Remove any existing entries for this transaction + patternVectorStore + .embeddingStore() + .removeAll(MetadataFilterBuilder.metadataKey("id") + .isEqualTo(transaction.getId().toString()) + .and(MetadataFilterBuilder.metadataKey("user") + .isEqualTo(currentUserProvider + .currentUser() + .getUsername() + .email()))); + + // Add the transaction to the vector store + patternVectorStore.embeddingStore().add(embeddingModel.embed(segment).content(), segment); + } + + private TextSegment createTextSegment(Transaction transaction) { + Map metadata = new HashMap<>(); + metadata.put("id", transaction.getId().toString()); + metadata.put("user", currentUserProvider.currentUser().getUsername().email()); + metadata.put("date", transaction.getDate().toString()); + metadata.put( + "amount", String.valueOf(transaction.computeAmount(transaction.computeFrom()))); + metadata.put("budget", transaction.getBudget() != null ? transaction.getBudget() : ""); + + // Create a rich text representation of the transaction + String text = + String.format("%s - %s", transaction.getBudget(), transaction.getDescription()); + + return TextSegment.textSegment(text, Metadata.from(metadata)); + } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/Anomaly.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/Anomaly.java index 8424a893..330acbcd 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/Anomaly.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/Anomaly.java @@ -3,19 +3,20 @@ import com.jongsoft.finance.domain.insight.Severity; import com.jongsoft.finance.domain.insight.SpendingInsight; import com.jongsoft.finance.domain.transaction.Transaction; + import java.util.Optional; public interface Anomaly { - Optional detect(Transaction transaction, UserCategoryStatistics statistics); + Optional detect(Transaction transaction, UserCategoryStatistics statistics); - default Severity getSeverityFromScore(double score) { - if (score >= 0.8) { - return Severity.ALERT; - } else if (score >= 0.5) { - return Severity.WARNING; - } else { - return Severity.INFO; + default Severity getSeverityFromScore(double score) { + if (score >= 0.8) { + return Severity.ALERT; + } else if (score >= 0.5) { + return Severity.WARNING; + } else { + return Severity.INFO; + } } - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/SpendingSpike.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/SpendingSpike.java index 3028089c..16721a94 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/SpendingSpike.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/SpendingSpike.java @@ -9,6 +9,7 @@ import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.lang.Collections; import com.jongsoft.lang.Dates; + import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.Map; @@ -16,77 +17,79 @@ public class SpendingSpike implements Anomaly { - private final FilterFactory filterFactory; - private final TransactionProvider transactionProvider; - private final BudgetProvider budgetProvider; + private final FilterFactory filterFactory; + private final TransactionProvider transactionProvider; + private final BudgetProvider budgetProvider; + + public SpendingSpike( + FilterFactory filterFactory, + TransactionProvider transactionProvider, + BudgetProvider budgetProvider) { + this.filterFactory = filterFactory; + this.transactionProvider = transactionProvider; + this.budgetProvider = budgetProvider; + } - public SpendingSpike( - FilterFactory filterFactory, - TransactionProvider transactionProvider, - BudgetProvider budgetProvider) { - this.filterFactory = filterFactory; - this.transactionProvider = transactionProvider; - this.budgetProvider = budgetProvider; - } + @Override + public Optional detect( + Transaction transaction, UserCategoryStatistics statistics) { + var monthlyMap = computeSpendingPerMonth(transaction, 4); + var avgMonthlySpending = monthlyMap.values().stream() + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); - @Override - public Optional detect( - Transaction transaction, UserCategoryStatistics statistics) { - var monthlyMap = computeSpendingPerMonth(transaction, 4); - var avgMonthlySpending = - monthlyMap.values().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + String currentMonth = + transaction.getDate().getYear() + "-" + transaction.getDate().getMonthValue(); + double currentMonthTotal = monthlyMap.getOrDefault(currentMonth, 0.0); + if (currentMonthTotal > avgMonthlySpending * 1.5) { + double percentIncrease = (currentMonthTotal - avgMonthlySpending) / avgMonthlySpending; + double score = Math.min(1.0, percentIncrease); - String currentMonth = - transaction.getDate().getYear() + "-" + transaction.getDate().getMonthValue(); - double currentMonthTotal = monthlyMap.getOrDefault(currentMonth, 0.0); - if (currentMonthTotal > avgMonthlySpending * 1.5) { - double percentIncrease = (currentMonthTotal - avgMonthlySpending) / avgMonthlySpending; - double score = Math.min(1.0, percentIncrease); + return Optional.of(SpendingInsight.builder() + .type(InsightType.SPENDING_SPIKE) + .category(transaction.getBudget()) + .severity(getSeverityFromScore(score)) + .score(score) + .detectedDate(transaction.getDate().withDayOfMonth(1)) + .message("computed.insight.spending.spike") + .metadata(Map.of( + "current_month_total", currentMonthTotal, + "avg_monthly_spending", avgMonthlySpending, + "percent_increase", percentIncrease)) + .build()); + } - return Optional.of(SpendingInsight.builder() - .type(InsightType.SPENDING_SPIKE) - .category(transaction.getBudget()) - .severity(getSeverityFromScore(score)) - .score(score) - .detectedDate(transaction.getDate().withDayOfMonth(1)) - .message("computed.insight.spending.spike") - .metadata(Map.of( - "current_month_total", currentMonthTotal, - "avg_monthly_spending", avgMonthlySpending, - "percent_increase", percentIncrease)) - .build()); + return Optional.empty(); } - return Optional.empty(); - } + public Map computeSpendingPerMonth( + Transaction transaction, int lastNumberOfMonths) { + var expense = budgetProvider + .lookup(transaction.getDate().getYear(), transaction.getDate().getMonthValue()) + .stream() + .flatMap(b -> b.getExpenses().stream()) + .filter(e -> e.getName().equalsIgnoreCase(transaction.getBudget())) + .findFirst() + .orElseThrow(); + var filter = filterFactory + .transaction() + .ownAccounts() + .expenses(Collections.List(new EntityRef(expense.getId()))); - public Map computeSpendingPerMonth( - Transaction transaction, int lastNumberOfMonths) { - var expense = - budgetProvider - .lookup(transaction.getDate().getYear(), transaction.getDate().getMonthValue()) - .stream() - .flatMap(b -> b.getExpenses().stream()) - .filter(e -> e.getName().equalsIgnoreCase(transaction.getBudget())) - .findFirst() - .orElseThrow(); - var filter = filterFactory - .transaction() - .ownAccounts() - .expenses(Collections.List(new EntityRef(expense.getId()))); + var currentMonth = transaction.getDate().withDayOfMonth(1); + var monthlyMap = new HashMap(); + for (int i = 0; i < lastNumberOfMonths; i++) { + var startDate = transaction.getDate().minusMonths(i); + var dateRange = Dates.range(currentMonth, ChronoUnit.MONTHS); - var currentMonth = transaction.getDate().withDayOfMonth(1); - var monthlyMap = new HashMap(); - for (int i = 0; i < lastNumberOfMonths; i++) { - var startDate = transaction.getDate().minusMonths(i); - var dateRange = Dates.range(currentMonth, ChronoUnit.MONTHS); + var computedBalance = transactionProvider.balance(filter.range(dateRange)); + computedBalance.ifPresent(amount -> monthlyMap.put( + startDate.getYear() + "-" + startDate.getMonthValue(), + Math.abs(amount.doubleValue()))); + currentMonth = currentMonth.minusMonths(1); + } - var computedBalance = transactionProvider.balance(filter.range(dateRange)); - computedBalance.ifPresent(amount -> monthlyMap.put( - startDate.getYear() + "-" + startDate.getMonthValue(), Math.abs(amount.doubleValue()))); - currentMonth = currentMonth.minusMonths(1); + return monthlyMap; } - - return monthlyMap; - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualAmount.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualAmount.java index e2ea235d..8e80d152 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualAmount.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualAmount.java @@ -3,59 +3,60 @@ import com.jongsoft.finance.domain.insight.InsightType; import com.jongsoft.finance.domain.insight.SpendingInsight; import com.jongsoft.finance.domain.transaction.Transaction; + import java.util.Map; import java.util.Optional; public class UnusualAmount implements Anomaly { - private static final double AMOUNT_ANOMALY_THRESHOLD = 2.0; - private static final double SENSITIVITY = 0.15; - - @Override - public Optional detect( - Transaction transaction, UserCategoryStatistics statistics) { - var typicalAmount = statistics.amounts().get(transaction.getBudget()); - if (typicalAmount == null) { - return Optional.empty(); - } - - double mean = typicalAmount.getMean(); - double stdDev = typicalAmount.getStandardDeviation(); - - // Skip if we don't have enough data or standard deviation is too small - if (typicalAmount.getN() < 5 || stdDev < 0.01) { - return Optional.empty(); + private static final double AMOUNT_ANOMALY_THRESHOLD = 2.0; + private static final double SENSITIVITY = 0.15; + + @Override + public Optional detect( + Transaction transaction, UserCategoryStatistics statistics) { + var typicalAmount = statistics.amounts().get(transaction.getBudget()); + if (typicalAmount == null) { + return Optional.empty(); + } + + double mean = typicalAmount.getMean(); + double stdDev = typicalAmount.getStandardDeviation(); + + // Skip if we don't have enough data or standard deviation is too small + if (typicalAmount.getN() < 5 || stdDev < 0.01) { + return Optional.empty(); + } + + var transactionAmount = transaction.computeAmount(transaction.computeTo()); + var zScore = Math.abs(transactionAmount - mean) / stdDev; + var adjustedThreshold = AMOUNT_ANOMALY_THRESHOLD * (2.0 - SENSITIVITY); + + if (zScore > adjustedThreshold) { + var score = Math.min(1.0, zScore / (adjustedThreshold * 2)); + + return Optional.of(SpendingInsight.builder() + .type(InsightType.UNUSUAL_AMOUNT) + .category(transaction.getBudget()) + .severity(getSeverityFromScore(score)) + .score(score) + .detectedDate(transaction.getDate()) + .message(generateMessage(transactionAmount, mean)) + .transactionId(transaction.getId()) + .metadata(Map.of( + "amount", transactionAmount, + "z_score", zScore, + "mean", mean, + "std_dev", stdDev)) + .build()); + } + + return Optional.empty(); } - var transactionAmount = transaction.computeAmount(transaction.computeTo()); - var zScore = Math.abs(transactionAmount - mean) / stdDev; - var adjustedThreshold = AMOUNT_ANOMALY_THRESHOLD * (2.0 - SENSITIVITY); - - if (zScore > adjustedThreshold) { - var score = Math.min(1.0, zScore / (adjustedThreshold * 2)); - - return Optional.of(SpendingInsight.builder() - .type(InsightType.UNUSUAL_AMOUNT) - .category(transaction.getBudget()) - .severity(getSeverityFromScore(score)) - .score(score) - .detectedDate(transaction.getDate()) - .message(generateMessage(transactionAmount, mean)) - .transactionId(transaction.getId()) - .metadata(Map.of( - "amount", transactionAmount, - "z_score", zScore, - "mean", mean, - "std_dev", stdDev)) - .build()); - } - - return Optional.empty(); - } - - private String generateMessage(double transactionAmount, double mean) { - if (transactionAmount > mean) { - return "computed.insight.amount.high"; + private String generateMessage(double transactionAmount, double mean) { + if (transactionAmount > mean) { + return "computed.insight.amount.high"; + } + return "computed.insight.amount.low"; } - return "computed.insight.amount.low"; - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualFrequency.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualFrequency.java index ee4b06d2..e9b83d7e 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualFrequency.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualFrequency.java @@ -9,90 +9,92 @@ import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.lang.Collections; import com.jongsoft.lang.Dates; + +import lombok.extern.slf4j.Slf4j; + import java.time.temporal.ChronoUnit; import java.util.Map; import java.util.Optional; -import lombok.extern.slf4j.Slf4j; @Slf4j public class UnusualFrequency implements Anomaly { - private static final double FREQUENCY_ANOMALY_THRESHOLD = 1.5; - private static final double ADJUSTED_THRESHOLD = FREQUENCY_ANOMALY_THRESHOLD * (2.0 - .7); - - private final BudgetProvider budgetProvider; - private final TransactionProvider transactionProvider; - private final FilterFactory filterFactory; - - public UnusualFrequency( - TransactionProvider transactionProvider, - FilterFactory filterFactory, - BudgetProvider budgetProvider) { - this.transactionProvider = transactionProvider; - this.filterFactory = filterFactory; - this.budgetProvider = budgetProvider; - } - - @Override - public Optional detect( - Transaction transaction, UserCategoryStatistics statistics) { - var typicalFrequency = statistics.frequencies().get(transaction.getBudget()); - if (typicalFrequency == null || typicalFrequency.getN() < 3) { - log.trace( - "Not enough data for transaction {}. Skipping anomaly detection.", transaction.getId()); - return Optional.empty(); + private static final double FREQUENCY_ANOMALY_THRESHOLD = 1.5; + private static final double ADJUSTED_THRESHOLD = FREQUENCY_ANOMALY_THRESHOLD * (2.0 - .7); + + private final BudgetProvider budgetProvider; + private final TransactionProvider transactionProvider; + private final FilterFactory filterFactory; + + public UnusualFrequency( + TransactionProvider transactionProvider, + FilterFactory filterFactory, + BudgetProvider budgetProvider) { + this.transactionProvider = transactionProvider; + this.filterFactory = filterFactory; + this.budgetProvider = budgetProvider; } - long currentMonthCount = computeTransactionsInMonth(transaction); - double mean = typicalFrequency.getMean(); - double stdDev = typicalFrequency.getStandardDeviation(); - double zScore = Math.abs(currentMonthCount - mean) / stdDev; - - if (zScore > ADJUSTED_THRESHOLD) { - double score = Math.min(1.0, zScore / (ADJUSTED_THRESHOLD * 2)); - - return Optional.of(SpendingInsight.builder() - .type(InsightType.UNUSUAL_FREQUENCY) - .category(transaction.getBudget()) - .severity(getSeverityFromScore(score)) - .score(score) - .detectedDate(transaction.getDate()) - .message(generateMessage(currentMonthCount, mean)) - .transactionId(transaction.getId()) - .metadata(Map.of( - "frequency", currentMonthCount, - "z_score", zScore, - "mean", mean, - "std_dev", stdDev)) - .build()); + @Override + public Optional detect( + Transaction transaction, UserCategoryStatistics statistics) { + var typicalFrequency = statistics.frequencies().get(transaction.getBudget()); + if (typicalFrequency == null || typicalFrequency.getN() < 3) { + log.trace( + "Not enough data for transaction {}. Skipping anomaly detection.", + transaction.getId()); + return Optional.empty(); + } + + long currentMonthCount = computeTransactionsInMonth(transaction); + double mean = typicalFrequency.getMean(); + double stdDev = typicalFrequency.getStandardDeviation(); + double zScore = Math.abs(currentMonthCount - mean) / stdDev; + + if (zScore > ADJUSTED_THRESHOLD) { + double score = Math.min(1.0, zScore / (ADJUSTED_THRESHOLD * 2)); + + return Optional.of(SpendingInsight.builder() + .type(InsightType.UNUSUAL_FREQUENCY) + .category(transaction.getBudget()) + .severity(getSeverityFromScore(score)) + .score(score) + .detectedDate(transaction.getDate()) + .message(generateMessage(currentMonthCount, mean)) + .transactionId(transaction.getId()) + .metadata(Map.of( + "frequency", currentMonthCount, + "z_score", zScore, + "mean", mean, + "std_dev", stdDev)) + .build()); + } + + return Optional.empty(); } - return Optional.empty(); - } + private String generateMessage(long currentMonthCount, double mean) { + if (currentMonthCount > mean) { + return "computed.insight.frequency.high"; + } + return "computed.insight.frequency.low"; + } + + protected long computeTransactionsInMonth(Transaction transaction) { + var expense = budgetProvider + .lookup(transaction.getDate().getYear(), transaction.getDate().getMonthValue()) + .stream() + .flatMap(b -> b.getExpenses().stream()) + .filter(e -> e.getName().equalsIgnoreCase(transaction.getBudget())) + .findFirst() + .orElseThrow(); + + var filter = filterFactory + .transaction() + .expenses(Collections.List(new EntityRef(expense.getId()))) + .range(Dates.range(transaction.getDate().withDayOfMonth(1), ChronoUnit.MONTHS)) + .page(1, 1); - private String generateMessage(long currentMonthCount, double mean) { - if (currentMonthCount > mean) { - return "computed.insight.frequency.high"; + return transactionProvider.lookup(filter).total(); } - return "computed.insight.frequency.low"; - } - - protected long computeTransactionsInMonth(Transaction transaction) { - var expense = - budgetProvider - .lookup(transaction.getDate().getYear(), transaction.getDate().getMonthValue()) - .stream() - .flatMap(b -> b.getExpenses().stream()) - .filter(e -> e.getName().equalsIgnoreCase(transaction.getBudget())) - .findFirst() - .orElseThrow(); - - var filter = filterFactory - .transaction() - .expenses(Collections.List(new EntityRef(expense.getId()))) - .range(Dates.range(transaction.getDate().withDayOfMonth(1), ChronoUnit.MONTHS)) - .page(1, 1); - - return transactionProvider.lookup(filter).total(); - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualMerchant.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualMerchant.java index a69805fa..90ea4288 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualMerchant.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UnusualMerchant.java @@ -4,39 +4,43 @@ import com.jongsoft.finance.domain.insight.Severity; import com.jongsoft.finance.domain.insight.SpendingInsight; import com.jongsoft.finance.domain.transaction.Transaction; + +import lombok.extern.slf4j.Slf4j; + import java.time.LocalDate; import java.util.Map; import java.util.Optional; -import lombok.extern.slf4j.Slf4j; @Slf4j public class UnusualMerchant implements Anomaly { - @Override - public Optional detect( - Transaction transaction, UserCategoryStatistics statistics) { - var merchant = transaction.computeTo().getName(); - var typicalMerchants = statistics.typicalMerchants().get(transaction.getBudget()); - if (typicalMerchants == null || typicalMerchants.isEmpty()) { - log.trace( - "Not enough data for transaction {}. Skipping anomaly detection.", transaction.getId()); - return Optional.empty(); - } + @Override + public Optional detect( + Transaction transaction, UserCategoryStatistics statistics) { + var merchant = transaction.computeTo().getName(); + var typicalMerchants = statistics.typicalMerchants().get(transaction.getBudget()); + if (typicalMerchants == null || typicalMerchants.isEmpty()) { + log.trace( + "Not enough data for transaction {}. Skipping anomaly detection.", + transaction.getId()); + return Optional.empty(); + } - if (!typicalMerchants.contains(merchant)) { - double score = 0.8; // Fixed score for unusual merchant + if (!typicalMerchants.contains(merchant)) { + double score = 0.8; // Fixed score for unusual merchant - return Optional.of(SpendingInsight.builder() - .type(InsightType.UNUSUAL_MERCHANT) - .category(transaction.getBudget()) - .severity(Severity.INFO) - .score(score) - .detectedDate(LocalDate.now()) - .message("computed.insight.merchant.unusual") - .transactionId(transaction.getId()) - .metadata(Map.of("merchant", merchant, "known_merchants_count", typicalMerchants.size())) - .build()); - } + return Optional.of(SpendingInsight.builder() + .type(InsightType.UNUSUAL_MERCHANT) + .category(transaction.getBudget()) + .severity(Severity.INFO) + .score(score) + .detectedDate(LocalDate.now()) + .message("computed.insight.merchant.unusual") + .transactionId(transaction.getId()) + .metadata(Map.of( + "merchant", merchant, "known_merchants_count", typicalMerchants.size())) + .build()); + } - return Optional.empty(); - } + return Optional.empty(); + } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UserCategoryStatistics.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UserCategoryStatistics.java index 5cac1e53..97dbfc7f 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UserCategoryStatistics.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/anomaly/UserCategoryStatistics.java @@ -1,13 +1,14 @@ package com.jongsoft.finance.spending.detector.anomaly; +import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics; + import java.util.HashMap; import java.util.Map; import java.util.Set; -import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics; public record UserCategoryStatistics( - BudgetStatisticsMap amounts, - BudgetStatisticsMap frequencies, - Map> typicalMerchants) { - public static class BudgetStatisticsMap extends HashMap {} + BudgetStatisticsMap amounts, + BudgetStatisticsMap frequencies, + Map> typicalMerchants) { + public static class BudgetStatisticsMap extends HashMap {} } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/AmountPattern.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/AmountPattern.java index 485a2687..9af7cc78 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/AmountPattern.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/AmountPattern.java @@ -3,73 +3,77 @@ import com.jongsoft.finance.domain.insight.PatternType; import com.jongsoft.finance.domain.insight.SpendingPattern; import com.jongsoft.finance.domain.transaction.Transaction; + import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingMatch; + import java.time.LocalDate; import java.util.*; public class AmountPattern implements Pattern { - @Override - public Optional detect( - Transaction transaction, List> matches) { - var amountPerDate = matches.stream() - .map(match -> new AbstractMap.SimpleEntry<>( - LocalDate.parse(Objects.requireNonNull(match.embedded().metadata().getString("date"))), - match.embedded().metadata().getDouble("amount"))) - .sorted(Map.Entry.comparingByKey()) - .toList(); + @Override + public Optional detect( + Transaction transaction, List> matches) { + var amountPerDate = matches.stream() + .map(match -> new AbstractMap.SimpleEntry<>( + LocalDate.parse(Objects.requireNonNull( + match.embedded().metadata().getString("date"))), + match.embedded().metadata().getDouble("amount"))) + .sorted(Map.Entry.comparingByKey()) + .toList(); + + var amountPatternType = detectPatternType(amountPerDate); + if (amountPatternType != null) { + var averageAmount = calculateAverage(amountPerDate); + return Optional.of(SpendingPattern.builder() + .type(amountPatternType) + .category(transaction.getCategory()) + .detectedDate(transaction.getDate().withDayOfMonth(1)) + .confidence(0.85) + .metadata(Map.of( + "typical_amount", averageAmount, + "current_amount", transaction.computeAmount(transaction.computeFrom()), + "deviation_percent", + calculateDeviationPercent(transaction, averageAmount))) + .build()); + } - var amountPatternType = detectPatternType(amountPerDate); - if (amountPatternType != null) { - var averageAmount = calculateAverage(amountPerDate); - return Optional.of(SpendingPattern.builder() - .type(amountPatternType) - .category(transaction.getCategory()) - .detectedDate(transaction.getDate().withDayOfMonth(1)) - .confidence(0.85) - .metadata(Map.of( - "typical_amount", averageAmount, - "current_amount", transaction.computeAmount(transaction.computeFrom()), - "deviation_percent", calculateDeviationPercent(transaction, averageAmount))) - .build()); + return Optional.empty(); } - return Optional.empty(); - } + private PatternType detectPatternType( + List> amountPerDate) { + var midPoint = amountPerDate.size() / 2; + double firstHalfAvg = amountPerDate.subList(0, midPoint).stream() + .mapToDouble(Map.Entry::getValue) + .average() + .orElse(0); - private PatternType detectPatternType( - List> amountPerDate) { - var midPoint = amountPerDate.size() / 2; - double firstHalfAvg = amountPerDate.subList(0, midPoint).stream() - .mapToDouble(Map.Entry::getValue) - .average() - .orElse(0); + double secondHalfAvg = amountPerDate.subList(midPoint, amountPerDate.size()).stream() + .mapToDouble(Map.Entry::getValue) + .average() + .orElse(0); - double secondHalfAvg = amountPerDate.subList(midPoint, amountPerDate.size()).stream() - .mapToDouble(Map.Entry::getValue) - .average() - .orElse(0); + double percentChange = (secondHalfAvg - firstHalfAvg) / firstHalfAvg; - double percentChange = (secondHalfAvg - firstHalfAvg) / firstHalfAvg; + if (percentChange > 0.15) { + return PatternType.INCREASING_TREND; + } else if (percentChange < -0.15) { + return PatternType.DECREASING_TREND; + } - if (percentChange > 0.15) { - return PatternType.INCREASING_TREND; - } else if (percentChange < -0.15) { - return PatternType.DECREASING_TREND; + return null; } - return null; - } - - private double calculateAverage(List> values) { - return values.stream() - .mapToDouble(AbstractMap.SimpleEntry::getValue) - .average() - .orElse(0); - } + private double calculateAverage(List> values) { + return values.stream() + .mapToDouble(AbstractMap.SimpleEntry::getValue) + .average() + .orElse(0); + } - private double calculateDeviationPercent(Transaction transaction, double average) { - double currentAmount = Math.abs(transaction.computeAmount(transaction.computeFrom())); - return (currentAmount - average) / average * 100; - } + private double calculateDeviationPercent(Transaction transaction, double average) { + double currentAmount = Math.abs(transaction.computeAmount(transaction.computeFrom())); + return (currentAmount - average) / average * 100; + } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/OccurrencePattern.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/OccurrencePattern.java index 8210b0b6..e492b48f 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/OccurrencePattern.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/OccurrencePattern.java @@ -5,139 +5,144 @@ import com.jongsoft.finance.domain.insight.PatternType; import com.jongsoft.finance.domain.insight.SpendingPattern; import com.jongsoft.finance.domain.transaction.Transaction; + import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingMatch; + +import org.slf4j.Logger; + import java.time.DayOfWeek; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; -import org.slf4j.Logger; public class OccurrencePattern implements Pattern { - private static final double PERCENTAGE_INSIDE_AVG_INTERVAL = 0.75; - private static final int DAYS_DEVIATION_ALLOWED = 3; - private final Logger log = getLogger(OccurrencePattern.class); - - public Optional detect( - Transaction transaction, List> matches) { - // Need at least 3 matches to establish a pattern - if (matches.size() < 3) { - return Optional.empty(); + private static final double PERCENTAGE_INSIDE_AVG_INTERVAL = 0.75; + private static final int DAYS_DEVIATION_ALLOWED = 3; + private final Logger log = getLogger(OccurrencePattern.class); + + public Optional detect( + Transaction transaction, List> matches) { + // Need at least 3 matches to establish a pattern + if (matches.size() < 3) { + return Optional.empty(); + } + + var dates = matches.stream() + .map(match -> LocalDate.parse( + Objects.requireNonNull(match.embedded().metadata().getString("date")))) + .sorted() + .toList(); + + var detected = detectMonthlyOrWeekly(computeIntervals(dates)); + if (detected != null) { + var amounts = matches.stream() + .map(match -> match.embedded().metadata().getDouble("amount")) + .filter(Objects::nonNull) + .toList(); + + return Optional.of(SpendingPattern.builder() + .type(detected) + .category(transaction.getCategory()) + .detectedDate(transaction.getDate().withDayOfMonth(1)) + .confidence(calculateConfidence(matches)) + .metadata(Map.of( + "frequency", + detected == PatternType.RECURRING_WEEKLY ? "weekly" : "monthly", + "typical_amount", calculateAverage(amounts), + "vector_similarity", calculateAverageSimilarity(matches), + "typical_day", getMostCommonDayOfWeek(dates))) + .build()); + } + + return Optional.empty(); } - var dates = matches.stream() - .map(match -> - LocalDate.parse(Objects.requireNonNull(match.embedded().metadata().getString("date")))) - .sorted() - .toList(); - - var detected = detectMonthlyOrWeekly(computeIntervals(dates)); - if (detected != null) { - var amounts = matches.stream() - .map(match -> match.embedded().metadata().getDouble("amount")) - .filter(Objects::nonNull) - .toList(); - - return Optional.of(SpendingPattern.builder() - .type(detected) - .category(transaction.getCategory()) - .detectedDate(transaction.getDate().withDayOfMonth(1)) - .confidence(calculateConfidence(matches)) - .metadata(Map.of( - "frequency", detected == PatternType.RECURRING_WEEKLY ? "weekly" : "monthly", - "typical_amount", calculateAverage(amounts), - "vector_similarity", calculateAverageSimilarity(matches), - "typical_day", getMostCommonDayOfWeek(dates))) - .build()); + private double calculateAverage(List values) { + return values.stream().mapToDouble(Double::doubleValue).average().orElse(0); } - return Optional.empty(); - } - - private double calculateAverage(List values) { - return values.stream().mapToDouble(Double::doubleValue).average().orElse(0); - } - - private double calculateConfidence(List> matches) { - // Calculate confidence based on number of matches and their similarity scores - double avgSimilarity = calculateAverageSimilarity(matches); - double matchFactor = - Math.min(1.0, matches.size() / 10.0); // Scale based on number of matches, max at 10 - - return avgSimilarity * 0.7 + matchFactor * 0.3; // Weighted combination - } - - private double calculateAverageSimilarity(List> matches) { - return matches.stream().mapToDouble(EmbeddingMatch::score).average().orElse(0); - } - - private DayOfWeek getMostCommonDayOfWeek(List dates) { - Map dayCount = dates.stream() - .collect(Collectors.groupingBy(LocalDate::getDayOfWeek, Collectors.counting())); - - return dayCount.entrySet().stream() - .max(Map.Entry.comparingByValue()) - .map(Map.Entry::getKey) - .orElse(DayOfWeek.MONDAY); - } - - private PatternType detectMonthlyOrWeekly(List intervals) { - if (intervals.isEmpty()) return null; - - var avgInterval = - (int) intervals.stream().mapToLong(Number::longValue).average().orElse(0); - - // Check for monthly pattern first (more specific check) - if (avgInterval >= 28 && avgInterval <= 31) { - // For monthly patterns, we need to be more lenient because months have different - // lengths - // Check if most intervals are between 28 and 31 days - var monthlyIntervals = intervals.stream() - .mapToLong(Number::longValue) - .filter(i -> i >= 28 && i <= 31) - .count(); - boolean isMonthly = monthlyIntervals >= intervals.size() * PERCENTAGE_INSIDE_AVG_INTERVAL; - - log.trace( - "Average interval: {}, monthly intervals: {}, is monthly: {}.", - avgInterval, - monthlyIntervals, - isMonthly); - if (isMonthly) { - return PatternType.RECURRING_MONTHLY; - } + private double calculateConfidence(List> matches) { + // Calculate confidence based on number of matches and their similarity scores + double avgSimilarity = calculateAverageSimilarity(matches); + double matchFactor = + Math.min(1.0, matches.size() / 10.0); // Scale based on number of matches, max at 10 + + return avgSimilarity * 0.7 + matchFactor * 0.3; // Weighted combination } - // Check for weekly pattern - var numberWithinAvg = intervals.stream() - .mapToLong(Number::longValue) - .filter(i -> Math.abs(i - avgInterval) <= DAYS_DEVIATION_ALLOWED) - .count(); - boolean isConsistent = numberWithinAvg >= intervals.size() * PERCENTAGE_INSIDE_AVG_INTERVAL; - - log.trace( - "Average interval: {}, number within avg: {}, consistent: {}.", - avgInterval, - numberWithinAvg, - isConsistent); - if (!isConsistent) { - return null; + private double calculateAverageSimilarity(List> matches) { + return matches.stream().mapToDouble(EmbeddingMatch::score).average().orElse(0); } - if (avgInterval >= 7 && avgInterval <= 8) { - return PatternType.RECURRING_WEEKLY; + private DayOfWeek getMostCommonDayOfWeek(List dates) { + Map dayCount = dates.stream() + .collect(Collectors.groupingBy(LocalDate::getDayOfWeek, Collectors.counting())); + + return dayCount.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(DayOfWeek.MONDAY); } - return null; - } + private PatternType detectMonthlyOrWeekly(List intervals) { + if (intervals.isEmpty()) return null; + + var avgInterval = + (int) intervals.stream().mapToLong(Number::longValue).average().orElse(0); + + // Check for monthly pattern first (more specific check) + if (avgInterval >= 28 && avgInterval <= 31) { + // For monthly patterns, we need to be more lenient because months have different + // lengths + // Check if most intervals are between 28 and 31 days + var monthlyIntervals = intervals.stream() + .mapToLong(Number::longValue) + .filter(i -> i >= 28 && i <= 31) + .count(); + boolean isMonthly = + monthlyIntervals >= intervals.size() * PERCENTAGE_INSIDE_AVG_INTERVAL; + + log.trace( + "Average interval: {}, monthly intervals: {}, is monthly: {}.", + avgInterval, + monthlyIntervals, + isMonthly); + if (isMonthly) { + return PatternType.RECURRING_MONTHLY; + } + } + + // Check for weekly pattern + var numberWithinAvg = intervals.stream() + .mapToLong(Number::longValue) + .filter(i -> Math.abs(i - avgInterval) <= DAYS_DEVIATION_ALLOWED) + .count(); + boolean isConsistent = numberWithinAvg >= intervals.size() * PERCENTAGE_INSIDE_AVG_INTERVAL; + + log.trace( + "Average interval: {}, number within avg: {}, consistent: {}.", + avgInterval, + numberWithinAvg, + isConsistent); + if (!isConsistent) { + return null; + } + + if (avgInterval >= 7 && avgInterval <= 8) { + return PatternType.RECURRING_WEEKLY; + } + + return null; + } - private List computeIntervals(List dates) { - List intervals = new ArrayList<>(); - for (int i = 1; i < dates.size(); i++) { - intervals.add(ChronoUnit.DAYS.between(dates.get(i - 1), dates.get(i))); + private List computeIntervals(List dates) { + List intervals = new ArrayList<>(); + for (int i = 1; i < dates.size(); i++) { + intervals.add(ChronoUnit.DAYS.between(dates.get(i - 1), dates.get(i))); + } + return intervals; } - return intervals; - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/Pattern.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/Pattern.java index 514056d7..9eb964b3 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/Pattern.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/Pattern.java @@ -2,13 +2,15 @@ import com.jongsoft.finance.domain.insight.SpendingPattern; import com.jongsoft.finance.domain.transaction.Transaction; + import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingMatch; + import java.util.List; import java.util.Optional; public interface Pattern { - Optional detect( - Transaction transaction, List> matches); + Optional detect( + Transaction transaction, List> matches); } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/SeasonalPattern.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/SeasonalPattern.java index 454b942c..b7145ba0 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/SeasonalPattern.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/detector/pattern/SeasonalPattern.java @@ -3,8 +3,10 @@ import com.jongsoft.finance.domain.insight.PatternType; import com.jongsoft.finance.domain.insight.SpendingPattern; import com.jongsoft.finance.domain.transaction.Transaction; + import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingMatch; + import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -15,53 +17,53 @@ public class SeasonalPattern implements Pattern { - @Override - public Optional detect( - Transaction transaction, List> matches) { - if (matches.isEmpty()) { - return Optional.empty(); - } + @Override + public Optional detect( + Transaction transaction, List> matches) { + if (matches.isEmpty()) { + return Optional.empty(); + } - int currentMonth = transaction.getDate().getMonthValue(); - var transactionsByMonth = matches.stream() - .map(match -> - LocalDate.parse(Objects.requireNonNull(match.embedded().metadata().getString("date")))) - .map(LocalDate::getMonthValue) - .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + int currentMonth = transaction.getDate().getMonthValue(); + var transactionsByMonth = matches.stream() + .map(match -> LocalDate.parse( + Objects.requireNonNull(match.embedded().metadata().getString("date")))) + .map(LocalDate::getMonthValue) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); - if (transactionsByMonth.isEmpty()) { - return Optional.empty(); - } + if (transactionsByMonth.isEmpty()) { + return Optional.empty(); + } - var numberInMonth = transactionsByMonth.getOrDefault(currentMonth, 0L); - var avgPerMonth = matches.size() / transactionsByMonth.size(); - if (isSignificantlyMoreThanAverage(numberInMonth, avgPerMonth)) { - return Optional.of(SpendingPattern.builder() - .type(PatternType.SEASONAL) - .category(transaction.getCategory()) - .detectedDate(transaction.getDate().withDayOfMonth(1)) - .confidence(.75) - .metadata(Map.of("season", getCurrentSeason(transaction.getDate()))) - .build()); - } + var numberInMonth = transactionsByMonth.getOrDefault(currentMonth, 0L); + var avgPerMonth = matches.size() / transactionsByMonth.size(); + if (isSignificantlyMoreThanAverage(numberInMonth, avgPerMonth)) { + return Optional.of(SpendingPattern.builder() + .type(PatternType.SEASONAL) + .category(transaction.getCategory()) + .detectedDate(transaction.getDate().withDayOfMonth(1)) + .confidence(.75) + .metadata(Map.of("season", getCurrentSeason(transaction.getDate()))) + .build()); + } - return Optional.empty(); - } + return Optional.empty(); + } - private boolean isSignificantlyMoreThanAverage(long numberInMonth, long avgPerMonth) { - return numberInMonth >= avgPerMonth * 2.0; - } + private boolean isSignificantlyMoreThanAverage(long numberInMonth, long avgPerMonth) { + return numberInMonth >= avgPerMonth * 2.0; + } - private String getCurrentSeason(LocalDate date) { - int month = date.getMonthValue(); - if (month >= 3 && month <= 5) { - return "Spring"; - } else if (month >= 6 && month <= 8) { - return "Summer"; - } else if (month >= 9 && month <= 11) { - return "Fall"; - } else { - return "Winter"; + private String getCurrentSeason(LocalDate date) { + int month = date.getMonthValue(); + if (month >= 3 && month <= 5) { + return "Spring"; + } else if (month >= 6 && month <= 8) { + return "Summer"; + } else if (month >= 9 && month <= 11) { + return "Fall"; + } else { + return "Winter"; + } } - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/AnalysisRunner.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/AnalysisRunner.java index cdd4d223..e445c20e 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/AnalysisRunner.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/AnalysisRunner.java @@ -8,80 +8,88 @@ import com.jongsoft.finance.spending.Detector; import com.jongsoft.finance.spending.SpendingAnalyticsEnabled; import com.jongsoft.lang.Dates; + +import io.micrometer.core.annotation.Timed; import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.time.YearMonth; import java.util.List; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @SpendingAnalyticsEnabled class AnalysisRunner { - private final List> transactionDetectors; - private final FilterFactory filterFactory; - private final TransactionProvider transactionProvider; - - AnalysisRunner( - List> transactionDetectors, - FilterFactory filterFactory, - TransactionProvider transactionProvider) { - this.transactionDetectors = transactionDetectors; - this.filterFactory = filterFactory; - this.transactionProvider = transactionProvider; - } + private final List> transactionDetectors; + private final FilterFactory filterFactory; + private final TransactionProvider transactionProvider; - @Transactional - public boolean analyzeForUser(YearMonth month) { - if (!transactionDetectors.stream().allMatch(Detector::readyForAnalysis)) { - log.debug("Not all transaction detectors are ready for analysis. Skipping analysis."); - return false; + AnalysisRunner( + List> transactionDetectors, + FilterFactory filterFactory, + TransactionProvider transactionProvider) { + this.transactionDetectors = transactionDetectors; + this.filterFactory = filterFactory; + this.transactionProvider = transactionProvider; } - log.info("Starting monthly spending analysis for {}.", month); - try { - CleanInsightsForMonth.cleanInsightsForMonth(month); - var transactionFilter = filterFactory - .transaction() - .range(Dates.range(month.atDay(1), month.atEndOfMonth())) - .ownAccounts(); + @Transactional + @Timed("insight.monthly.analysis") + public boolean analyzeForUser(YearMonth month) { + if (!transactionDetectors.stream().allMatch(Detector::readyForAnalysis)) { + log.debug("Not all transaction detectors are ready for analysis. Skipping analysis."); + return false; + } - var transactionInMonth = transactionProvider.lookup(transactionFilter).content(); - if (transactionInMonth.isEmpty()) { - log.trace("No transactions found for {}, skipping analysis.", month); - return false; - } + log.info("Starting monthly spending analysis for {}.", month); + try { + CleanInsightsForMonth.cleanInsightsForMonth(month); + var transactionFilter = filterFactory + .transaction() + .range(Dates.range(month.atDay(1), month.atEndOfMonth())) + .ownAccounts(); - log.debug("Retrieved {} transactions for {}.", transactionInMonth.size(), month); - transactionDetectors.forEach(detector -> detector.updateBaseline(month)); - transactionInMonth.stream() - .flatMap(t -> processTransaction(t).stream()) - .distinct() - .forEach(Insight::signal); - log.debug("Completed monthly spending analysis for {}.", month); - } catch (Exception e) { - log.error("Error occurred while processing monthly spending analysis for {}.", month, e); - return false; - } finally { - transactionDetectors.forEach(Detector::analysisCompleted); - } + var transactionInMonth = + transactionProvider.lookup(transactionFilter).content(); + if (transactionInMonth.isEmpty()) { + log.trace("No transactions found for {}, skipping analysis.", month); + return false; + } - return true; - } + log.debug("Retrieved {} transactions for {}.", transactionInMonth.size(), month); + transactionDetectors.forEach(detector -> detector.updateBaseline(month)); + transactionInMonth.stream() + .flatMap(t -> processTransaction(t).stream()) + .distinct() + .forEach(Insight::signal); + log.debug("Completed monthly spending analysis for {}.", month); + } catch (Exception e) { + log.error( + "Error occurred while processing monthly spending analysis for {}.", month, e); + return false; + } finally { + transactionDetectors.forEach(Detector::analysisCompleted); + } - private List processTransaction(Transaction transaction) { - if (transaction.getCategory() == null && transaction.getBudget() == null) { - return List.of(); // Skip transactions without a category + return true; } - var insightList = new java.util.ArrayList(); - for (var detector : transactionDetectors) { - log.trace( - "Processing transaction {} with detector {}.", - transaction, - detector.getClass().getSimpleName()); - insightList.addAll(detector.detect(transaction)); + private List processTransaction(Transaction transaction) { + if (transaction.getCategory() == null && transaction.getBudget() == null) { + return List.of(); // Skip transactions without a category + } + + var insightList = new java.util.ArrayList(); + for (var detector : transactionDetectors) { + log.trace( + "Processing transaction {} with detector {}.", + transaction, + detector.getClass().getSimpleName()); + insightList.addAll(detector.detect(transaction)); + } + return insightList; } - return insightList; - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/MonthlySpendingAnalysisScheduler.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/MonthlySpendingAnalysisScheduler.java index 3b07c5bc..d952a40f 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/MonthlySpendingAnalysisScheduler.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/MonthlySpendingAnalysisScheduler.java @@ -4,34 +4,38 @@ import com.jongsoft.finance.messaging.InternalAuthenticationEvent; import com.jongsoft.finance.providers.UserProvider; import com.jongsoft.finance.spending.SpendingAnalyticsEnabled; + import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.scheduling.annotation.Scheduled; + import jakarta.inject.Singleton; -import java.time.YearMonth; + import lombok.extern.slf4j.Slf4j; +import java.time.YearMonth; + @Slf4j @Singleton @SpendingAnalyticsEnabled public class MonthlySpendingAnalysisScheduler { - private final UserProvider userProvider; - private final ApplicationEventPublisher eventPublisher; - - public MonthlySpendingAnalysisScheduler( - UserProvider userProvider, - ApplicationEventPublisher eventPublisher) { - this.userProvider = userProvider; - this.eventPublisher = eventPublisher; - } - - @Scheduled(cron = "0 0 0 15 1-12 *") - public void analyzeMonthlySpendingPatterns() { - log.info("Scheduling monthly spending analysis, for month {}.", YearMonth.now()); - for (var user : userProvider.lookup()) { - eventPublisher.publishEvent( - new InternalAuthenticationEvent(this, user.getUsername().email())); - new AnalyzeJob(user.getUsername(), YearMonth.now()); + private final UserProvider userProvider; + private final ApplicationEventPublisher eventPublisher; + + public MonthlySpendingAnalysisScheduler( + UserProvider userProvider, + ApplicationEventPublisher eventPublisher) { + this.userProvider = userProvider; + this.eventPublisher = eventPublisher; + } + + @Scheduled(cron = "0 0 0 15 1-12 *") + public void analyzeMonthlySpendingPatterns() { + log.info("Scheduling monthly spending analysis, for month {}.", YearMonth.now()); + for (var user : userProvider.lookup()) { + eventPublisher.publishEvent( + new InternalAuthenticationEvent(this, user.getUsername().email())); + new AnalyzeJob(user.getUsername(), YearMonth.now()); + } } - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/RunAnalyzeJobScheduler.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/RunAnalyzeJobScheduler.java index 6831b643..002867f6 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/RunAnalyzeJobScheduler.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/RunAnalyzeJobScheduler.java @@ -4,61 +4,66 @@ import com.jongsoft.finance.providers.AnalyzeJobProvider; import com.jongsoft.finance.providers.UserProvider; import com.jongsoft.finance.spending.SpendingAnalyticsEnabled; + import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.scheduling.annotation.Scheduled; import io.micronaut.transaction.annotation.Transactional; + import jakarta.inject.Singleton; -import java.util.UUID; + import lombok.extern.slf4j.Slf4j; + import org.slf4j.MDC; +import java.util.UUID; + @Slf4j @Singleton @SpendingAnalyticsEnabled public class RunAnalyzeJobScheduler { - private final AnalyzeJobProvider analyzeJobProvider; - private final AnalysisRunner analysisRunner; - private final UserProvider userProvider; - private final ApplicationEventPublisher eventPublisher; + private final AnalyzeJobProvider analyzeJobProvider; + private final AnalysisRunner analysisRunner; + private final UserProvider userProvider; + private final ApplicationEventPublisher eventPublisher; - RunAnalyzeJobScheduler( - AnalyzeJobProvider analyzeJobProvider, - AnalysisRunner analysisRunner, - UserProvider userProvider, - ApplicationEventPublisher eventPublisher) { - this.analyzeJobProvider = analyzeJobProvider; - this.analysisRunner = analysisRunner; - this.userProvider = userProvider; - this.eventPublisher = eventPublisher; - } + RunAnalyzeJobScheduler( + AnalyzeJobProvider analyzeJobProvider, + AnalysisRunner analysisRunner, + UserProvider userProvider, + ApplicationEventPublisher eventPublisher) { + this.analyzeJobProvider = analyzeJobProvider; + this.analysisRunner = analysisRunner; + this.userProvider = userProvider; + this.eventPublisher = eventPublisher; + } - @Transactional - @Scheduled(cron = "*/5 * * * * *") - public void analyzeMonthlySpendingPatterns() { - MDC.put("correlationId", UUID.randomUUID().toString()); - var jobToRun = analyzeJobProvider.first(); - if (jobToRun.isPresent()) { - var analyzeJob = jobToRun.get(); - log.info("Scheduling analyze job {}.", analyzeJob.getMonth()); - eventPublisher.publishEvent( - new InternalAuthenticationEvent(this, analyzeJob.getUser().email())); - var success = analysisRunner.analyzeForUser(analyzeJob.getMonth()); + @Transactional + @Scheduled(cron = "*/5 * * * * *") + public void analyzeMonthlySpendingPatterns() { + MDC.put("correlationId", UUID.randomUUID().toString()); + var jobToRun = analyzeJobProvider.first(); + if (jobToRun.isPresent()) { + var analyzeJob = jobToRun.get(); + log.info("Scheduling analyze job {}.", analyzeJob.getMonth()); + eventPublisher.publishEvent( + new InternalAuthenticationEvent(this, analyzeJob.getUser().email())); + var success = analysisRunner.analyzeForUser(analyzeJob.getMonth()); - if (success) { - log.debug( - "Completed analysis for month {} for user {}.", - analyzeJob.getMonth(), - analyzeJob.getUser().email()); - analyzeJob.complete(); - } else { - log.warn( - "Failed to complete analysis for month {} for user {}.", - analyzeJob.getMonth(), - analyzeJob.getUser().email()); - analyzeJob.fail(); - } + if (success) { + log.debug( + "Completed analysis for month {} for user {}.", + analyzeJob.getMonth(), + analyzeJob.getUser().email()); + analyzeJob.complete(); + } else { + log.warn( + "Failed to complete analysis for month {} for user {}.", + analyzeJob.getMonth(), + analyzeJob.getUser().email()); + analyzeJob.fail(); + } + } + MDC.remove("correlationId"); } - MDC.remove("correlationId"); - } } diff --git a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/SpendingReportScheduler.java b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/SpendingReportScheduler.java index 47dd5595..04fb73c4 100644 --- a/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/SpendingReportScheduler.java +++ b/learning/learning-module-spending-patterns/src/main/java/com/jongsoft/finance/spending/scheduler/SpendingReportScheduler.java @@ -6,65 +6,73 @@ import com.jongsoft.finance.providers.SpendingPatternProvider; import com.jongsoft.finance.security.CurrentUserProvider; import com.jongsoft.finance.spending.SpendingAnalyticsEnabled; + import io.micronaut.scheduling.annotation.Scheduled; + import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.Properties; -import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @SpendingAnalyticsEnabled class SpendingReportScheduler { - private final SpendingInsightProvider spendingInsightProvider; - private final SpendingPatternProvider spendingPatternProvider; - private final CurrentUserProvider currentUserProvider; - private final MailDaemon mailDaemon; + private final SpendingInsightProvider spendingInsightProvider; + private final SpendingPatternProvider spendingPatternProvider; + private final CurrentUserProvider currentUserProvider; + private final MailDaemon mailDaemon; - SpendingReportScheduler( - SpendingInsightProvider spendingInsightProvider, - SpendingPatternProvider spendingPatternProvider, - CurrentUserProvider currentUserProvider, - MailDaemon mailDaemon) { - this.spendingInsightProvider = spendingInsightProvider; - this.spendingPatternProvider = spendingPatternProvider; - this.currentUserProvider = currentUserProvider; - this.mailDaemon = mailDaemon; - } + SpendingReportScheduler( + SpendingInsightProvider spendingInsightProvider, + SpendingPatternProvider spendingPatternProvider, + CurrentUserProvider currentUserProvider, + MailDaemon mailDaemon) { + this.spendingInsightProvider = spendingInsightProvider; + this.spendingPatternProvider = spendingPatternProvider; + this.currentUserProvider = currentUserProvider; + this.mailDaemon = mailDaemon; + } - @Scheduled(cron = "0 0 0 20 1-12 *") - public void analyzeMonthlySpendingPatterns() { - try { - // Get the previous month - YearMonth previousMonth = YearMonth.now().minusMonths(1); - log.info("Sending out mail report for the previous month of analysis {}.", previousMonth); + @Scheduled(cron = "0 0 0 20 1-12 *") + public void analyzeMonthlySpendingPatterns() { + try { + // Get the previous month + YearMonth previousMonth = YearMonth.now().minusMonths(1); + log.info( + "Sending out mail report for the previous month of analysis {}.", + previousMonth); - // Get the current user - UserAccount user = currentUserProvider.currentUser(); + // Get the current user + UserAccount user = currentUserProvider.currentUser(); - // Retrieve spending insights and patterns for the previous month - var insights = spendingInsightProvider.lookup(previousMonth); - var patterns = spendingPatternProvider.lookup(previousMonth); + // Retrieve spending insights and patterns for the previous month + var insights = spendingInsightProvider.lookup(previousMonth); + var patterns = spendingPatternProvider.lookup(previousMonth); - // Format the month for display - String formattedMonth = previousMonth.format(DateTimeFormatter.ofPattern("MMMM yyyy")); + // Format the month for display + String formattedMonth = previousMonth.format(DateTimeFormatter.ofPattern("MMMM yyyy")); - // Create properties for the email template - Properties mailProperties = new Properties(); - mailProperties.put("user", user); - mailProperties.put("reportMonth", formattedMonth); - mailProperties.put("insights", insights); - mailProperties.put("patterns", patterns); + // Create properties for the email template + Properties mailProperties = new Properties(); + mailProperties.put("user", user); + mailProperties.put("reportMonth", formattedMonth); + mailProperties.put("insights", insights); + mailProperties.put("patterns", patterns); - // Send the email - mailDaemon.send(user.getUsername().email(), "spending-report", mailProperties); + // Send the email + mailDaemon.send(user.getUsername().email(), "spending-report", mailProperties); - log.info( - "Spending report email sent to {} for {}", user.getUsername().email(), formattedMonth); - } catch (Exception e) { - log.error("Error sending spending report email: {}", e.getMessage(), e); + log.info( + "Spending report email sent to {} for {}", + user.getUsername().email(), + formattedMonth); + } catch (Exception e) { + log.error("Error sending spending report email: {}", e.getMessage(), e); + } } - } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionEngine.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionEngine.java index 09bddfd0..a12f5262 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionEngine.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionEngine.java @@ -4,16 +4,16 @@ public interface SuggestionEngine { - SuggestionResult makeSuggestions(SuggestionInput transactionInput); + SuggestionResult makeSuggestions(SuggestionInput transactionInput); - /** - * Extracts a transaction based on the given input string and returns a {@code - * TransactionResult}. - * - * @param transactionInput the raw transaction input string to be processed - * @return a {@code TransactionResult} containing the extracted transaction data - */ - default Optional extractTransaction(String transactionInput) { - return Optional.empty(); - } + /** + * Extracts a transaction based on the given input string and returns a {@code + * TransactionResult}. + * + * @param transactionInput the raw transaction input string to be processed + * @return a {@code TransactionResult} containing the extracted transaction data + */ + default Optional extractTransaction(String transactionInput) { + return Optional.empty(); + } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionInput.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionInput.java index 78e19ea3..b402677d 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionInput.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/SuggestionInput.java @@ -3,4 +3,4 @@ import java.time.LocalDate; public record SuggestionInput( - LocalDate date, String description, String fromAccount, String toAccount, double amount) {} + LocalDate date, String description, String fromAccount, String toAccount, double amount) {} diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/TransactionResult.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/TransactionResult.java index ad648cfb..15fd960b 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/TransactionResult.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/TransactionResult.java @@ -1,14 +1,15 @@ package com.jongsoft.finance.learning; import com.jongsoft.finance.core.TransactionType; + import java.time.LocalDate; public record TransactionResult( - TransactionType type, - LocalDate date, - AccountResult from, - AccountResult to, - String description, - double amount) { - public record AccountResult(long id, String name) {} + TransactionType type, + LocalDate date, + AccountResult from, + AccountResult to, + String description, + double amount) { + public record AccountResult(long id, String name) {} } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/UserScopedExecutor.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/UserScopedExecutor.java index d3000aa1..9c28e339 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/UserScopedExecutor.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/UserScopedExecutor.java @@ -3,38 +3,42 @@ import com.jongsoft.finance.learning.config.LearningExecutor; import com.jongsoft.finance.messaging.InternalAuthenticationEvent; import com.jongsoft.finance.providers.UserProvider; + import io.micronaut.context.event.ApplicationEventPublisher; + import jakarta.inject.Singleton; + +import org.slf4j.MDC; + import java.util.UUID; import java.util.concurrent.ExecutorService; -import org.slf4j.MDC; @Singleton public final class UserScopedExecutor { - private final UserProvider userProvider; - private final ExecutorService executorService; - private final ApplicationEventPublisher eventPublisher; - - public UserScopedExecutor( - UserProvider userProvider, - @LearningExecutor ExecutorService executorService, - ApplicationEventPublisher eventPublisher) { - this.userProvider = userProvider; - this.executorService = executorService; - this.eventPublisher = eventPublisher; - } - - public void runForPerUser(Runnable runnable) { - for (var user : userProvider.lookup()) { - executorService.submit(() -> runForUser(user.getUsername().email(), runnable)); + private final UserProvider userProvider; + private final ExecutorService executorService; + private final ApplicationEventPublisher eventPublisher; + + public UserScopedExecutor( + UserProvider userProvider, + @LearningExecutor ExecutorService executorService, + ApplicationEventPublisher eventPublisher) { + this.userProvider = userProvider; + this.executorService = executorService; + this.eventPublisher = eventPublisher; + } + + public void runForPerUser(Runnable runnable) { + for (var user : userProvider.lookup()) { + executorService.submit(() -> runForUser(user.getUsername().email(), runnable)); + } + } + + private void runForUser(String username, Runnable runnable) { + MDC.put("correlationId", UUID.randomUUID().toString()); + eventPublisher.publishEvent(new InternalAuthenticationEvent(this, username)); + runnable.run(); + MDC.remove("correlationId"); } - } - - private void runForUser(String username, Runnable runnable) { - MDC.put("correlationId", UUID.randomUUID().toString()); - eventPublisher.publishEvent(new InternalAuthenticationEvent(this, username)); - runnable.run(); - MDC.remove("correlationId"); - } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningConfiguration.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningConfiguration.java index 841e82d5..bef73821 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningConfiguration.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningConfiguration.java @@ -2,15 +2,16 @@ import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; + import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @Factory class LearningConfiguration { - @Bean - @LearningExecutor - public ExecutorService learningExecutor() { - return Executors.newScheduledThreadPool(5); - } + @Bean + @LearningExecutor + public ExecutorService learningExecutor() { + return Executors.newScheduledThreadPool(5); + } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningExecutor.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningExecutor.java index 64c64f7c..5ccdb765 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningExecutor.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/LearningExecutor.java @@ -1,6 +1,7 @@ package com.jongsoft.finance.learning.config; import jakarta.inject.Qualifier; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/VectorConfiguration.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/VectorConfiguration.java index 76384540..f6b6ccc0 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/VectorConfiguration.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/config/VectorConfiguration.java @@ -5,36 +5,36 @@ @ConfigurationProperties("application.ai.vectors") public class VectorConfiguration { - public enum StorageType { - MEMORY, - PGSQL - } - - private StorageType storageType; - private String passKey; - private String storage; - - public void setStorageType(StorageType storageType) { - this.storageType = storageType; - } - - public StorageType getStorageType() { - return storageType; - } - - public String getPassKey() { - return passKey; - } - - public void setPassKey(String passKey) { - this.passKey = passKey; - } - - public String getStorage() { - return storage; - } - - public void setStorage(String storage) { - this.storage = storage; - } + public enum StorageType { + MEMORY, + PGSQL + } + + private StorageType storageType; + private String passKey; + private String storage; + + public void setStorageType(StorageType storageType) { + this.storageType = storageType; + } + + public StorageType getStorageType() { + return storageType; + } + + public String getPassKey() { + return passKey; + } + + public void setPassKey(String passKey) { + this.passKey = passKey; + } + + public String getStorage() { + return storage; + } + + public void setStorage(String storage) { + this.storage = storage; + } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwareInMemoryStore.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwareInMemoryStore.java index 4995646c..fb01c973 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwareInMemoryStore.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwareInMemoryStore.java @@ -4,54 +4,60 @@ import com.jongsoft.finance.security.Encryption; import com.jongsoft.lang.Control; + import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; + +import org.slf4j.Logger; + import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import org.slf4j.Logger; class ContextAwareInMemoryStore implements PledgerEmbeddingStore { - private final Logger log = getLogger(ContextAwareInMemoryStore.class); - - private final Path storagePath; - private final InMemoryEmbeddingStore internalStore; - private boolean shouldInitialize; - - private final Encryption encryption; - private final String decryptionKey; - - public ContextAwareInMemoryStore(Path storagePath, Encryption encryption, String decryptionKey) { - this.storagePath = storagePath; - this.encryption = encryption; - this.decryptionKey = decryptionKey; - if (Files.exists(storagePath)) { - log.debug("Embeddings found at '{}', loading from file.", storagePath); - var encrypted = Control.Try(() -> Files.readAllBytes(storagePath)); - var contents = encryption.decrypt(encrypted.get(), decryptionKey); - internalStore = InMemoryEmbeddingStore.fromJson(new String(contents, StandardCharsets.UTF_8)); - } else { - log.debug("No embeddings found at '{}', creating new store.", storagePath); - internalStore = new InMemoryEmbeddingStore<>(); - shouldInitialize = true; + private final Logger log = getLogger(ContextAwareInMemoryStore.class); + + private final Path storagePath; + private final InMemoryEmbeddingStore internalStore; + private boolean shouldInitialize; + + private final Encryption encryption; + private final String decryptionKey; + + public ContextAwareInMemoryStore( + Path storagePath, Encryption encryption, String decryptionKey) { + this.storagePath = storagePath; + this.encryption = encryption; + this.decryptionKey = decryptionKey; + if (Files.exists(storagePath)) { + log.debug("Embeddings found at '{}', loading from file.", storagePath); + var encrypted = Control.Try(() -> Files.readAllBytes(storagePath)); + var contents = encryption.decrypt(encrypted.get(), decryptionKey); + internalStore = + InMemoryEmbeddingStore.fromJson(new String(contents, StandardCharsets.UTF_8)); + } else { + log.debug("No embeddings found at '{}', creating new store.", storagePath); + internalStore = new InMemoryEmbeddingStore<>(); + shouldInitialize = true; + } + } + + @Override + public EmbeddingStore embeddingStore() { + return internalStore; + } + + @Override + public boolean shouldInitialize() { + return shouldInitialize; + } + + @Override + public void close() { + log.info("Shutting down embeddings store."); + var storageBytes = internalStore.serializeToJson().getBytes(StandardCharsets.UTF_8); + Control.Try( + () -> Files.write(storagePath, encryption.encrypt(storageBytes, decryptionKey))); } - } - - @Override - public EmbeddingStore embeddingStore() { - return internalStore; - } - - @Override - public boolean shouldInitialize() { - return shouldInitialize; - } - - @Override - public void close() { - log.info("Shutting down embeddings store."); - var storageBytes = internalStore.serializeToJson().getBytes(StandardCharsets.UTF_8); - Control.Try(() -> Files.write(storagePath, encryption.encrypt(storageBytes, decryptionKey))); - } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwarePgSQLStore.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwarePgSQLStore.java index 4ebe44e6..94e9253d 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwarePgSQLStore.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/ContextAwarePgSQLStore.java @@ -3,39 +3,40 @@ import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.pgvector.PgVectorEmbeddingStore; + import javax.sql.DataSource; public class ContextAwarePgSQLStore implements PledgerEmbeddingStore { - private PgVectorEmbeddingStore internalStore; - private final String tableName; - private final DataSource dataSource; + private PgVectorEmbeddingStore internalStore; + private final String tableName; + private final DataSource dataSource; - ContextAwarePgSQLStore(String tableName, DataSource dataSource) { - this.tableName = tableName; - this.dataSource = dataSource; - } + ContextAwarePgSQLStore(String tableName, DataSource dataSource) { + this.tableName = tableName; + this.dataSource = dataSource; + } - @Override - public EmbeddingStore embeddingStore() { - if (internalStore == null) { - internalStore = PgVectorEmbeddingStore.datasourceBuilder() - .datasource(dataSource) - .table("embedding_" + tableName) - .dimension(384) // copied from the - // AllMiniLmL6V2EmbeddingModel.knownDimension - .createTable(true) - .build(); + @Override + public EmbeddingStore embeddingStore() { + if (internalStore == null) { + internalStore = PgVectorEmbeddingStore.datasourceBuilder() + .datasource(dataSource) + .table("embedding_" + tableName) + .dimension(384) // copied from the + // AllMiniLmL6V2EmbeddingModel.knownDimension + .createTable(true) + .build(); + } + return internalStore; } - return internalStore; - } - @Override - public boolean shouldInitialize() { - return false; - } + @Override + public boolean shouldInitialize() { + return false; + } - @Override - public void close() { - // no action needed for the PgVector store - } + @Override + public void close() { + // no action needed for the PgVector store + } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFactory.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFactory.java index 8ee0d405..c691d428 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFactory.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFactory.java @@ -3,37 +3,42 @@ import com.jongsoft.finance.learning.config.VectorConfiguration; import com.jongsoft.finance.security.Encryption; import com.jongsoft.lang.Control; + import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Nullable; + import java.nio.file.Files; import java.nio.file.Path; + import javax.sql.DataSource; @Factory public class EmbeddingStoreFactory { - private final VectorConfiguration vectorConfiguration; - private final DataSource dataSource; - private final Encryption encryption; - - public EmbeddingStoreFactory( - VectorConfiguration vectorConfiguration, @Nullable DataSource dataSource) { - this.vectorConfiguration = vectorConfiguration; - this.dataSource = dataSource; - this.encryption = new Encryption(); - } + private final VectorConfiguration vectorConfiguration; + private final DataSource dataSource; + private final Encryption encryption; - public PledgerEmbeddingStore createEmbeddingStore(String purpose) { - if (vectorConfiguration.getStorageType() == VectorConfiguration.StorageType.PGSQL) { - return new ContextAwarePgSQLStore(purpose, dataSource); + public EmbeddingStoreFactory( + VectorConfiguration vectorConfiguration, @Nullable DataSource dataSource) { + this.vectorConfiguration = vectorConfiguration; + this.dataSource = dataSource; + this.encryption = new Encryption(); } - var storagePath = Path.of(vectorConfiguration.getStorage()); - if (!Files.exists(storagePath)) { - Control.Try(() -> Files.createDirectories(storagePath)); - } + public PledgerEmbeddingStore createEmbeddingStore(String purpose) { + if (vectorConfiguration.getStorageType() == VectorConfiguration.StorageType.PGSQL) { + return new ContextAwarePgSQLStore(purpose, dataSource); + } + + var storagePath = Path.of(vectorConfiguration.getStorage()); + if (!Files.exists(storagePath)) { + Control.Try(() -> Files.createDirectories(storagePath)); + } - return new ContextAwareInMemoryStore( - storagePath.resolve(purpose + ".store"), encryption, vectorConfiguration.getPassKey()); - } + return new ContextAwareInMemoryStore( + storagePath.resolve(purpose + ".store"), + encryption, + vectorConfiguration.getPassKey()); + } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFiller.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFiller.java index 02544682..b0d21f01 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFiller.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/EmbeddingStoreFiller.java @@ -7,70 +7,75 @@ import com.jongsoft.finance.messaging.InternalAuthenticationEvent; import com.jongsoft.finance.providers.TransactionProvider; import com.jongsoft.finance.providers.UserProvider; + import io.micronaut.context.event.ApplicationEventPublisher; + import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.function.Consumer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @Singleton public class EmbeddingStoreFiller { - private final Logger logger = LoggerFactory.getLogger(EmbeddingStoreFiller.class); + private final Logger logger = LoggerFactory.getLogger(EmbeddingStoreFiller.class); - private final ApplicationEventPublisher eventPublisher; + private final ApplicationEventPublisher eventPublisher; - private final ExecutorService executorService; - private final FilterFactory filterFactory; - private final UserProvider userProvider; - private final TransactionProvider transactionProvider; - private final List> futures = new ArrayList<>(); + private final ExecutorService executorService; + private final FilterFactory filterFactory; + private final UserProvider userProvider; + private final TransactionProvider transactionProvider; + private final List> futures = new ArrayList<>(); - EmbeddingStoreFiller( - ApplicationEventPublisher eventPublisher, - @LearningExecutor ExecutorService executorService, - FilterFactory filterFactory, - UserProvider userProvider, - TransactionProvider transactionProvider) { - this.eventPublisher = eventPublisher; - this.executorService = executorService; - this.filterFactory = filterFactory; - this.userProvider = userProvider; - this.transactionProvider = transactionProvider; - } + EmbeddingStoreFiller( + ApplicationEventPublisher eventPublisher, + @LearningExecutor ExecutorService executorService, + FilterFactory filterFactory, + UserProvider userProvider, + TransactionProvider transactionProvider) { + this.eventPublisher = eventPublisher; + this.executorService = executorService; + this.filterFactory = filterFactory; + this.userProvider = userProvider; + this.transactionProvider = transactionProvider; + } - public void consumeTransactions(Consumer callback) { - for (var user : userProvider.lookup()) { - futures.add( - executorService.submit(() -> performInitialFill(user.getUsername().email(), callback))); + public void consumeTransactions(Consumer callback) { + for (var user : userProvider.lookup()) { + futures.add(executorService.submit( + () -> performInitialFill(user.getUsername().email(), callback))); + } } - } - public boolean isDone() { - return futures.stream().allMatch(Future::isDone); - } + public boolean isDone() { + return futures.stream().allMatch(Future::isDone); + } - private void performInitialFill(String userId, Consumer callback) { - logger.debug("Indexing transactions for user {}.", userId); - eventPublisher.publishEvent(new InternalAuthenticationEvent(this, userId)); + private void performInitialFill(String userId, Consumer callback) { + logger.debug("Indexing transactions for user {}.", userId); + eventPublisher.publishEvent(new InternalAuthenticationEvent(this, userId)); - try { - var processingPage = 0; - var filterApplied = filterFactory.transaction().ownAccounts().page(processingPage, 500); - ResultPage transactionPage; - do { - transactionPage = transactionProvider.lookup(filterApplied); - transactionPage.content().forEach(callback); - filterApplied.page(++processingPage, 500); - logger.trace("Processed page {} of transactions for user {}.", processingPage, userId); - } while (transactionPage.hasNext()); - } catch (Exception e) { - logger.error("Error indexing transactions for user {}.", userId, e); + try { + var processingPage = 0; + var filterApplied = filterFactory.transaction().ownAccounts().page(processingPage, 500); + ResultPage transactionPage; + do { + transactionPage = transactionProvider.lookup(filterApplied); + transactionPage.content().forEach(callback); + filterApplied.page(++processingPage, 500); + logger.trace( + "Processed page {} of transactions for user {}.", processingPage, userId); + } while (transactionPage.hasNext()); + } catch (Exception e) { + logger.error("Error indexing transactions for user {}.", userId, e); + } + logger.debug("Finished indexing transactions for user {}.", userId); } - logger.debug("Finished indexing transactions for user {}.", userId); - } } diff --git a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/PledgerEmbeddingStore.java b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/PledgerEmbeddingStore.java index 753cf540..487bff03 100644 --- a/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/PledgerEmbeddingStore.java +++ b/learning/learning-module/src/main/java/com/jongsoft/finance/learning/stores/PledgerEmbeddingStore.java @@ -4,10 +4,10 @@ import dev.langchain4j.store.embedding.EmbeddingStore; public interface PledgerEmbeddingStore extends AutoCloseable { - EmbeddingStore embeddingStore(); + EmbeddingStore embeddingStore(); - boolean shouldInitialize(); + boolean shouldInitialize(); - @Override - void close(); + @Override + void close(); } diff --git a/settings.gradle.kts b/settings.gradle.kts index fc5377bf..56179c35 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ pluginManagement { id("org.sonarqube").version("7.1.0.6387") id("org.openapi.generator").version("7.17.0") id("com.diffplug.spotless").version("8.1.0") + id("io.micronaut.openapi").version("4.5.4") id("signing") id("maven-publish") @@ -41,20 +42,21 @@ dependencyResolutionManagement { } create("mn") { - val micronautVersion: String by settings - from("io.micronaut.platform:micronaut-platform:${micronautVersion}") + from("io.micronaut.platform:micronaut-platform:4.9.3") } create("llm") { - val langchain4jVersion: String = "1.5.0" + val langchain4jVersion: String = "1.8.0" + val betaVersion: String = "$langchain4jVersion-beta15" library("core", "dev.langchain4j", "langchain4j").version(langchain4jVersion) - library("retriever-sql", "dev.langchain4j", "langchain4j-pgvector").version("1.3.0-beta9") - library("store", "dev.langchain4j", "langchain4j-embeddings-all-minilm-l6-v2").version("1.3.0-beta9") + library("retriever-sql", "dev.langchain4j", "langchain4j-pgvector").version(betaVersion) + library("store", "dev.langchain4j", "langchain4j-embeddings-all-minilm-l6-v2").version(betaVersion) + library("agentic", "dev.langchain4j", "langchain4j-agentic").version(betaVersion) library("model-openai", "dev.langchain4j", "langchain4j-open-ai").version(langchain4jVersion) library("model-ollama", "dev.langchain4j", "langchain4j-ollama").version(langchain4jVersion) bundle("embeddings", listOf("core", "store", "retriever-sql")) - bundle("langchain4j", listOf("core", "retriever-sql", "store", "model-openai", "model-ollama")) + bundle("langchain4j", listOf("core", "retriever-sql", "store", "agentic", "model-openai", "model-ollama")) } } } @@ -70,4 +72,10 @@ include( "learning:learning-module-spending-patterns", "bpmn-process", "jpa-repository", - "fintrack-api") + "website:rest-api", + "website:learning-rule-api", + "website:runtime-api", + "website:importer-api", + "website:system-api", + "website:budget-api", + "website:application") diff --git a/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/ImporterProvider.java b/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/ImporterProvider.java index 3670bb20..771f4243 100644 --- a/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/ImporterProvider.java +++ b/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/ImporterProvider.java @@ -7,16 +7,16 @@ public interface ImporterProvider { - void readTransactions( - TransactionConsumer consumer, - ImporterConfiguration updatedConfiguration, - BatchImport importJob); + void readTransactions( + TransactionConsumer consumer, + ImporterConfiguration updatedConfiguration, + BatchImport importJob); - T loadConfiguration(BatchImportConfig batchImportConfig); + T loadConfiguration(BatchImportConfig batchImportConfig); - boolean supports(X configuration); + boolean supports(X configuration); - default String getImporterType() { - return this.getClass().getSimpleName(); - } + default String getImporterType() { + return this.getClass().getSimpleName(); + } } diff --git a/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/api/TransactionDTO.java b/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/api/TransactionDTO.java index 89458977..75f23690 100644 --- a/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/api/TransactionDTO.java +++ b/transaction-importer/transaction-importer-api/src/main/java/com/jongsoft/finance/importer/api/TransactionDTO.java @@ -1,44 +1,46 @@ package com.jongsoft.finance.importer.api; import com.jongsoft.finance.core.TransactionType; + import io.micronaut.serde.annotation.Serdeable; + import java.time.LocalDate; import java.util.List; @Serdeable public record TransactionDTO( - // The amount of the transaction - double amount, - // The type of the transaction - TransactionType type, - // The description of the transaction - String description, - // The date of the transaction - LocalDate transactionDate, - // The date the transaction starts to accrue interest - LocalDate interestDate, - // The date the transaction was booked - LocalDate bookDate, - // The IBAN of the opposing account - String opposingIBAN, - // The name of the opposing account - String opposingName, - // Optional: The name of the budget the transaction falls under - String budget, - // Optional: The category of the transaction - String category, - // Optional: The tags of the transaction - List tags) { + // The amount of the transaction + double amount, + // The type of the transaction + TransactionType type, + // The description of the transaction + String description, + // The date of the transaction + LocalDate transactionDate, + // The date the transaction starts to accrue interest + LocalDate interestDate, + // The date the transaction was booked + LocalDate bookDate, + // The IBAN of the opposing account + String opposingIBAN, + // The name of the opposing account + String opposingName, + // Optional: The name of the budget the transaction falls under + String budget, + // Optional: The category of the transaction + String category, + // Optional: The tags of the transaction + List tags) { - @Override - public String toString() { - return "Transfer of " - + amount - + " to " - + opposingName - + " (" - + opposingIBAN - + ") on " - + transactionDate; - } + @Override + public String toString() { + return "Transfer of " + + amount + + " to " + + opposingName + + " (" + + opposingIBAN + + ") on " + + transactionDate; + } } diff --git a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVConfiguration.java b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVConfiguration.java index c97fc60f..1aa467c1 100644 --- a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVConfiguration.java +++ b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVConfiguration.java @@ -2,14 +2,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.jongsoft.finance.importer.api.ImporterConfiguration; + import io.micronaut.serde.annotation.Serdeable; + import java.util.List; @Serdeable public record CSVConfiguration( - @JsonProperty("has-headers") boolean headers, - @JsonProperty("date-format") String dateFormat, - @JsonProperty("delimiter") char delimiter, - @JsonProperty("custom-indicator") TransactionTypeIndicator transactionTypeIndicator, - @JsonProperty("column-roles") List columnRoles) - implements ImporterConfiguration {} + @JsonProperty("has-headers") boolean headers, + @JsonProperty("date-format") String dateFormat, + @JsonProperty("delimiter") String delimiter, + @JsonProperty("custom-indicator") TransactionTypeIndicator transactionTypeIndicator, + @JsonProperty("column-roles") List columnRoles) + implements ImporterConfiguration {} diff --git a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVImportProvider.java b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVImportProvider.java index 2415876b..24311ff4 100644 --- a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVImportProvider.java +++ b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/CSVImportProvider.java @@ -12,9 +12,15 @@ import com.opencsv.CSVParserBuilder; import com.opencsv.CSVReaderBuilder; import com.opencsv.exceptions.CsvValidationException; + import io.micronaut.serde.ObjectMapper; + import jakarta.inject.Inject; import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; @@ -22,133 +28,137 @@ import java.time.format.DateTimeFormatter; import java.util.List; import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @Singleton public class CSVImportProvider implements ImporterProvider { - private final Logger logger = LoggerFactory.getLogger(CSVImportProvider.class); - - private final StorageService storageService; - private final ObjectMapper objectMapper; - - @Inject - public CSVImportProvider(StorageService storageService, ObjectMapper objectMapper) { - this.storageService = storageService; - this.objectMapper = objectMapper; - } - - @Override - public void readTransactions( - TransactionConsumer consumer, ImporterConfiguration configuration, BatchImport importJob) { - logger.info("Reading transactions from CSV file: {}", importJob.getSlug()); - var csvConfiguration = (CSVConfiguration) configuration; - - try { - var inputStream = storageService - .read(importJob.getFileCode()) - .map(ByteArrayInputStream::new) - .map(InputStreamReader::new) - .getOrThrow(() -> - new IllegalStateException("Failed to read CSV file: " + importJob.getFileCode())); - - try (var reader = new CSVReaderBuilder(inputStream) - .withCSVParser( - new CSVParserBuilder().withSeparator(csvConfiguration.delimiter()).build()) - .build()) { - - if (csvConfiguration.headers()) { - logger.debug("CSV file has headers, skipping first line"); - reader.skip(1); + private final Logger logger = LoggerFactory.getLogger(CSVImportProvider.class); + + private final StorageService storageService; + private final ObjectMapper objectMapper; + + @Inject + public CSVImportProvider(StorageService storageService, ObjectMapper objectMapper) { + this.storageService = storageService; + this.objectMapper = objectMapper; + } + + @Override + public void readTransactions( + TransactionConsumer consumer, + ImporterConfiguration configuration, + BatchImport importJob) { + logger.info("Reading transactions from CSV file: {}", importJob.getSlug()); + var csvConfiguration = (CSVConfiguration) configuration; + + try { + var inputStream = storageService + .read(importJob.getFileCode()) + .map(ByteArrayInputStream::new) + .map(InputStreamReader::new) + .getOrThrow(() -> new IllegalStateException( + "Failed to read CSV file: " + importJob.getFileCode())); + + try (var reader = new CSVReaderBuilder(inputStream) + .withCSVParser(new CSVParserBuilder() + .withSeparator(csvConfiguration.delimiter().charAt(0)) + .build()) + .build()) { + + if (csvConfiguration.headers()) { + logger.debug("CSV file has headers, skipping first line"); + reader.skip(1); + } + + String[] line; + while ((line = reader.readNext()) != null) { + if (line.length != csvConfiguration.columnRoles().size()) { + logger.warn( + "Skipping line, columns found {} but expected is {}: {}", + line.length, + csvConfiguration.columnRoles().size(), + line); + continue; + } + + consumer.accept(readLine(line, csvConfiguration)); + } + } + } catch (IOException | CsvValidationException e) { + logger.warn("Failed to read CSV file: {}", importJob.getFileCode(), e); } + } + + @Override + public CSVConfiguration loadConfiguration(BatchImportConfig batchImportConfig) { + logger.debug("Loading CSV configuration from disk: {}", batchImportConfig.getFileCode()); - String[] line; - while ((line = reader.readNext()) != null) { - if (line.length != csvConfiguration.columnRoles().size()) { + try { + var jsonBytes = storageService.read(batchImportConfig.getFileCode()); + if (jsonBytes.isPresent()) { + return objectMapper.readValue(jsonBytes.get(), CSVConfiguration.class); + } + + logger.warn("No CSV configuration found on disk: {}", batchImportConfig.getFileCode()); + throw new IllegalStateException( + "No CSV configuration found on disk: " + batchImportConfig.getFileCode()); + } catch (IOException e) { logger.warn( - "Skipping line, columns found {} but expected is {}: {}", - line.length, - csvConfiguration.columnRoles().size(), - line); - continue; - } - - consumer.accept(readLine(line, csvConfiguration)); + "Could not load CSV configuration from disk: {}", + batchImportConfig.getFileCode(), + e); + throw new IllegalStateException("Failed to load CSV configuration from disk: " + + batchImportConfig.getFileCode()); } - } - } catch (IOException | CsvValidationException e) { - logger.warn("Failed to read CSV file: {}", importJob.getFileCode(), e); } - } - - @Override - public CSVConfiguration loadConfiguration(BatchImportConfig batchImportConfig) { - logger.debug("Loading CSV configuration from disk: {}", batchImportConfig.getFileCode()); - - try { - var jsonBytes = storageService.read(batchImportConfig.getFileCode()); - if (jsonBytes.isPresent()) { - return objectMapper.readValue(jsonBytes.get(), CSVConfiguration.class); - } - - logger.warn("No CSV configuration found on disk: {}", batchImportConfig.getFileCode()); - throw new IllegalStateException( - "No CSV configuration found on disk: " + batchImportConfig.getFileCode()); - } catch (IOException e) { - logger.warn( - "Could not load CSV configuration from disk: {}", batchImportConfig.getFileCode(), e); - throw new IllegalStateException( - "Failed to load CSV configuration from disk: " + batchImportConfig.getFileCode()); + + @Override + public boolean supports(X configuration) { + return configuration instanceof CSVConfiguration; + } + + private TransactionDTO readLine(String[] line, CSVConfiguration configuration) { + Function columnLocator = + (role) -> Control.Try(() -> line[configuration.columnRoles().indexOf(role)]) + .recover(_ -> null) + .get(); + Function parseDate = (date) -> date != null + ? LocalDate.parse(date, DateTimeFormatter.ofPattern(configuration.dateFormat())) + : null; + Function parseAmount = + (amount) -> Double.parseDouble(amount.replace(',', '.')); + + var amount = parseAmount.apply(columnLocator.apply(ColumnRole.AMOUNT)); + var type = Control.Option(columnLocator.apply(ColumnRole.CUSTOM_INDICATOR)) + .map(indicator -> { + if (indicator.equalsIgnoreCase( + configuration.transactionTypeIndicator().credit())) { + return TransactionType.CREDIT; + } else if (indicator.equalsIgnoreCase( + configuration.transactionTypeIndicator().deposit())) { + return TransactionType.DEBIT; + } + + return null; + }) + .getOrSupply(() -> amount >= 0 ? TransactionType.DEBIT : TransactionType.CREDIT); + + logger.trace( + "Reading single transaction on {}: amount={}, type={}", + parseDate.apply(columnLocator.apply(ColumnRole.DATE)), + amount, + type); + + return new TransactionDTO( + amount, + type, + columnLocator.apply(ColumnRole.DESCRIPTION), + parseDate.apply(columnLocator.apply(ColumnRole.DATE)), + parseDate.apply(columnLocator.apply(ColumnRole.INTEREST_DATE)), + parseDate.apply(columnLocator.apply(ColumnRole.BOOK_DATE)), + columnLocator.apply(ColumnRole.OPPOSING_IBAN), + columnLocator.apply(ColumnRole.OPPOSING_NAME), + columnLocator.apply(ColumnRole.BUDGET), + columnLocator.apply(ColumnRole.CATEGORY), + List.of()); } - } - - @Override - public boolean supports(X configuration) { - return configuration instanceof CSVConfiguration; - } - - private TransactionDTO readLine(String[] line, CSVConfiguration configuration) { - Function columnLocator = - (role) -> Control.Try(() -> line[configuration.columnRoles().indexOf(role)]) - .recover(x -> null) - .get(); - Function parseDate = (date) -> date != null - ? LocalDate.parse(date, DateTimeFormatter.ofPattern(configuration.dateFormat())) - : null; - Function parseAmount = (amount) -> Double.parseDouble(amount.replace(',', '.')); - - var amount = parseAmount.apply(columnLocator.apply(ColumnRole.AMOUNT)); - var type = Control.Option(columnLocator.apply(ColumnRole.CUSTOM_INDICATOR)) - .map(indicator -> { - if (indicator.equalsIgnoreCase( - configuration.transactionTypeIndicator().credit())) { - return TransactionType.CREDIT; - } else if (indicator.equalsIgnoreCase( - configuration.transactionTypeIndicator().deposit())) { - return TransactionType.DEBIT; - } - - return null; - }) - .getOrSupply(() -> amount >= 0 ? TransactionType.DEBIT : TransactionType.CREDIT); - - logger.trace( - "Reading single transaction on {}: amount={}, type={}", - parseDate.apply(columnLocator.apply(ColumnRole.DATE)), - amount, - type); - - return new TransactionDTO( - amount, - type, - columnLocator.apply(ColumnRole.DESCRIPTION), - parseDate.apply(columnLocator.apply(ColumnRole.DATE)), - parseDate.apply(columnLocator.apply(ColumnRole.INTEREST_DATE)), - parseDate.apply(columnLocator.apply(ColumnRole.BOOK_DATE)), - columnLocator.apply(ColumnRole.OPPOSING_IBAN), - columnLocator.apply(ColumnRole.OPPOSING_NAME), - columnLocator.apply(ColumnRole.BUDGET), - columnLocator.apply(ColumnRole.CATEGORY), - List.of()); - } } diff --git a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/ColumnRole.java b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/ColumnRole.java index 839c8aab..d4df4125 100644 --- a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/ColumnRole.java +++ b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/ColumnRole.java @@ -1,36 +1,36 @@ package com.jongsoft.finance.importer.csv; public enum ColumnRole { - IGNORE("_ignore"), - DATE("transaction-date"), - BOOK_DATE("booking-date"), - INTEREST_DATE("interest-date"), - OPPOSING_NAME("opposing-name"), - OPPOSING_IBAN("opposing-iban"), - ACCOUNT_IBAN("account-iban"), - AMOUNT("amount"), - CUSTOM_INDICATOR("custom-indicator"), - TAGS("tags"), - BUDGET("budget"), - CATEGORY("category"), - DESCRIPTION("description"); + IGNORE("_ignore"), + DATE("transaction-date"), + BOOK_DATE("booking-date"), + INTEREST_DATE("interest-date"), + OPPOSING_NAME("opposing-name"), + OPPOSING_IBAN("opposing-iban"), + ACCOUNT_IBAN("account-iban"), + AMOUNT("amount"), + CUSTOM_INDICATOR("custom-indicator"), + TAGS("tags"), + BUDGET("budget"), + CATEGORY("category"), + DESCRIPTION("description"); - private final String label; + private final String label; - ColumnRole(String label) { - this.label = label; - } + ColumnRole(String label) { + this.label = label; + } - public String getLabel() { - return label; - } + public String getLabel() { + return label; + } - public static ColumnRole value(String source) { - for (ColumnRole role : values()) { - if (role.label.equalsIgnoreCase(source)) { - return role; - } + public static ColumnRole value(String source) { + for (ColumnRole role : values()) { + if (role.label.equalsIgnoreCase(source)) { + return role; + } + } + throw new IllegalStateException("No mapping role found for " + source); } - throw new IllegalStateException("No mapping role found for " + source); - } } diff --git a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/TransactionTypeIndicator.java b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/TransactionTypeIndicator.java index 4c409ef0..3d16175d 100644 --- a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/TransactionTypeIndicator.java +++ b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/TransactionTypeIndicator.java @@ -1,8 +1,9 @@ package com.jongsoft.finance.importer.csv; import com.fasterxml.jackson.annotation.JsonProperty; + import io.micronaut.serde.annotation.Serdeable; @Serdeable public record TransactionTypeIndicator( - @JsonProperty("deposit") String deposit, @JsonProperty("credit") String credit) {} + @JsonProperty("deposit") String deposit, @JsonProperty("credit") String credit) {} diff --git a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/serde/ColumnRoleSerde.java b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/serde/ColumnRoleSerde.java index a8a46012..b7ef2512 100644 --- a/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/serde/ColumnRoleSerde.java +++ b/transaction-importer/transaction-importer-csv/src/main/java/com/jongsoft/finance/importer/csv/serde/ColumnRoleSerde.java @@ -1,29 +1,32 @@ package com.jongsoft.finance.importer.csv.serde; import com.jongsoft.finance.importer.csv.ColumnRole; + import io.micronaut.core.type.Argument; import io.micronaut.serde.Decoder; import io.micronaut.serde.Encoder; import io.micronaut.serde.Serde; + import jakarta.inject.Singleton; + import java.io.IOException; @Singleton class ColumnRoleSerde implements Serde { - @Override - public ColumnRole deserialize( - Decoder decoder, DecoderContext context, Argument type) - throws IOException { - return ColumnRole.value(decoder.decodeString()); - } + @Override + public ColumnRole deserialize( + Decoder decoder, DecoderContext context, Argument type) + throws IOException { + return ColumnRole.value(decoder.decodeString()); + } - @Override - public void serialize( - Encoder encoder, - EncoderContext context, - Argument type, - ColumnRole value) - throws IOException { - encoder.encodeString(value.getLabel()); - } + @Override + public void serialize( + Encoder encoder, + EncoderContext context, + Argument type, + ColumnRole value) + throws IOException { + encoder.encodeString(value.getLabel()); + } } diff --git a/transaction-importer/transaction-importer-csv/src/test/java/com/jongsoft/finance/importer/csv/CSVImportProviderTest.java b/transaction-importer/transaction-importer-csv/src/test/java/com/jongsoft/finance/importer/csv/CSVImportProviderTest.java index 994c60e6..32fb582c 100644 --- a/transaction-importer/transaction-importer-csv/src/test/java/com/jongsoft/finance/importer/csv/CSVImportProviderTest.java +++ b/transaction-importer/transaction-importer-csv/src/test/java/com/jongsoft/finance/importer/csv/CSVImportProviderTest.java @@ -62,7 +62,7 @@ void loadConfiguration() throws IOException { .isNotNull() .extracting(CSVConfiguration::delimiter, CSVConfiguration::headers, CSVConfiguration::dateFormat, CSVConfiguration::transactionTypeIndicator, CSVConfiguration::columnRoles) .isEqualTo(List.of( - ',', + ",", true, "yyyyMMdd", new TransactionTypeIndicator("Bij", "Af"), @@ -148,4 +148,4 @@ private BatchImportConfig createBatchImportConfig() { .fileCode("my-secret-files") .build(); } -} \ No newline at end of file +} diff --git a/website/application/build.gradle.kts b/website/application/build.gradle.kts new file mode 100644 index 00000000..18f5d048 --- /dev/null +++ b/website/application/build.gradle.kts @@ -0,0 +1,105 @@ +plugins { + id("io.micronaut.application") + id("io.micronaut.openapi") +} + +micronaut { + runtime("jetty") + testRuntime("junit5") + + processing { + incremental(true) + } +} + + +dependencies { + annotationProcessor(mn.micronaut.openapi.asProvider()) + compileOnly(mn.micronaut.openapi.annotations) + + // Security setup + implementation(mn.micronaut.security.annotations) + implementation(mn.micronaut.security) + + implementation(mn.micronaut.serde.api) + implementation(mn.validation) + + implementation(mn.micronaut.micrometer.core) + implementation(mn.micronaut.micrometer.registry.prometheus) + implementation(mn.hibernate.micrometer) + + // Email dependencies + implementation(mn.micronaut.email.javamail) + implementation(mn.micronaut.email.template) + implementation(mn.micronaut.views.velocity) + + // Http Server + implementation(mn.micronaut.http.server.jetty) + implementation(mn.micronaut.http.validation) + implementation(mn.micronaut.hibernate.validator) + + implementation(project(":core")) + implementation(project(":domain")) + + // Web layer dependencies + implementation(project(":website:system-api")) + implementation(project(":website:runtime-api")) + implementation(project(":website:budget-api")) + implementation(project(":website:rest-api")) + implementation(project(":website:importer-api")) + implementation(project(":website:learning-rule-api")) + + runtimeOnly(mn.logback.classic) + runtimeOnly(mn.micronaut.serde.jackson) + runtimeOnly(mn.micronaut.jackson.databind) + + // Contains the health checker + implementation(mn.micronaut.management) + + implementation(project(":jpa-repository")) + implementation(project(":bpmn-process")) + + // Libraries for running analysis and transaction corrections + implementation(project(":learning:learning-module")) + implementation(project(":learning:learning-module-rules")) + implementation(project(":learning:learning-module-llm")) + implementation(project(":learning:learning-module-spending-patterns")) + + implementation(project(":transaction-importer:transaction-importer-api")) + implementation(project(":transaction-importer:transaction-importer-csv")) +} + +tasks.processResources { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + from("../budget-api/src/contract") { + into("META-INF/swagger") + exclude("*-api.yaml") + } + from("../importer-api/src/contract") { + into("META-INF/swagger") + exclude("*-api.yaml") + } + from("../system-api/src/contract") { + into("META-INF/swagger") + exclude("*-api.yaml") + } + from("../runtime-api/src/contract") { + into("META-INF/swagger") + exclude("*-api.yaml") + } + from("../learning-rule-api/src/contract") { + into("META-INF/swagger") + exclude("*-api.yaml") + } + from("../rest-api/src/contract") { + into("META-INF/swagger") + exclude("*-api.yaml") + } + + filesMatching("**/micronaut-banner.txt") { + filter { line -> + var updated = line.replace("\${application.version}", project.version.toString()) + updated.replace("\${micronaut.version}", properties.get("micronautVersion").toString()) + } + } +} diff --git a/website/application/openapi.properties b/website/application/openapi.properties new file mode 100644 index 00000000..8e83714e --- /dev/null +++ b/website/application/openapi.properties @@ -0,0 +1,7 @@ +micronaut.openapi.openapi31.enabled=true +micronaut.openapi.additional.files=\ + ../system-api/src/contract/system-api.yaml,\ + ../runtime-api/src/contract/runtime-api.yaml,\ + ../rest-api/src/contract/rest-api.yaml,\ + ../importer-api/src/contract/importer-api.yaml,\ + ../budget-api/src/contract/budget-api.yaml diff --git a/website/application/src/main/java/com/jongsoft/finance/Pledger.java b/website/application/src/main/java/com/jongsoft/finance/Pledger.java new file mode 100644 index 00000000..eacd68d7 --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/Pledger.java @@ -0,0 +1,37 @@ +package com.jongsoft.finance; + +import io.micronaut.runtime.Micronaut; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; + +@SecurityScheme( + name = "bearer", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer") +@OpenAPIDefinition( + security = @SecurityRequirement(name = "bearer"), + info = + @Info( + title = "Pledger.io", + version = "4.0.0", + description = + "Pledger.io is a self-hosted personal finance application that" + + " helps you track your income and expenses.", + license = + @License(name = "MIT", url = "https://opensource.org/licenses/MIT"), + contact = + @Contact( + name = "Jong Soft Development", + url = "https://github.com/pledger-io/rest-application"))) +public class Pledger { + public static void main(String[] args) { + Micronaut.run(Pledger.class, args); + System.exit(0); + } +} diff --git a/fintrack-api/src/main/java/com/jongsoft/finance/factory/EventBusFactory.java b/website/application/src/main/java/com/jongsoft/finance/config/EventBusFactory.java similarity index 50% rename from fintrack-api/src/main/java/com/jongsoft/finance/factory/EventBusFactory.java rename to website/application/src/main/java/com/jongsoft/finance/config/EventBusFactory.java index 0bed767f..22e12131 100644 --- a/fintrack-api/src/main/java/com/jongsoft/finance/factory/EventBusFactory.java +++ b/website/application/src/main/java/com/jongsoft/finance/config/EventBusFactory.java @@ -1,20 +1,22 @@ -package com.jongsoft.finance.factory; +package com.jongsoft.finance.config; import com.jongsoft.finance.messaging.EventBus; + import io.micronaut.context.annotation.Context; import io.micronaut.context.annotation.Factory; import io.micronaut.context.event.ApplicationEventPublisher; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @Factory public class EventBusFactory { - private final Logger log = LoggerFactory.getLogger(EventBusFactory.class); + private final Logger logger = LoggerFactory.getLogger(EventBusFactory.class); - @Context - public EventBus eventBus(ApplicationEventPublisher eventPublisher) { - log.info("Staring the event bus"); - return new EventBus(eventPublisher); - } + @Context + public EventBus eventBus(ApplicationEventPublisher eventPublisher) { + logger.info("Starting the event bus"); + return new EventBus(eventPublisher); + } } diff --git a/website/application/src/main/java/com/jongsoft/finance/config/MailDaemonFactory.java b/website/application/src/main/java/com/jongsoft/finance/config/MailDaemonFactory.java new file mode 100644 index 00000000..b506412c --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/config/MailDaemonFactory.java @@ -0,0 +1,66 @@ +package com.jongsoft.finance.config; + +import com.jongsoft.finance.core.MailDaemon; + +import io.micronaut.context.annotation.*; +import io.micronaut.email.*; +import io.micronaut.email.javamail.sender.JavaxEmailComposer; +import io.micronaut.email.javamail.sender.JavaxEmailSender; +import io.micronaut.email.template.TemplateBody; +import io.micronaut.scheduling.TaskExecutors; +import io.micronaut.views.ModelAndView; + +import jakarta.inject.Named; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ExecutorService; + +@Factory +public class MailDaemonFactory { + + private final Logger logger = LoggerFactory.getLogger(MailDaemonFactory.class); + + @Context + @Primary + @Requirements(@Requires(env = "smtp")) + public TransactionalEmailSender createTransactionSender( + @Named(TaskExecutors.IO) ExecutorService executorService, + JavaxEmailComposer javaxEmailComposer) { + return new JavaxEmailSender(executorService, javaxEmailComposer); + } + + @Context + public MailDaemon createMailDaemon() { + logger.info("Starting a mock mail daemon"); + return (recipient, _, _) -> logger.info("Sending email to {}", recipient); + } + + @Context + @Requirements({ + @Requires(property = "application.mail", notEquals = "mock"), + }) + @Replaces(MailDaemon.class) + public MailDaemon createMailDaemon( + @Value("${application.mail}") String mailImplementation, + EmailSender customMailer) { + logger.info("Starting a real mail daemon using {}", mailImplementation); + return (recipient, template, mailProperties) -> { + logger.debug("Sending email to {}", recipient); + + var email = Email.builder() + .to(recipient) + .subject("Pleger.io: Welcome to the family!") + .body(new MultipartBody( + new TemplateBody<>( + BodyType.HTML, + new ModelAndView<>(template + ".html", mailProperties)), + new TemplateBody<>( + BodyType.TEXT, + new ModelAndView<>(template + ".text", mailProperties)))); + + customMailer.send(email); + }; + } +} diff --git a/website/application/src/main/java/com/jongsoft/finance/exception/StatusExceptionHandler.java b/website/application/src/main/java/com/jongsoft/finance/exception/StatusExceptionHandler.java new file mode 100644 index 00000000..82fd27eb --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/exception/StatusExceptionHandler.java @@ -0,0 +1,51 @@ +package com.jongsoft.finance.exception; + +import com.jongsoft.finance.core.exception.StatusException; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.hateoas.JsonError; +import io.micronaut.http.hateoas.Link; +import io.micronaut.http.server.exceptions.ExceptionHandler; + +import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Produces +@Singleton +public class StatusExceptionHandler + implements ExceptionHandler> { + + private final Logger log = LoggerFactory.getLogger(StatusExceptionHandler.class); + + @Override + public HttpResponse handle(HttpRequest request, StatusException exception) { + if (exception.getStatusCode() != 404) { + log.warn( + "{}: {} - Resource requested status {} with message: '{}'", + request.getMethod(), + request.getPath(), + exception.getStatusCode(), + exception.getMessage()); + } else { + log.trace( + "{}: {} - Resource not found on server.", + request.getMethod(), + request.getPath()); + } + + var error = new JsonError(exception.getMessage()); + error.link(Link.SELF, Link.of(request.getUri())); + + if (exception.getLocalizationMessage() != null) { + error.link(Link.HELP, exception.getLocalizationMessage()); + } + + return HttpResponse.status(HttpStatus.valueOf(exception.getStatusCode())) + .body(error); + } +} diff --git a/website/application/src/main/java/com/jongsoft/finance/http/ReactController.java b/website/application/src/main/java/com/jongsoft/finance/http/ReactController.java new file mode 100644 index 00000000..abf71475 --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/http/ReactController.java @@ -0,0 +1,94 @@ +package com.jongsoft.finance.http; + +import io.micronaut.core.io.ResourceResolver; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.server.types.files.StreamedFile; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +@Controller("/ui") +@Secured(SecurityRule.IS_ANONYMOUS) +public class ReactController { + + private final ResourceResolver resourceResolver; + + public ReactController(ResourceResolver resourceResolver) { + this.resourceResolver = resourceResolver; + } + + /** + * Serves the React app's index.html file. + * + * @return The index.html file + */ + @Get(produces = MediaType.TEXT_HTML) + public HttpResponse index() { + Optional indexHtml = + resourceResolver.getResourceAsStream("classpath:public/index.html"); + if (indexHtml.isPresent()) { + return HttpResponse.ok(new StreamedFile(indexHtml.get(), MediaType.TEXT_HTML_TYPE)); + } else { + return HttpResponse.notFound("React app not found"); + } + } + + /** + * Catch-all route to serve the React app for any path under /react/. This allows the React + * Router to handle client-side routing properly. + * + * @return The index.html file + */ + @Get(uri = "/{path:.*}", produces = MediaType.TEXT_HTML) + public HttpResponse catchAll(String path) { + return index(); + } + + @Get(uri = "/favicon.ico") + public HttpResponse favicon() { + return loadResource("favicon.ico"); + } + + @Get(uri = "/manifest.json") + public HttpResponse manifest() { + return loadResource("manifest.json"); + } + + @Get(uri = "/logo192.png") + public HttpResponse logo() { + return loadResource("logo192.png"); + } + + @Get(uri = "/logo512.png") + public HttpResponse logo_512() { + return loadResource("logo512.png"); + } + + @Get(uri = "/assets/{path:.*}") + public HttpResponse getAsset(String path) { + return loadResource("assets/" + path); + } + + @Get(uri = "/images/{path:.*}") + public HttpResponse getImage(String path) { + return loadResource("images/" + path); + } + + private HttpResponse loadResource(String path) { + var assetFile = resourceResolver.getResource("classpath:public/" + path); + if (assetFile.isPresent()) { + var streamedFile = new StreamedFile(assetFile.get()); + return HttpResponse.ok(streamedFile.getInputStream()) + .contentType(streamedFile.getMediaType()) + .characterEncoding(StandardCharsets.UTF_8); + } else { + return HttpResponse.notFound("React app not found"); + } + } +} diff --git a/website/application/src/main/java/com/jongsoft/finance/http/StaticController.java b/website/application/src/main/java/com/jongsoft/finance/http/StaticController.java new file mode 100644 index 00000000..355c06d5 --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/http/StaticController.java @@ -0,0 +1,41 @@ +package com.jongsoft.finance.http; + +import io.micronaut.core.io.ResourceResolver; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.server.types.files.StreamedFile; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; + +import jakarta.inject.Inject; + +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +@Controller +@Secured(SecurityRule.IS_ANONYMOUS) +public class StaticController { + + @Inject + ResourceResolver resourceResolver; + + @Get + public HttpResponse index() throws URISyntaxException { + return HttpResponse.redirect(new URI("/ui/dashboard")); + } + + @Get("/favicon.ico") + public HttpResponse favicon() { + Optional indexHtml = + resourceResolver.getResourceAsStream("classpath:public/assets/favicon.ico"); + if (indexHtml.isPresent()) { + return HttpResponse.ok(new StreamedFile(indexHtml.get(), MediaType.IMAGE_X_ICON_TYPE)); + } else { + return HttpResponse.notFound("Favicon not found"); + } + } +} diff --git a/website/application/src/main/java/com/jongsoft/finance/http/filter/CorrelationIdFilter.java b/website/application/src/main/java/com/jongsoft/finance/http/filter/CorrelationIdFilter.java new file mode 100644 index 00000000..8297301f --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/http/filter/CorrelationIdFilter.java @@ -0,0 +1,35 @@ +package com.jongsoft.finance.http.filter; + +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.filter.ServerFilterPhase; + +import org.reactivestreams.Publisher; +import org.slf4j.MDC; + +import java.util.UUID; + +@Filter("/v2/api/**") +public class CorrelationIdFilter implements HttpServerFilter { + + @Override + public int getOrder() { + return ServerFilterPhase.FIRST.order(); + } + + @Override + public Publisher> doFilter( + HttpRequest request, ServerFilterChain chain) { + if (request.getHeaders().contains("X-Correlation-Id")) { + MDC.put("correlationId", request.getHeaders().get("X-Correlation-Id")); + } else { + MDC.put("correlationId", UUID.randomUUID().toString()); + } + + return Publishers.then(chain.proceed(request), _ -> MDC.remove("correlationId")); + } +} diff --git a/website/application/src/main/java/com/jongsoft/finance/http/filter/RequestLoggingFilter.java b/website/application/src/main/java/com/jongsoft/finance/http/filter/RequestLoggingFilter.java new file mode 100644 index 00000000..a771b490 --- /dev/null +++ b/website/application/src/main/java/com/jongsoft/finance/http/filter/RequestLoggingFilter.java @@ -0,0 +1,48 @@ +package com.jongsoft.finance.http.filter; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.filter.ServerFilterPhase; + +import org.reactivestreams.Publisher; + +import java.time.Instant; + +@Filter("/v2/api/**") +public class RequestLoggingFilter implements HttpServerFilter { + + private final MeterRegistry meterRegistry; + + public RequestLoggingFilter(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public int getOrder() { + return ServerFilterPhase.METRICS.order(); + } + + @Override + public Publisher> doFilter( + HttpRequest request, ServerFilterChain chain) { + var startTime = Instant.now(); + + return Publishers.then(chain.proceed(request), response -> { + var endTime = Instant.now(); + var duration = endTime.toEpochMilli() - startTime.toEpochMilli(); + + Timer.builder("http.custom.requests") + .tag("method", request.getMethod().name()) + .tag("status", String.valueOf(response.getStatus().getCode())) + .tag("path", request.getPath()) + .register(meterRegistry) + .record(java.time.Duration.ofMillis(duration)); + }); + } +} diff --git a/website/application/src/main/resources/application-openid.properties b/website/application/src/main/resources/application-openid.properties new file mode 100644 index 00000000..f5f65ff8 --- /dev/null +++ b/website/application/src/main/resources/application-openid.properties @@ -0,0 +1,6 @@ +micronaut.security.token.jwt.signatures.jwks.keycloak.key-type=RSA +micronaut.security.token.jwt.signatures.jwks.keycloak.url=${OPENID_URI} + +application.openid.client-id=${OPENID_CLIENT:pledger-io} +application.openid.client-secret=${OPENID_SECRET:-} +application.openid.authority=${OPENID_AUTHORITY:-} diff --git a/website/application/src/main/resources/application-smtp.properties b/website/application/src/main/resources/application-smtp.properties new file mode 100644 index 00000000..1c07f4bb --- /dev/null +++ b/website/application/src/main/resources/application-smtp.properties @@ -0,0 +1,9 @@ +application.mail=smtp + +javamail.authentication.username=${SMTP_USER} +javamail.authentication.password=${SMTP_PASSWORD} + +javamail.properties.mail.smtp.host=${SMTP_HOST} +javamail.properties.mail.smtp.port=587 +javamail.properties.mail.smtp.auth=true +javamail.properties.mail.smtp.starttls.enable=true diff --git a/website/application/src/main/resources/application.properties b/website/application/src/main/resources/application.properties new file mode 100644 index 00000000..62b4f891 --- /dev/null +++ b/website/application/src/main/resources/application.properties @@ -0,0 +1,52 @@ +micronaut.application.name=Pledger.io +micronaut.application.storage.location=${java.io.tmpdir} + +# Security configuration +micronaut.security.authentication=bearer +micronaut.security.token.jwt.enabled=true +micronaut.security.endpoints.logout.enabled=true +micronaut.security.endpoints.logout.get-allowed=true +micronaut.security.endpoints.login.enabled=true +micronaut.security.endpoints.oauth.enabled=true + +micronaut.security.endpoints.oauth.path=/v2/api/security/oauth +micronaut.security.endpoints.login.path=/v2/api/security/authenticate +micronaut.security.endpoints.logout.path=/v2/api/security/logout + +## Security secret and encryption +micronaut.application.security.secret=MyLittleSecret +micronaut.application.security.encrypt=true + +# Setup static resources +micronaut.router.static-resources.docs.paths=classpath:docs +micronaut.router.static-resources.docs.mapping=/openapi/** +micronaut.router.static-resources.swagger.paths=classpath:META-INF/swagger +micronaut.router.static-resources.swagger.mapping=/spec/** + +# Upload configuration +micronaut.server.multipart.enabled=true +micronaut.server.multipart.location=${micronaut.application.storage.location}/temp +micronaut.server.multipart.max-file-size=20971520 + +# Thread pool configuration +micronaut.executors.io.type=fixed +micronaut.executors.io.n-threads=50 + +# Email configuration +application.mail=mock + +# AI vectors configuration +application.ai.vectors.storageType=memory +application.ai.vectors.pass-key=E5MC00ZWUxLWJiMm +application.ai.vectors.storage=${micronaut.application.storage.location}/vector_stores + +# Metric configuration +micronaut.metrics.export.prometheus.enabled=true +micronaut.metrics.export.prometheus.step=PT1M +micronaut.metrics.export.prometheus.descriptions=true + +# Metric endpoints +endpoints.metrics.enabled=true +endpoints.metrics.sensitive=false +endpoints.prometheus.enabled=true +endpoints.prometheus.sensitive=false diff --git a/fintrack-api/src/main/resources/docs/index.html b/website/application/src/main/resources/docs/index.html similarity index 97% rename from fintrack-api/src/main/resources/docs/index.html rename to website/application/src/main/resources/docs/index.html index b4d43aa1..c4a68d98 100644 --- a/fintrack-api/src/main/resources/docs/index.html +++ b/website/application/src/main/resources/docs/index.html @@ -10,7 +10,7 @@ + Retrieve budget periods for the authenticated user. You can filter by + year and/or month. When firstOnly is true, only the first matching + budget period is returned. + parameters: + - name: year + required: false + description: The year to get the budget for + in: query + schema: { type: int } + - name: month + required: false + description: The month to get the budget for + in: query + schema: + type: int + min: 1 + max: 12 + - name: firstOnly + description: If true, only the first budget will be returned. + in: query + schema: { type: boolean } + responses: + "200": + description: The list of matching budget periods + content: + application/json: + schema: { $ref: '#/components/schemas/budget-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createInitialBudget + tags: [ budget-command ] + summary: Create budget + description: > + Create the initial budget for the current user. This + can only be called once if no budget exists for the + current user. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/budget-request' } + responses: + "201": + description: The created budget + content: + application/json: + schema: { $ref: '#/components/schemas/budget-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + patch: + operationId: updateCurrentBudget + tags: [ budget-command ] + summary: Update budget + description: > + Update the current budget for the current user. This operation will also + index all expenses based upon the difference between the previous expected income + and the new expected income. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/budget-request' } + responses: + "200": + description: The created budget + content: + application/json: + schema: { $ref: '#/components/schemas/budget-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + /v2/api/budgets/expenses: + get: + operationId: findExpensesByFilter + tags: [ budget-fetcher ] + summary: Search expenses + description: > + Search expenses within the currently active budget for the authenticated + user. You can provide a partial name to filter the results. Returns the + matching expenses. + parameters: + - name: name + description: Filter expenses based upon the partial name + in: query + schema: { type: string } + responses: + "200": + description: The found expenses + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/expense-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + patch: + operationId: updateExpense + tags: [ budget-command ] + summary: Update expense + description: > + Create or update an expense under the current active budget. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/expense-request' } + responses: + "200": + description: The updated budget + content: + application/json: + schema: { $ref: '#/components/schemas/budget-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + /v2/api/budgets/expenses/balance: + get: + operationId: computeBudgetExpenseBalance + tags: [ budget-fetcher ] + summary: Compute expense balance + description: > + Compute the budget-vs-spending balance for one month of the authenticated + user. Provide year and month to select the period. Optionally pass one or + more expenseId values to limit the calculation to specific expenses. + Returns the computed balance per expense for the selected period. + parameters: + - name: year + required: true + description: The year to get the budget for + in: query + schema: { type: int } + - name: month + required: true + description: The month to get the budget for + in: query + schema: + type: int + min: 1 + max: 12 + - name: expenseId + description: Optional list of expense identifiers to filter on + in: query + schema: + type: array + items: + type: integer + format: int64 + responses: + "200": + description: The current balance + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/computed-expense-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + +components: + securitySchemes: + bearer: + type: http + scheme: bearer + bearerFormat: JWT + + responses: + 400: { $ref: './responses/400-response.yaml' } + 401: { $ref: './responses/401-response.yaml' } + 403: { $ref: './responses/403-response.yaml' } + 404: { $ref: './responses/404-response.yaml' } + + parameters: + # date range related parameters + start-date: { $ref: 'parameters/startDate.yaml' } + end-date: { $ref: 'parameters/endDate.yaml' } + + schemas: + budget-request: { $ref: 'components/requests/budget.yaml' } + expense-request: { $ref: 'components/requests/expense.yaml' } + date-range: { $ref: 'components/date-range.yaml' } + json-error-response: { $ref: 'components/responses/json-error.yaml' } + budget-response: { $ref: 'components/responses/budget.yaml' } + expense-response: { $ref: 'components/responses/expense.yaml' } + computed-expense-response: { $ref: 'components/responses/computed-expense.yaml' } diff --git a/website/budget-api/src/contract/components/date-range.yaml b/website/budget-api/src/contract/components/date-range.yaml new file mode 100644 index 00000000..fa281b8d --- /dev/null +++ b/website/budget-api/src/contract/components/date-range.yaml @@ -0,0 +1,11 @@ +type: object +required: + - startDate + - endDate +properties: + startDate: + type: string + format: date + endDate: + type: string + format: date diff --git a/website/budget-api/src/contract/components/requests/budget.yaml b/website/budget-api/src/contract/components/requests/budget.yaml new file mode 100644 index 00000000..91ef09a4 --- /dev/null +++ b/website/budget-api/src/contract/components/requests/budget.yaml @@ -0,0 +1,18 @@ +type: object +required: + - year + - month + - income +properties: + year: + type: integer + min: 1900 + max: 9999 + month: + type: integer + min: 1 + max: 12 + income: + type: number + format: double + minimum: 0 diff --git a/website/budget-api/src/contract/components/requests/expense.yaml b/website/budget-api/src/contract/components/requests/expense.yaml new file mode 100644 index 00000000..115a5352 --- /dev/null +++ b/website/budget-api/src/contract/components/requests/expense.yaml @@ -0,0 +1,18 @@ +type: object +required: + - name + - amount +properties: + id: + type: integer + format: int64 + description: > + The unique identifier for the expense. + Only fill when updating an existing expense. + name: + type: string + minLength: 1 + amount: + type: number + format: double + minimum: 0 diff --git a/website/budget-api/src/contract/components/responses/budget.yaml b/website/budget-api/src/contract/components/responses/budget.yaml new file mode 100644 index 00000000..27ab29e5 --- /dev/null +++ b/website/budget-api/src/contract/components/responses/budget.yaml @@ -0,0 +1,14 @@ +type: object +required: + - income + - period + - expenses +properties: + income: + type: number + period: + $ref: '#/components/schemas/date-range' + expenses: + type: array + items: + $ref: '#/components/schemas/expense-response' diff --git a/website/budget-api/src/contract/components/responses/computed-expense.yaml b/website/budget-api/src/contract/components/responses/computed-expense.yaml new file mode 100644 index 00000000..a64e6237 --- /dev/null +++ b/website/budget-api/src/contract/components/responses/computed-expense.yaml @@ -0,0 +1,28 @@ +type: object +required: + - id + - left + - dailyLeft + - spent + - dailySpent +properties: + id: + description: The identifier of the expense + type: integer + format: int64 + left: + description: The amount of money left to spend on the expense + type: number + format: double + dailyLeft: + description: The amount of money left to spend on the expense each day + type: number + format: double + spent: + description: The amount of money spent on the expense + type: number + format: double + dailySpent: + description: The amount of money spent on the expense each day + type: number + format: double diff --git a/website/budget-api/src/contract/components/responses/expense.yaml b/website/budget-api/src/contract/components/responses/expense.yaml new file mode 100644 index 00000000..54432f70 --- /dev/null +++ b/website/budget-api/src/contract/components/responses/expense.yaml @@ -0,0 +1,13 @@ +type: object +required: + - id + - name + - expected +properties: + id: + type: integer + format: int64 + name: + type: string + expected: + type: number diff --git a/website/budget-api/src/contract/components/responses/json-error.yaml b/website/budget-api/src/contract/components/responses/json-error.yaml new file mode 100644 index 00000000..685de80e --- /dev/null +++ b/website/budget-api/src/contract/components/responses/json-error.yaml @@ -0,0 +1,15 @@ +type: object +required: + - message +properties: + _links: + type: object + additionalProperties: true + message: + type: string + logref: + type: string + nullable: true + path: + type: string + nullable: true diff --git a/website/budget-api/src/contract/parameters/endDate.yaml b/website/budget-api/src/contract/parameters/endDate.yaml new file mode 100644 index 00000000..d9879871 --- /dev/null +++ b/website/budget-api/src/contract/parameters/endDate.yaml @@ -0,0 +1,5 @@ +name: endDate +in: query +schema: + type: string + format: date diff --git a/website/budget-api/src/contract/parameters/startDate.yaml b/website/budget-api/src/contract/parameters/startDate.yaml new file mode 100644 index 00000000..31c4f0c1 --- /dev/null +++ b/website/budget-api/src/contract/parameters/startDate.yaml @@ -0,0 +1,5 @@ +name: startDate +in: query +schema: + type: string + format: date diff --git a/website/budget-api/src/contract/responses/400-response.yaml b/website/budget-api/src/contract/responses/400-response.yaml new file mode 100644 index 00000000..1a386984 --- /dev/null +++ b/website/budget-api/src/contract/responses/400-response.yaml @@ -0,0 +1,4 @@ +description: Bad Request +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/budget-api/src/contract/responses/401-response.yaml b/website/budget-api/src/contract/responses/401-response.yaml new file mode 100644 index 00000000..cacaf0e4 --- /dev/null +++ b/website/budget-api/src/contract/responses/401-response.yaml @@ -0,0 +1,4 @@ +description: Not authenticated +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/budget-api/src/contract/responses/403-response.yaml b/website/budget-api/src/contract/responses/403-response.yaml new file mode 100644 index 00000000..e205f822 --- /dev/null +++ b/website/budget-api/src/contract/responses/403-response.yaml @@ -0,0 +1,4 @@ +description: Not authorized for resource +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/budget-api/src/contract/responses/404-response.yaml b/website/budget-api/src/contract/responses/404-response.yaml new file mode 100644 index 00000000..d083af34 --- /dev/null +++ b/website/budget-api/src/contract/responses/404-response.yaml @@ -0,0 +1,4 @@ +description: Resource not found +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetCommandController.java b/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetCommandController.java new file mode 100644 index 00000000..f99e9096 --- /dev/null +++ b/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetCommandController.java @@ -0,0 +1,120 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.providers.BudgetProvider; +import com.jongsoft.finance.rest.model.budget.BudgetRequest; +import com.jongsoft.finance.rest.model.budget.BudgetResponse; +import com.jongsoft.finance.rest.model.budget.ExpenseRequest; +import com.jongsoft.finance.security.CurrentUserProvider; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Objects; + +@Controller +class BudgetCommandController implements BudgetCommandApi { + + private final Logger logger; + private final BudgetProvider budgetProvider; + private final CurrentUserProvider currentUserProvider; + + BudgetCommandController( + BudgetProvider budgetProvider, CurrentUserProvider currentUserProvider) { + this.budgetProvider = budgetProvider; + this.currentUserProvider = currentUserProvider; + this.logger = LoggerFactory.getLogger(BudgetCommandController.class); + } + + @Override + public HttpResponse<@Valid BudgetResponse> createInitialBudget(BudgetRequest budgetRequest) { + logger.info( + "Creating initial budget for year {} and month {}.", + budgetRequest.getYear(), + budgetRequest.getMonth()); + + budgetProvider + .lookup(budgetRequest.getYear(), budgetRequest.getMonth()) + .ifPresent(() -> StatusException.badRequest( + "A budget already exists, cannot start a new one.")); + + currentUserProvider + .currentUser() + .createBudget( + LocalDate.of(budgetRequest.getYear(), budgetRequest.getMonth(), 1), + budgetRequest.getIncome()); + + var budget = budgetProvider + .lookup(budgetRequest.getYear(), budgetRequest.getMonth()) + .getOrThrow(() -> StatusException.internalError("Error whilst creating budget.")); + return HttpResponse.created(BudgetMapper.toBudgetResponse(budget)); + } + + @Override + public BudgetResponse updateCurrentBudget(BudgetRequest budgetRequest) { + logger.info( + "Updating budget for year {} and month {}.", + budgetRequest.getYear(), + budgetRequest.getMonth()); + + var budget = budgetProvider + .lookup(budgetRequest.getYear(), budgetRequest.getMonth()) + .getOrThrow(() -> StatusException.notFound( + "Cannot update budget, no previous version found.")); + + budget.indexBudget(budget.getStart(), budgetRequest.getIncome()); + var updateBudget = budgetProvider + .lookup(budget.getStart().getYear(), budget.getStart().getMonthValue()) + .getOrThrow(() -> StatusException.internalError("Error whilst updating budget.")); + return BudgetMapper.toBudgetResponse(updateBudget); + } + + @Override + public BudgetResponse updateExpense(ExpenseRequest expenseRequest) { + logger.info("Updating expense {}.", expenseRequest.getId()); + + var now = LocalDate.now().withDayOfMonth(1); + var budget = budgetProvider + .lookup(now.getYear(), now.getMonthValue()) + .getOrThrow(() -> StatusException.notFound( + "Cannot update expenses, no budget available yet.")); + + if (expenseRequest.getId() != null) { + logger.debug("Updating expense {} within active budget.", expenseRequest.getId()); + if (budget.getStart().isBefore(now)) { + logger.debug( + "Starting new budget period as the current period {} is after the existing start of {}.", + now, + budget.getStart()); + budget.indexBudget(now, budget.getExpectedIncome()); + budget = budgetProvider + .lookup(now.getYear(), now.getMonthValue()) + .getOrThrow(() -> + StatusException.internalError("Error whilst updating budget.")); + } + + budget.getExpenses() + .first(expense -> Objects.equals(expense.getId(), expenseRequest.getId())) + .getOrThrow(() -> StatusException.badRequest( + "Attempted to update a non existing expense.")) + .updateExpense(expenseRequest.getAmount()); + } else { + logger.debug("Creating new expense within active budget."); + budget.createExpense( + expenseRequest.getName(), + expenseRequest.getAmount() - 0.01, + expenseRequest.getAmount()); + } + + var updateBudget = budgetProvider + .lookup(budget.getStart().getYear(), budget.getStart().getMonthValue()) + .getOrThrow(() -> StatusException.internalError("Error whilst updating budget.")); + return BudgetMapper.toBudgetResponse(updateBudget); + } +} diff --git a/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetFetcherController.java b/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetFetcherController.java new file mode 100644 index 00000000..2b06cefd --- /dev/null +++ b/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetFetcherController.java @@ -0,0 +1,140 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.DateUtils; +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.core.EntityRef; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.BudgetProvider; +import com.jongsoft.finance.providers.ExpenseProvider; +import com.jongsoft.finance.providers.TransactionProvider; +import com.jongsoft.finance.rest.model.budget.BudgetResponse; +import com.jongsoft.finance.rest.model.budget.ComputedExpenseResponse; +import com.jongsoft.finance.rest.model.budget.ExpenseResponse; +import com.jongsoft.lang.Collections; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +@Controller +class BudgetFetcherController implements BudgetFetcherApi { + + private final Logger logger; + private final BudgetProvider budgetProvider; + private final ExpenseProvider expenseProvider; + private final FilterFactory filterFactory; + private final TransactionProvider transactionProvider; + + BudgetFetcherController( + BudgetProvider budgetProvider, + ExpenseProvider expenseProvider, + FilterFactory filterFactory, + TransactionProvider transactionProvider) { + this.budgetProvider = budgetProvider; + this.expenseProvider = expenseProvider; + this.filterFactory = filterFactory; + this.transactionProvider = transactionProvider; + this.logger = LoggerFactory.getLogger(BudgetFetcherController.class); + } + + @Override + public List computeBudgetExpenseBalance( + Integer year, Integer month, List expenseId) { + logger.info("Computing budget expense balance for {}-{}.", year, month); + + var budget = budgetProvider + .lookup(year, month) + .getOrThrow(() -> + StatusException.badRequest("Cannot fetch expenses, no budget found.")); + + var dateRange = DateUtils.forMonth(year, month); + var days = (int) ChronoUnit.DAYS.between(dateRange.from(), dateRange.until()); + + var computedExpenses = new ArrayList(); + for (var expense : budget.getExpenses()) { + if (!expenseId.isEmpty() && !expenseId.contains(expense.getId())) { + continue; + } + + var filter = filterFactory + .transaction() + .range(dateRange) + .onlyIncome(false) + .ownAccounts() + .expenses(Collections.List(new EntityRef(expense.getId()))); + var balance = transactionProvider + .balance(filter) + .getOrSupply(() -> BigDecimal.ZERO) + .doubleValue(); + computedExpenses.add(new ComputedExpenseResponse( + expense.getId(), + expense.computeBudget() - balance, + calculateDaily( + BigDecimal.valueOf(expense.computeBudget()) + .subtract(BigDecimal.valueOf(Math.abs(balance))) + .doubleValue(), + days) + .doubleValue(), + balance, + calculateDaily(balance, days).doubleValue())); + } + + return computedExpenses; + } + + @Override + public BudgetResponse findByFilter(Integer year, Integer month, Boolean firstOnly) { + logger.info("Finding budget by year {} and month {}.", year, month); + + if (firstOnly != null && firstOnly) { + var budget = budgetProvider + .first() + .getOrThrow(() -> + StatusException.badRequest("Cannot fetch budget, no budget found.")); + return BudgetMapper.toBudgetResponse(budget); + } + + var date = LocalDate.now().withDayOfMonth(1); + if (year != null) { + date = date.withYear(year); + } + if (month != null) { + date = date.withMonth(month); + } + + var budget = budgetProvider + .lookup(date.getYear(), date.getMonthValue()) + .getOrThrow(() -> StatusException.notFound("Budget not found for the given date.")); + + return BudgetMapper.toBudgetResponse(budget); + } + + @Override + public List<@Valid ExpenseResponse> findExpensesByFilter(String name) { + logger.info("Finding expenses by name {}.", name); + var filter = filterFactory.expense().name(name, false); + + return expenseProvider + .lookup(filter) + .content() + .map(BudgetMapper::toBudgetExpense) + .toJava(); + } + + private BigDecimal calculateDaily(double spent, int days) { + return BigDecimal.valueOf(spent) + .divide(BigDecimal.valueOf(days), new MathContext(6, RoundingMode.HALF_UP)) + .setScale(2, RoundingMode.HALF_UP); + } +} diff --git a/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetMapper.java b/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetMapper.java new file mode 100644 index 00000000..e633cebe --- /dev/null +++ b/website/budget-api/src/main/java/com/jongsoft/finance/rest/BudgetMapper.java @@ -0,0 +1,28 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.domain.core.EntityRef; +import com.jongsoft.finance.domain.user.Budget; +import com.jongsoft.finance.rest.model.budget.BudgetResponse; +import com.jongsoft.finance.rest.model.budget.DateRange; +import com.jongsoft.finance.rest.model.budget.ExpenseResponse; + +import java.math.BigDecimal; + +public interface BudgetMapper { + + static BudgetResponse toBudgetResponse(Budget budget) { + return new BudgetResponse( + BigDecimal.valueOf(budget.getExpectedIncome()), + new DateRange(budget.getStart(), budget.getEnd()), + budget.getExpenses().map(BudgetMapper::toBudgetExpense).toJava()); + } + + static ExpenseResponse toBudgetExpense(Budget.Expense expense) { + return new ExpenseResponse( + expense.getId(), expense.getName(), BigDecimal.valueOf(expense.computeBudget())); + } + + static ExpenseResponse toBudgetExpense(EntityRef.NamedEntity expense) { + return new ExpenseResponse(expense.getId(), expense.name(), BigDecimal.ZERO); + } +} diff --git a/website/importer-api/build.gradle.kts b/website/importer-api/build.gradle.kts new file mode 100644 index 00000000..583e8fd6 --- /dev/null +++ b/website/importer-api/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("io.micronaut.library") + id("io.micronaut.openapi") +} + +micronaut { + runtime("jetty") + testRuntime("junit5") + + openapi { + server("importer", file("src/contract/importer-api.yaml")) { + apiPackageName = "com.jongsoft.finance.rest" + modelPackageName = "com.jongsoft.finance.rest.model" + useAuth = true + useReactive = false + generatedAnnotation = false + + importMapping = mapOf( + "ExternalErrorResponse" to "io.micronaut.http.hateoas.JsonError", + ) + typeMapping = mapOf( + "json-error-response" to "ExternalErrorResponse" + ) + } + } +} + +dependencies { + implementation(mn.micronaut.http.validation) + implementation(mn.micronaut.security.annotations) + implementation(mn.micronaut.security) + + implementation(mn.micronaut.serde.api) + implementation(mn.validation) + + implementation(libs.lang) + + implementation(project(":core")) + implementation(project(":domain")) +} diff --git a/website/importer-api/src/contract/components/batch-job.yaml b/website/importer-api/src/contract/components/batch-job.yaml new file mode 100644 index 00000000..c3d38818 --- /dev/null +++ b/website/importer-api/src/contract/components/batch-job.yaml @@ -0,0 +1,32 @@ +type: object +required: + - slug + - created + - config + - balance +properties: + slug: + type: string + description: The unique identifier of the import job + created: + type: string + format: date + description: The date the job was created + finished: + type: string + format: date + description: The date the job was finished + config: + $ref: '#/components/schemas/configuration-response' + balance: + type: object + required: [ income, expenses ] + properties: + income: + type: number + format: double + description: The total amount of money earned in this import + expenses: + type: number + format: double + description: The total amount of money spent in this import diff --git a/website/importer-api/src/contract/components/configuration.yaml b/website/importer-api/src/contract/components/configuration.yaml new file mode 100644 index 00000000..7ea57b52 --- /dev/null +++ b/website/importer-api/src/contract/components/configuration.yaml @@ -0,0 +1,20 @@ +type: object +required: + - id + - name + - type + - fileCode +properties: + id: + type: integer + format: int64 + description: The configuration identifier + name: + type: string + description: The name of the configuration + type: + type: string + description: The type of importer that will be used + fileCode: + type: string + description: The file code to get the contents of the configuration diff --git a/website/importer-api/src/contract/components/paged-batch-job.yaml b/website/importer-api/src/contract/components/paged-batch-job.yaml new file mode 100644 index 00000000..415445e1 --- /dev/null +++ b/website/importer-api/src/contract/components/paged-batch-job.yaml @@ -0,0 +1,8 @@ +allOf: + - $ref: '#/components/schemas/paged-response' + - type: object + required: [ content ] + properties: + content: + type: array + items: { $ref: '#/components/schemas/batch-job-response' } diff --git a/website/importer-api/src/contract/components/paged-response.yaml b/website/importer-api/src/contract/components/paged-response.yaml new file mode 100644 index 00000000..6e68b39f --- /dev/null +++ b/website/importer-api/src/contract/components/paged-response.yaml @@ -0,0 +1,26 @@ +type: object +required: + - info +properties: + info: + type: object + required: + - pageSize + - pages + - records + properties: + records: + type: integer + description: The total amount of matches + format: int64 + example: 20 + pages: + type: integer + description: The amount of pages available + format: int32 + example: 2 + pageSize: + type: integer + description: The amount of matches per page + format: int32 + example: 15 diff --git a/website/importer-api/src/contract/importer-api.yaml b/website/importer-api/src/contract/importer-api.yaml new file mode 100644 index 00000000..94d0e7e9 --- /dev/null +++ b/website/importer-api/src/contract/importer-api.yaml @@ -0,0 +1,153 @@ +openapi: 3.1.0 +info: + title: Pledger.io Importer API + version: 3.0.0 + contact: + name: Jong Soft Development + url: https://github.com/pledger-io/rest-application + license: + name: MIT + url: https://opensource.org/licenses/MIT + +security: + - bearerAuth: [ ] + +paths: + /v2/api/batch-importer: + get: + operationId: getJobsByFilters + tags: [ batch-importer ] + summary: Search import jobs + parameters: + - $ref: '#/components/parameters/number-of-results' + - $ref: '#/components/parameters/offset' + responses: + "200": + description: The batch jobs that were executed + content: + application/json: + schema: { $ref: '#/components/schemas/paged-batch-job-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createJob + tags: [ batch-importer ] + summary: Create import job + requestBody: + content: + application/json: + schema: + type: object + properties: + configuration: + type: string + fileToken: + type: string + responses: + "201": + description: The created batch job + content: + application/json: + schema: { $ref: '#/components/schemas/batch-job-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/batch-importer/{slug}: + parameters: + - name: slug + required: true + in: path + description: The unique identifier + schema: + type: string + get: + operationId: getJobBySlug + tags: [ batch-importer ] + summary: Get import job + responses: + "200": + description: The batch job + content: + application/json: + schema: { $ref: '#/components/schemas/batch-job-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteJobBySlug + tags: [ batch-importer ] + summary: Delete import job + responses: + "204": + description: Confirmation that the batch job was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + /v2/api/batch-importer-config: + get: + operationId: getConfigurations + tags: [ batch-importer ] + summary: Search configuration + responses: + "200": + description: A list of all available configurations + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/configuration-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createConfiguration + tags: [ batch-importer ] + summary: Create configuration + requestBody: + content: + application/json: + schema: + type: object + required: [ name, type, fileCode ] + properties: + name: + type: string + description: The name of the configuration + type: + type: string + description: The type of importer that will be used + fileCode: + type: string + description: The file code to get the contents of the configuration + responses: + "201": + description: THe created configuration + content: + application/json: + schema: { $ref: '#/components/schemas/configuration-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + +components: + securitySchemes: + bearer: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + # pagination related parameters + number-of-results: { $ref: 'parameters/number-of-results.yaml' } + offset: { $ref: 'parameters/offset.yaml' } + + responses: + 400: { $ref: './responses/400-response.yaml' } + 401: { $ref: './responses/401-response.yaml' } + 403: { $ref: './responses/403-response.yaml' } + 404: { $ref: './responses/404-response.yaml' } + + schemas: + paged-response: { $ref: 'components/paged-response.yaml' } + batch-job-response: { $ref: 'components/batch-job.yaml' } + paged-batch-job-response: { $ref: 'components/paged-batch-job.yaml' } + configuration-response: { $ref: 'components/configuration.yaml' } diff --git a/website/importer-api/src/contract/parameters/number-of-results.yaml b/website/importer-api/src/contract/parameters/number-of-results.yaml new file mode 100644 index 00000000..45b46823 --- /dev/null +++ b/website/importer-api/src/contract/parameters/number-of-results.yaml @@ -0,0 +1,6 @@ +name: numberOfResults +description: The number of accounts to be returned +in: query +required: true +schema: + type: integer diff --git a/website/importer-api/src/contract/parameters/offset.yaml b/website/importer-api/src/contract/parameters/offset.yaml new file mode 100644 index 00000000..c310ac54 --- /dev/null +++ b/website/importer-api/src/contract/parameters/offset.yaml @@ -0,0 +1,6 @@ +name: offset +description: The number of rows to skip in the result set +in: query +required: true +schema: + type: integer diff --git a/website/importer-api/src/contract/responses/400-response.yaml b/website/importer-api/src/contract/responses/400-response.yaml new file mode 100644 index 00000000..1a386984 --- /dev/null +++ b/website/importer-api/src/contract/responses/400-response.yaml @@ -0,0 +1,4 @@ +description: Bad Request +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/importer-api/src/contract/responses/401-response.yaml b/website/importer-api/src/contract/responses/401-response.yaml new file mode 100644 index 00000000..cacaf0e4 --- /dev/null +++ b/website/importer-api/src/contract/responses/401-response.yaml @@ -0,0 +1,4 @@ +description: Not authenticated +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/importer-api/src/contract/responses/403-response.yaml b/website/importer-api/src/contract/responses/403-response.yaml new file mode 100644 index 00000000..e205f822 --- /dev/null +++ b/website/importer-api/src/contract/responses/403-response.yaml @@ -0,0 +1,4 @@ +description: Not authorized for resource +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/importer-api/src/contract/responses/404-response.yaml b/website/importer-api/src/contract/responses/404-response.yaml new file mode 100644 index 00000000..d083af34 --- /dev/null +++ b/website/importer-api/src/contract/responses/404-response.yaml @@ -0,0 +1,4 @@ +description: Resource not found +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/importer-api/src/main/java/com/jongsoft/finance/rest/api/BatchImporterController.java b/website/importer-api/src/main/java/com/jongsoft/finance/rest/api/BatchImporterController.java new file mode 100644 index 00000000..cff1388c --- /dev/null +++ b/website/importer-api/src/main/java/com/jongsoft/finance/rest/api/BatchImporterController.java @@ -0,0 +1,135 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.importer.BatchImport; +import com.jongsoft.finance.domain.importer.BatchImportConfig; +import com.jongsoft.finance.providers.ImportConfigurationProvider; +import com.jongsoft.finance.providers.ImportProvider; +import com.jongsoft.finance.rest.BatchImporterApi; +import com.jongsoft.finance.rest.model.*; +import com.jongsoft.finance.security.CurrentUserProvider; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +@Controller +class BatchImporterController implements BatchImporterApi { + + private final Logger logger; + private final ImportProvider importProvider; + private final ImportConfigurationProvider importConfigurationProvider; + private final CurrentUserProvider currentUserProvider; + + BatchImporterController( + ImportProvider importProvider, + ImportConfigurationProvider importConfigurationProvider, + CurrentUserProvider currentUserProvider) { + this.importProvider = importProvider; + this.importConfigurationProvider = importConfigurationProvider; + this.currentUserProvider = currentUserProvider; + this.logger = LoggerFactory.getLogger(BatchImporterController.class); + } + + @Override + public HttpResponse<@Valid ConfigurationResponse> createConfiguration( + CreateConfigurationRequest createConfigurationRequest) { + logger.info("Creating configuration."); + importConfigurationProvider + .lookup(createConfigurationRequest.getName()) + .ifPresent(() -> StatusException.badRequest("Configuration with name " + + createConfigurationRequest.getName() + " already exists.")); + + var createdConfig = currentUserProvider + .currentUser() + .createImportConfiguration( + createConfigurationRequest.getType(), + createConfigurationRequest.getName(), + createConfigurationRequest.getFileCode()); + return HttpResponse.created(convertToConfigResponse(createdConfig)); + } + + @Override + public HttpResponse<@Valid BatchJobResponse> createJob(CreateJobRequest createJobRequest) { + logger.info( + "Creating new import job with config {}.", createJobRequest.get_configuration()); + var importConfig = importConfigurationProvider + .lookup(createJobRequest.get_configuration()) + .getOrThrow(() -> StatusException.badRequest( + "Configuration " + createJobRequest.get_configuration() + " not found.")); + + var importJob = importConfig.createImport(createJobRequest.getFileToken()); + return HttpResponse.created(convertToJobResponse(importJob)); + } + + @Override + public HttpResponse deleteJobBySlug(String slug) { + logger.info("Deleting job with slug {}.", slug); + importProvider + .lookup(slug) + .getOrThrow(() -> StatusException.notFound("Job with slug " + slug + " not found.")) + .archive(); + return HttpResponse.noContent(); + } + + @Override + public List<@Valid ConfigurationResponse> getConfigurations() { + logger.info("Retrieving all configurations from the system."); + return importConfigurationProvider + .lookup() + .map(this::convertToConfigResponse) + .toJava(); + } + + @Override + public BatchJobResponse getJobBySlug(String slug) { + logger.info("Retrieving job with slug {}.", slug); + var importJob = importProvider + .lookup(slug) + .getOrThrow( + () -> StatusException.notFound("Job with slug " + slug + " not found.")); + return convertToJobResponse(importJob); + } + + @Override + public PagedBatchJobResponse getJobsByFilters(Integer numberOfResults, Integer offset) { + logger.info("Retrieving jobs by filters."); + var result = importProvider.lookup(new ImportProvider.FilterCommand() { + @Override + public int page() { + return offset / numberOfResults; + } + + @Override + public int pageSize() { + return numberOfResults; + } + }); + + return new PagedBatchJobResponse( + new PagedResponseInfo(result.total(), result.pages(), result.pageSize()), + result.content().map(this::convertToJobResponse).toJava()); + } + + private BatchJobResponse convertToJobResponse(BatchImport batchImport) { + return new BatchJobResponse( + batchImport.getSlug(), + LocalDate.ofInstant(batchImport.getCreated().toInstant(), ZoneId.of("UTC")), + convertToConfigResponse(batchImport.getConfig()), + new BatchJobResponseBalance( + batchImport.getTotalIncome(), batchImport.getTotalExpense())); + } + + private ConfigurationResponse convertToConfigResponse(BatchImportConfig config) { + return new ConfigurationResponse( + config.getId(), config.getName(), config.getType(), config.getFileCode()); + } +} diff --git a/website/learning-rule-api/build.gradle.kts b/website/learning-rule-api/build.gradle.kts new file mode 100644 index 00000000..07565d21 --- /dev/null +++ b/website/learning-rule-api/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("io.micronaut.library") + id("io.micronaut.openapi") +} + +micronaut { + runtime("jetty") + testRuntime("junit5") + + openapi { + server(file("src/contract/learning-rule-api.yaml")) { + apiPackageName = "com.jongsoft.finance.rest" + modelPackageName = "com.jongsoft.finance.rest.model.rule" + useAuth = true + useReactive = false + generatedAnnotation = false + + importMapping = mapOf( + "JsonError" to "io.micronaut.http.hateoas.JsonError", + "RuleOperation" to "com.jongsoft.finance.core.RuleOperation", + "RuleColumn" to "com.jongsoft.finance.core.RuleColumn", + ) + typeMapping = mapOf( + "json-error-response" to "JsonError", + "operation-type" to "RuleOperation", + "rule-column" to "RuleColumn" + ) + } + } +} + +dependencies { + implementation(mn.micronaut.http.validation) + implementation(mn.micronaut.security.annotations) + implementation(mn.micronaut.security) + + implementation(mn.micronaut.serde.api) + implementation(mn.validation) + + implementation(libs.lang) + + implementation(project(":core")) + implementation(project(":domain")) + + testRuntimeOnly(mn.micronaut.serde.jackson) + testRuntimeOnly(mn.micronaut.jackson.databind) + testRuntimeOnly(mn.logback.classic) + + testRuntimeOnly(project(":jpa-repository")) + + testImplementation(mn.micronaut.test.rest.assured) + testImplementation(libs.bundles.junit) + testImplementation(mn.micronaut.http.server.jetty) +} diff --git a/website/learning-rule-api/src/contract/components/operation-type.yaml b/website/learning-rule-api/src/contract/components/operation-type.yaml new file mode 100644 index 00000000..984a8a76 --- /dev/null +++ b/website/learning-rule-api/src/contract/components/operation-type.yaml @@ -0,0 +1,7 @@ +type: string +enum: + - EQUALS + - CONTAINS + - STARTS_WITH + - LESS_THAN + - MORE_THAN diff --git a/website/learning-rule-api/src/contract/components/request/create-rule-group.yaml b/website/learning-rule-api/src/contract/components/request/create-rule-group.yaml new file mode 100644 index 00000000..b4f88b5f --- /dev/null +++ b/website/learning-rule-api/src/contract/components/request/create-rule-group.yaml @@ -0,0 +1,6 @@ +type: object +required: [ name ] +properties: + name: + type: string + example: "Stores" diff --git a/website/learning-rule-api/src/contract/components/request/patch-rule-group.yaml b/website/learning-rule-api/src/contract/components/request/patch-rule-group.yaml new file mode 100644 index 00000000..f976b595 --- /dev/null +++ b/website/learning-rule-api/src/contract/components/request/patch-rule-group.yaml @@ -0,0 +1,10 @@ +type: object +description: > + Patch part of the rule group. + Either `name` or `move` must be provided. +properties: + name: + type: string + move: + type: string + enum: [ UP, DOWN ] diff --git a/website/learning-rule-api/src/contract/components/request/rule.yaml b/website/learning-rule-api/src/contract/components/request/rule.yaml new file mode 100644 index 00000000..594dd506 --- /dev/null +++ b/website/learning-rule-api/src/contract/components/request/rule.yaml @@ -0,0 +1,44 @@ +type: object +required: [ name, active, restrictive ] +properties: + name: + type: string + description: The name of the rule + description: + type: string + description: A long description of the rule + active: + type: boolean + description: Should the rule be executed when the engine runs + restrictive: + type: boolean + description: Should the rule execution stop after a positive match + changes: + type: array + description: List of all the changes to be applied + items: + type: object + required: [ field, change ] + properties: + column: + $ref: '#/components/schemas/rule-column' + value: + type: string + description: The value to be applied, this could be an identifier + conditions: + type: array + description: List of all pre-conditions that must be met + items: + type: object + required: [ field, operation, condition ] + properties: + column: + description: The column on which the change is effected + $ref: '#/components/schemas/rule-column' + operation: + description: The type of comparison operation to perform + $ref: '#/components/schemas/operation-type' + value: + type: string + description: The value the column must have to match the pre-condition + example: My personal account diff --git a/website/learning-rule-api/src/contract/components/response/rule.yaml b/website/learning-rule-api/src/contract/components/response/rule.yaml new file mode 100644 index 00000000..4f98491d --- /dev/null +++ b/website/learning-rule-api/src/contract/components/response/rule.yaml @@ -0,0 +1,45 @@ +type: object +required: [ id, name, active, restrictive, sort ] +properties: + id: + type: integer + format: int64 + name: + type: string + description: + type: string + active: + type: boolean + restrictive: + type: boolean + sort: + type: integer + format: int32 + changes: + type: array + items: + type: object + required: [ id, field, change ] + properties: + id: + type: integer + format: int64 + field: + $ref: '#/components/schemas/rule-column' + change: + type: string + conditions: + type: array + items: + type: object + required: [ id, field, operation, condition ] + properties: + id: + type: integer + format: int64 + field: + $ref: '#/components/schemas/rule-column' + operation: + $ref: '#/components/schemas/operation-type' + condition: + type: string diff --git a/website/learning-rule-api/src/contract/components/rule-column.yaml b/website/learning-rule-api/src/contract/components/rule-column.yaml new file mode 100644 index 00000000..dc173939 --- /dev/null +++ b/website/learning-rule-api/src/contract/components/rule-column.yaml @@ -0,0 +1,12 @@ +type: string +enum: + - SOURCE_ACCOUNT + - TO_ACCOUNT + - DESCRIPTION + - AMOUNT + - CATEGORY + - CHANGE_TRANSFER_TO + - CHANGE_TRANSFER_FROM + - BUDGET + - CONTRACT + - TAGS diff --git a/website/learning-rule-api/src/contract/learning-rule-api.yaml b/website/learning-rule-api/src/contract/learning-rule-api.yaml new file mode 100644 index 00000000..9a08060d --- /dev/null +++ b/website/learning-rule-api/src/contract/learning-rule-api.yaml @@ -0,0 +1,220 @@ +openapi: 3.1.0 +info: + title: Pledger.io Learning Rule API + version: 3.0.0 + contact: + name: Jong Soft Development + url: https://github.com/pledger-io/rest-application + license: + name: MIT + url: https://opensource.org/licenses/MIT + +security: + - bearerAuth: [ ] + +paths: + /v2/api/transaction-rules: + get: + operationId: fetchRuleGroups + tags: [ learning-rule-fetcher ] + summary: Fetch rule groups + responses: + "200": + description: The list of rule groups + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/rule-group-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createRuleGroup + tags: [ learning-rule-command ] + summary: Create rule group + requestBody: + description: > + The request body for creating a new rule group. + content: + application/json: + schema: { $ref: '#/components/schemas/rule-group' } + responses: + "204": + description: Confirmation that the group was created. + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/transaction-rules/{group}: + parameters: + - name: group + required: true + in: path + description: The name of the group + schema: + type: string + get: + operationId: fetchRulesByGroup + tags: [ learning-rule-fetcher ] + summary: Fetch rules by group + responses: + "200": + description: The list of rule groups + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/rule-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteRuleGroup + tags: [ learning-rule-command ] + summary: Delete rule group + responses: + "204": + description: Confirmation that the group was deleted. + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + patch: + operationId: updateRuleGroup + tags: [ learning-rule-command ] + summary: Update rule group + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/patch-rule-group-request' } + responses: + "204": + description: Confirmation that the group was deleted. + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + post: + operationId: createRule + tags: [ learning-rule-command ] + summary: Create rule + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/rule-request' } + responses: + "201": + description: The created rule + content: + application/json: + schema: { $ref: '#/components/schemas/rule-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + /v2/api/transaction-rules/{group}/{ruleId}: + parameters: + - name: group + required: true + in: path + description: The name of the group + schema: + type: string + - name: ruleId + required: true + in: path + description: The name of the group + schema: + type: integer + format: int64 + get: + operationId: fetchRule + tags: [ learning-rule-fetcher ] + summary: Fetch rule + responses: + "200": + description: The rule + content: + application/json: + schema: { $ref: '#/components/schemas/rule-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + put: + operationId: updateRule + tags: [ learning-rule-command ] + summary: Update rule + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/rule-request' } + responses: + "200": + description: The updated rule + content: + application/json: + schema: { $ref: '#/components/schemas/rule-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + patch: + operationId: patchRule + tags: [ learning-rule-command ] + summary: Patch rule + requestBody: + content: + application/json: + schema: + type: object + required: [ move ] + properties: + move: + type: string + enum: [ UP, DOWN ] + responses: + "204": + description: The rule was patched + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteRule + tags: [ learning-rule-command ] + summary: Delete rule + responses: + "204": + description: The rule was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + +components: + securitySchemes: + bearer: + type: http + scheme: bearer + bearerFormat: JWT + + responses: + 400: { $ref: './responses/400-response.yaml' } + 401: { $ref: './responses/401-response.yaml' } + 403: { $ref: './responses/403-response.yaml' } + 404: { $ref: './responses/404-response.yaml' } + + schemas: + rule-group-response: { $ref: '#/components/schemas/rule-group' } + rule-group: + type: object + required: [ name, sort ] + properties: + sort: + type: integer + format: int32 + name: + type: string + + rule-column: { $ref: 'components/rule-column.yaml' } + operation-type: { $ref: 'components/operation-type.yaml' } + + create-rule-group-request: { $ref: 'components/request/create-rule-group.yaml' } + patch-rule-group-request: { $ref: 'components/request/patch-rule-group.yaml' } + rule-request: { $ref: 'components/request/rule.yaml' } + + rule-response: { $ref: 'components/response/rule.yaml' } diff --git a/website/learning-rule-api/src/contract/responses/400-response.yaml b/website/learning-rule-api/src/contract/responses/400-response.yaml new file mode 100644 index 00000000..1a386984 --- /dev/null +++ b/website/learning-rule-api/src/contract/responses/400-response.yaml @@ -0,0 +1,4 @@ +description: Bad Request +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/learning-rule-api/src/contract/responses/401-response.yaml b/website/learning-rule-api/src/contract/responses/401-response.yaml new file mode 100644 index 00000000..cacaf0e4 --- /dev/null +++ b/website/learning-rule-api/src/contract/responses/401-response.yaml @@ -0,0 +1,4 @@ +description: Not authenticated +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/learning-rule-api/src/contract/responses/403-response.yaml b/website/learning-rule-api/src/contract/responses/403-response.yaml new file mode 100644 index 00000000..e205f822 --- /dev/null +++ b/website/learning-rule-api/src/contract/responses/403-response.yaml @@ -0,0 +1,4 @@ +description: Not authorized for resource +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/learning-rule-api/src/contract/responses/404-response.yaml b/website/learning-rule-api/src/contract/responses/404-response.yaml new file mode 100644 index 00000000..d083af34 --- /dev/null +++ b/website/learning-rule-api/src/contract/responses/404-response.yaml @@ -0,0 +1,4 @@ +description: Resource not found +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/LearningRuleCommandController.java b/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/LearningRuleCommandController.java new file mode 100644 index 00000000..d1ec36c8 --- /dev/null +++ b/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/LearningRuleCommandController.java @@ -0,0 +1,182 @@ +package com.jongsoft.finance.rest.api; + +import static java.util.Optional.ofNullable; + +import com.jongsoft.finance.core.Removable; +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.transaction.TransactionRule; +import com.jongsoft.finance.messaging.commands.rule.CreateRuleGroupCommand; +import com.jongsoft.finance.providers.TransactionRuleGroupProvider; +import com.jongsoft.finance.providers.TransactionRuleProvider; +import com.jongsoft.finance.rest.LearningRuleCommandApi; +import com.jongsoft.finance.rest.model.rule.*; +import com.jongsoft.finance.security.CurrentUserProvider; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Controller +class LearningRuleCommandController implements LearningRuleCommandApi { + + private final Logger logger; + private final TransactionRuleGroupProvider ruleGroupProvider; + private final TransactionRuleProvider ruleProvider; + private final CurrentUserProvider currentUserProvider; + + LearningRuleCommandController( + TransactionRuleGroupProvider ruleGroupProvider, + TransactionRuleProvider ruleProvider, + CurrentUserProvider currentUserProvider) { + this.ruleGroupProvider = ruleGroupProvider; + this.ruleProvider = ruleProvider; + this.currentUserProvider = currentUserProvider; + this.logger = LoggerFactory.getLogger(LearningRuleCommandController.class); + } + + @Override + public HttpResponse<@Valid RuleResponse> createRule(String group, RuleRequest ruleRequest) { + logger.info("Creating rule {} in group {}.", ruleRequest.getName(), group); + var rule = currentUserProvider + .currentUser() + .createRule( + ruleRequest.getName(), + ofNullable(ruleRequest.getRestrictive()).orElse(false)); + + rule.assign(group); + rule.change( + ruleRequest.getName(), + ruleRequest.getDescription(), + ruleRequest.getRestrictive(), + ofNullable(ruleRequest.getActive()).orElse(false)); + + ruleRequest + .getChanges() + .forEach(change -> rule.registerChange(change.getColumn(), change.getValue())); + + ruleRequest + .getConditions() + .forEach(condition -> rule.registerCondition( + condition.getColumn(), condition.getOperation(), condition.getValue())); + ruleProvider.save(rule); + + var insertedRule = ruleProvider + .lookup(group) + .first(r -> r.getName().equalsIgnoreCase(ruleRequest.getName())) + .getOrThrow(() -> StatusException.internalError("Cannot find newly created rule.")); + + return HttpResponse.created(RuleMapper.convertToRuleResponse(insertedRule)); + } + + @Override + public HttpResponse createRuleGroup(RuleGroup ruleGroup) { + logger.info("Creating rule group {}.", ruleGroup.getName()); + + ruleGroupProvider + .lookup(ruleGroup.getName()) + .ifPresent(() -> StatusException.badRequest("Rule group name already exists.")); + + CreateRuleGroupCommand.ruleGroupCreated(ruleGroup.getName()); + + return HttpResponse.noContent(); + } + + @Override + public HttpResponse deleteRule(String group, Long ruleId) { + logger.info("Deleting rule {} in group {}.", ruleId, group); + + var rule = ruleProvider + .lookup(ruleId) + .getOrThrow(() -> StatusException.notFound("Rule not found.")); + rule.remove(); + return HttpResponse.noContent(); + } + + @Override + public HttpResponse deleteRuleGroup(String group) { + logger.info("Deleting rule group {}.", group); + + var ruleGroup = ruleGroupProvider + .lookup(group) + .getOrThrow(() -> StatusException.notFound("Rule group not found.")); + + ruleProvider.lookup(group).forEach(TransactionRule::remove); + ruleGroup.delete(); + + return HttpResponse.noContent(); + } + + @Override + public HttpResponse patchRule( + String group, Long ruleId, PatchRuleRequest patchRuleRequest) { + logger.info("Patching rule {} in group {}.", ruleId, group); + + var rule = ruleProvider + .lookup(ruleId) + .getOrThrow(() -> StatusException.notFound("Rule not found.")); + + if (patchRuleRequest.getMove() == PatchRuleRequestMove.UP) { + rule.changeOrder(rule.getSort() - 1); + } else { + rule.changeOrder(rule.getSort() + 1); + } + return HttpResponse.noContent(); + } + + @Override + public RuleResponse updateRule(String group, Long ruleId, RuleRequest ruleRequest) { + logger.info("Updating rule {} in group {}.", ruleId, group); + + var rule = ruleProvider + .lookup(ruleId) + .getOrThrow(() -> StatusException.notFound("Rule not found.")); + + rule.change( + ruleRequest.getName(), + ruleRequest.getDescription(), + ruleRequest.getRestrictive(), + ruleRequest.getActive()); + rule.getChanges().forEach(Removable::delete); + rule.getConditions().forEach(Removable::delete); + + ruleRequest + .getChanges() + .forEach(change -> rule.registerChange(change.getColumn(), change.getValue())); + + ruleRequest + .getConditions() + .forEach(condition -> rule.registerCondition( + condition.getColumn(), condition.getOperation(), condition.getValue())); + + ruleProvider.save(rule); + + return RuleMapper.convertToRuleResponse(rule); + } + + @Override + public HttpResponse updateRuleGroup( + String group, PatchRuleGroupRequest patchRuleGroupRequest) { + logger.info("Updating rule group {}.", group); + var ruleGroup = ruleGroupProvider + .lookup(group) + .getOrThrow(() -> StatusException.notFound("Rule group not found.")); + + if (patchRuleGroupRequest.getName() != null) { + ruleGroup.rename(patchRuleGroupRequest.getName()); + } + + if (patchRuleGroupRequest.getMove() != null) { + if (patchRuleGroupRequest.getMove() == PatchRuleRequestMove.UP) { + ruleGroup.changeOrder(ruleGroup.getSort() - 1); + } else { + ruleGroup.changeOrder(ruleGroup.getSort() + 1); + } + } + + return HttpResponse.noContent(); + } +} diff --git a/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/LearningRuleFetcherController.java b/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/LearningRuleFetcherController.java new file mode 100644 index 00000000..13017482 --- /dev/null +++ b/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/LearningRuleFetcherController.java @@ -0,0 +1,58 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.providers.TransactionRuleGroupProvider; +import com.jongsoft.finance.providers.TransactionRuleProvider; +import com.jongsoft.finance.rest.LearningRuleFetcherApi; +import com.jongsoft.finance.rest.model.rule.RuleGroup; +import com.jongsoft.finance.rest.model.rule.RuleResponse; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; + +import java.util.List; + +@Controller +class LearningRuleFetcherController implements LearningRuleFetcherApi { + + private final Logger logger; + private final TransactionRuleProvider ruleProvider; + private final TransactionRuleGroupProvider ruleGroupProvider; + + LearningRuleFetcherController( + TransactionRuleProvider ruleProvider, TransactionRuleGroupProvider ruleGroupProvider) { + this.ruleProvider = ruleProvider; + this.ruleGroupProvider = ruleGroupProvider; + this.logger = org.slf4j.LoggerFactory.getLogger(LearningRuleFetcherController.class); + } + + @Override + public RuleResponse fetchRule(String group, Long ruleId) { + logger.info("Fetching rule {} in group {}.", ruleId, group); + + var rule = ruleProvider + .lookup(ruleId) + .getOrThrow(() -> StatusException.notFound("Rule not found in system")); + + return RuleMapper.convertToRuleResponse(rule); + } + + @Override + public List fetchRuleGroups() { + logger.info("Fetching all rule groups."); + + return ruleGroupProvider + .lookup() + .map(ruleGroup -> new RuleGroup(ruleGroup.getSort(), ruleGroup.getName())) + .toJava(); + } + + @Override + public List<@Valid RuleResponse> fetchRulesByGroup(String group) { + logger.info("Fetching all rules in group {}.", group); + return ruleProvider.lookup(group).map(RuleMapper::convertToRuleResponse).toJava(); + } +} diff --git a/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/RuleMapper.java b/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/RuleMapper.java new file mode 100644 index 00000000..fbc57cf6 --- /dev/null +++ b/website/learning-rule-api/src/main/java/com/jongsoft/finance/rest/api/RuleMapper.java @@ -0,0 +1,37 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.transaction.TransactionRule; +import com.jongsoft.finance.rest.model.rule.RuleResponse; +import com.jongsoft.finance.rest.model.rule.RuleResponseChangesInner; +import com.jongsoft.finance.rest.model.rule.RuleResponseConditionsInner; + +interface RuleMapper { + + static RuleResponse convertToRuleResponse(TransactionRule rule) { + RuleResponse response = new RuleResponse( + rule.getId(), + rule.getName(), + rule.isActive(), + rule.isRestrictive(), + rule.getSort()); + + response.description(rule.getDescription()); + response.setChanges( + rule.getChanges().map(RuleMapper::convertToChange).stream().toList()); + + response.setConditions(rule.getConditions().map(RuleMapper::convertToCondition).stream() + .toList()); + + return response; + } + + static RuleResponseChangesInner convertToChange(TransactionRule.Change change) { + return new RuleResponseChangesInner(change.getId(), change.getField(), change.getChange()); + } + + static RuleResponseConditionsInner convertToCondition(TransactionRule.Condition condition) { + return new RuleResponseConditionsInner( + condition.getId(), condition.getField(), + condition.getOperation(), condition.getCondition()); + } +} diff --git a/website/learning-rule-api/src/test/java/com/jongsoft/finance/rest/RuleTest.java b/website/learning-rule-api/src/test/java/com/jongsoft/finance/rest/RuleTest.java new file mode 100644 index 00000000..37b9c83f --- /dev/null +++ b/website/learning-rule-api/src/test/java/com/jongsoft/finance/rest/RuleTest.java @@ -0,0 +1,104 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.domain.user.UserAccount; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.messaging.EventBus; +import com.jongsoft.finance.providers.UserProvider; +import com.jongsoft.finance.security.AuthenticationFacade; +import com.jongsoft.finance.security.CurrentUserProvider; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +public class RuleTest { + + @Inject private ApplicationEventPublisher eventPublisher; + @Inject private UserProvider userProvider; + + @MockBean + @Replaces(CurrentUserProvider.class) + CurrentUserProvider currentUserProvider() { + var currentUserProvider = mock(CurrentUserProvider.class); + when(currentUserProvider.currentUser()) + .thenAnswer(_ -> userProvider.lookup(new UserIdentifier("test-account@local")).get()); + return currentUserProvider; + } + + @MockBean + @Replaces(AuthenticationFacade.class) + AuthenticationFacade authenticationFacade() { + var mockedFacade = mock(AuthenticationFacade.class); + when(mockedFacade.authenticated()).thenReturn("test-account@local"); + return mockedFacade; + } + + @BeforeEach + void setup() { + new EventBus(eventPublisher); + userProvider.lookup(new UserIdentifier("test-account@local")) + .ifNotPresent(() -> new UserAccount("test-account@local", "test123")); + } + + @Test + void createRuleInGroup(RequestSpecification spec) { + given(spec) + .contentType("application/json") + .body(Map.of("name", "Shops")) + .when() + .post("/v2/api/transaction-rules") + .then() + .statusCode(204); + + Long id = given(spec) + .contentType("application/json") + .pathParam("group", "Shops") + .body(Map.of( + "name", "Grocery shop", + "active", true, + "restrictive", false, + "changes", List.of( + Map.of("column", "SOURCE_ACCOUNT", "value","1") + ), + "conditions", List.of( + Map.of("column", "SOURCE_ACCOUNT", "operation","STARTS_WITH", "value", "wall") + ) + )) + .when() + .post("/v2/api/transaction-rules/{group}") + .then() + .log().ifValidationFails() + .statusCode(201) + .body("name", equalTo("Grocery shop")) + .body("active", equalTo(true)) + .body("restrictive", equalTo(false)) + .body("changes", hasSize(1)) + .body("changes[0].field", equalTo("SOURCE_ACCOUNT")) + .body("conditions", hasSize(1)) + .extract().jsonPath().getLong("id"); + + given(spec) + .contentType("application/json") + .pathParam("group", "Shops") + .pathParam("id", id) + .when() + .delete("/v2/api/transaction-rules/{group}/{id}") + .then() + .statusCode(204); + } +} diff --git a/website/learning-rule-api/src/test/resources/application-test.properties b/website/learning-rule-api/src/test/resources/application-test.properties new file mode 100644 index 00000000..12ad2705 --- /dev/null +++ b/website/learning-rule-api/src/test/resources/application-test.properties @@ -0,0 +1,4 @@ +micronaut.security.enabled=false +datasources.default.url=jdbc:h2:mem:pledger-io;DB_CLOSE_DELAY=50;MODE=MariaDB + +micronaut.application.storage.location=./build/resources/test diff --git a/fintrack-api/src/integration/resources/logback.xml b/website/learning-rule-api/src/test/resources/logback.xml similarity index 100% rename from fintrack-api/src/integration/resources/logback.xml rename to website/learning-rule-api/src/test/resources/logback.xml diff --git a/website/rest-api/build.gradle.kts b/website/rest-api/build.gradle.kts new file mode 100644 index 00000000..6711337e --- /dev/null +++ b/website/rest-api/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("io.micronaut.library") + id("io.micronaut.openapi") +} + +micronaut { + runtime("jetty") + testRuntime("junit5") + + openapi { + server(file("src/contract/rest-api.yaml")) { + apiPackageName = "com.jongsoft.finance.rest.api" + modelPackageName = "com.jongsoft.finance.rest.model" + useAuth = true + useReactive = false + generatedAnnotation = false + } + } +} + +dependencies { + implementation(mn.micronaut.http.validation) + implementation(mn.micronaut.security.annotations) + implementation(mn.micronaut.security) + + implementation(mn.micronaut.serde.api) + implementation(mn.validation) + + implementation(libs.lang) + implementation(libs.bouncy) + + implementation(project(":core")) + implementation(project(":domain")) + + // Needed for running the tests + testRuntimeOnly(mn.micronaut.serde.jackson) + testRuntimeOnly(mn.micronaut.jackson.databind) + testRuntimeOnly(mn.logback.classic) + + testRuntimeOnly(project(":jpa-repository")) + + testImplementation(mn.micronaut.test.rest.assured) + testImplementation(libs.bundles.junit) + testImplementation(mn.micronaut.http.server.jetty) +} diff --git a/website/rest-api/src/contract/components/account-link.yaml b/website/rest-api/src/contract/components/account-link.yaml new file mode 100644 index 00000000..ada33981 --- /dev/null +++ b/website/rest-api/src/contract/components/account-link.yaml @@ -0,0 +1,19 @@ +type: object +required: + - id + - name + - type +properties: + id: + type: integer + description: The identifier of the account + format: int64 + example: 3212 + name: + type: string + description: "The account name, is unique for the user" + example: Fast food & co + type: + type: string + description: "The type of account, as defined by the account type API" + example: creditor diff --git a/website/rest-api/src/contract/components/account-type.yaml b/website/rest-api/src/contract/components/account-type.yaml new file mode 100644 index 00000000..73d0bc27 --- /dev/null +++ b/website/rest-api/src/contract/components/account-type.yaml @@ -0,0 +1,4 @@ +type: string +enum: + - creditor + - debit diff --git a/website/rest-api/src/contract/components/date-range.yaml b/website/rest-api/src/contract/components/date-range.yaml new file mode 100644 index 00000000..fa281b8d --- /dev/null +++ b/website/rest-api/src/contract/components/date-range.yaml @@ -0,0 +1,11 @@ +type: object +required: + - startDate + - endDate +properties: + startDate: + type: string + format: date + endDate: + type: string + format: date diff --git a/website/rest-api/src/contract/components/paged-response.yaml b/website/rest-api/src/contract/components/paged-response.yaml new file mode 100644 index 00000000..6e68b39f --- /dev/null +++ b/website/rest-api/src/contract/components/paged-response.yaml @@ -0,0 +1,26 @@ +type: object +required: + - info +properties: + info: + type: object + required: + - pageSize + - pages + - records + properties: + records: + type: integer + description: The total amount of matches + format: int64 + example: 20 + pages: + type: integer + description: The amount of pages available + format: int32 + example: 2 + pageSize: + type: integer + description: The amount of matches per page + format: int32 + example: 15 diff --git a/website/rest-api/src/contract/components/periodicity.yaml b/website/rest-api/src/contract/components/periodicity.yaml new file mode 100644 index 00000000..a9711a92 --- /dev/null +++ b/website/rest-api/src/contract/components/periodicity.yaml @@ -0,0 +1,7 @@ +type: string +description: The interval the interest is calculated on +example: MONTHS +enum: + - MONTHS + - WEEKS + - YEARS diff --git a/website/rest-api/src/contract/components/requests/account.yaml b/website/rest-api/src/contract/components/requests/account.yaml new file mode 100644 index 00000000..1226718b --- /dev/null +++ b/website/rest-api/src/contract/components/requests/account.yaml @@ -0,0 +1,46 @@ +type: object +required: + - name + - currency + - type +properties: + name: + minLength: 1 + type: string + description: Account name + description: + type: string + description: Account description + minLength: 1 + currency: + type: string + description: > + Currency code of the account. + This currency code must already exist in the system. + minLength: 3 + maxLength: 3 + iban: + pattern: > + ^([A-Z]{2}[ \\-]?[0-9]{2})(?=(?:[ \\-]?[A-Z0-9]){9,30}$)((?:[\\-]?[A-Z0-9]{3,5}){2,7})([ \\-]?[A-Z0-9]{1,3})?$ + type: string + description: IBAN number + bic: + pattern: "^([a-zA-Z]{4}[a-zA-Z]{2}[a-zA-Z0-9]{2}([a-zA-Z0-9]{3})?)$" + type: string + description: The banks BIC number + number: + type: string + description: The account number, in case IBAN is not applicable + interest: + maximum: 1 + minimum: 0 + type: number + format: double + interestPeriodicity: + $ref: "#/components/schemas/periodicity" + type: + description: The type of the account, as defined by the account type API + type: string + imageIcon: + description: The file code for the image of the account + type: string diff --git a/website/rest-api/src/contract/components/requests/balance.yaml b/website/rest-api/src/contract/components/requests/balance.yaml new file mode 100644 index 00000000..9035750f --- /dev/null +++ b/website/rest-api/src/contract/components/requests/balance.yaml @@ -0,0 +1,39 @@ +type: object +description: A filter that can be applied when computing the balance +required: + - range +properties: + accounts: + type: array + items: + type: integer + format: int64 + categories: + type: array + items: + type: integer + format: int64 + contracts: + type: array + items: + type: integer + format: int64 + expenses: + type: array + items: + type: integer + format: int64 + range: + $ref: '#/components/schemas/date-range' + type: + type: string + description: Filter a type of transaction + enum: [ INCOME, EXPENSE, ALL ] + currency: + type: string + description: Filter on a specific currency code. + min: 3 + max: 3 + importSlug: + type: string + description: Filter on the batch import job that created transactions diff --git a/website/rest-api/src/contract/components/requests/category.yaml b/website/rest-api/src/contract/components/requests/category.yaml new file mode 100644 index 00000000..8e95e5df --- /dev/null +++ b/website/rest-api/src/contract/components/requests/category.yaml @@ -0,0 +1,9 @@ +type: object +required: + - name +properties: + name: + type: string + minLength: 1 + description: + type: string diff --git a/website/rest-api/src/contract/components/requests/contract.yaml b/website/rest-api/src/contract/components/requests/contract.yaml new file mode 100644 index 00000000..7af04d3d --- /dev/null +++ b/website/rest-api/src/contract/components/requests/contract.yaml @@ -0,0 +1,26 @@ +type: object +properties: + name: + minLength: 1 + type: string + description: The name of the contract. + example: Contract 1 + description: + type: string + description: The description of the contract. + example: Contract 1 description + company: + description: The company the contract is with. + allOf: + - $ref: "#/components/schemas/account-link" + start: + type: string + description: The start date of the contract. + format: date + end: + type: string + description: The end date of the contract. + format: date + attachmentCode: + type: string + description: The file code to get the digital copy of the contract. diff --git a/website/rest-api/src/contract/components/requests/create-schedule.yaml b/website/rest-api/src/contract/components/requests/create-schedule.yaml new file mode 100644 index 00000000..5438f3f3 --- /dev/null +++ b/website/rest-api/src/contract/components/requests/create-schedule.yaml @@ -0,0 +1,35 @@ +type: object +required: + - name + - amount + - schedule + - transferBetween +properties: + name: + type: string + minLength: 1 + amount: + type: number + format: double + schedule: + type: object + required: + - periodicity + - interval + properties: + periodicity: + $ref: "#/components/schemas/periodicity" + interval: + type: integer + format: int32 + transferBetween: + type: object + description: The source and destination accounts for the transfer + required: + - source + - destination + properties: + source: + $ref: "#/components/schemas/account-link" + destination: + $ref: "#/components/schemas/account-link" diff --git a/website/rest-api/src/contract/components/requests/patch-schedule.yaml b/website/rest-api/src/contract/components/requests/patch-schedule.yaml new file mode 100644 index 00000000..79554a78 --- /dev/null +++ b/website/rest-api/src/contract/components/requests/patch-schedule.yaml @@ -0,0 +1,19 @@ +type: object +properties: + name: + type: string + description: + type: string + schedule: + type: object + required: + - periodicity + - interval + properties: + periodicity: + $ref: "#/components/schemas/periodicity" + interval: + type: integer + format: int32 + activeBetween: + $ref: "#/components/schemas/date-range" diff --git a/website/rest-api/src/contract/components/requests/saving-goal.yaml b/website/rest-api/src/contract/components/requests/saving-goal.yaml new file mode 100644 index 00000000..e31f6330 --- /dev/null +++ b/website/rest-api/src/contract/components/requests/saving-goal.yaml @@ -0,0 +1,16 @@ +type: object +required: + - goal + - name + - targetDate +properties: + name: + minLength: 1 + type: string + goal: + minimum: 0 + exclusiveMinimum: true + type: number + targetDate: + type: string + format: date diff --git a/website/rest-api/src/contract/components/requests/saving-reservation.yaml b/website/rest-api/src/contract/components/requests/saving-reservation.yaml new file mode 100644 index 00000000..0d6d06ea --- /dev/null +++ b/website/rest-api/src/contract/components/requests/saving-reservation.yaml @@ -0,0 +1,7 @@ +type: object +description: A saving reservation for a specific amount +required: [ amount ] +properties: + amount: + type: number + format: double diff --git a/website/rest-api/src/contract/components/requests/transaction.yaml b/website/rest-api/src/contract/components/requests/transaction.yaml new file mode 100644 index 00000000..df1fe361 --- /dev/null +++ b/website/rest-api/src/contract/components/requests/transaction.yaml @@ -0,0 +1,54 @@ +type: object +description: Transaction request +required: + - date + - currency + - description + - amount + - source + - target +properties: + date: + type: string + format: date + interestDate: + type: string + format: date + bookDate: + type: string + format: date + + currency: + minLength: 1 + maxLength: 3 + type: string + description: + maxLength: 1024 + minLength: 1 + type: string + amount: + type: number + format: double + + source: + type: integer + description: The account paying the money + target: + type: integer + description: The account receiving the money + + # classification fields + category: + type: integer + description: The category this transaction falls into + expense: + type: integer + description: The expense grouping this transaction falls into + contract: + type: integer + description: The contract this payment was for + + tags: + type: array + items: + type: string diff --git a/website/rest-api/src/contract/components/responses/account-spending.yaml b/website/rest-api/src/contract/components/responses/account-spending.yaml new file mode 100644 index 00000000..b6eb3a7e --- /dev/null +++ b/website/rest-api/src/contract/components/responses/account-spending.yaml @@ -0,0 +1,16 @@ +type: object +description: + The spending for a specific account +required: + - account + - total + - average +properties: + account: + $ref: '#/components/schemas/account-response' + total: + type: number + format: double + average: + type: number + format: double diff --git a/website/rest-api/src/contract/components/responses/account.yaml b/website/rest-api/src/contract/components/responses/account.yaml new file mode 100644 index 00000000..bc5e44b8 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/account.yaml @@ -0,0 +1,61 @@ +type: object +description: A bank account for the user +required: + - account + - id + - name + - type +allOf: + - $ref: "#/components/schemas/account-link" + - type: object + properties: + description: + type: string + description: The description for the account + iconFileCode: + type: string + description: The file code for the image of the account + account: + type: object + description: Bank identification numbers for the account + properties: + iban: + type: string + bic: + type: string + number: + type: string + currency: + type: string + interest: + description: > + The interest information for the account, only used for loans, + debts and mortgage + type: object + required: + - periodicity + - interest + properties: + periodicity: + description: The interval the interest is calculated on + $ref: "#/components/schemas/periodicity" + interest: + type: number + description: The amount of interest that is owed + format: double + example: 0.0754 + history: + type: object + description: Transaction history information for the account + required: + - firstTransaction + - lastTransaction + properties: + firstTransaction: + type: string + description: The date of the first recorded transaction for the account + format: date + lastTransaction: + type: string + description: The date of the latest recorded transaction for the account + format: date diff --git a/website/rest-api/src/contract/components/responses/balance.yaml b/website/rest-api/src/contract/components/responses/balance.yaml new file mode 100644 index 00000000..2a4e8851 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/balance.yaml @@ -0,0 +1,7 @@ +type: object +required: + - balance +properties: + balance: + type: number + format: double diff --git a/website/rest-api/src/contract/components/responses/category.yaml b/website/rest-api/src/contract/components/responses/category.yaml new file mode 100644 index 00000000..45c48625 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/category.yaml @@ -0,0 +1,19 @@ +type: object +required: + - id + - label +properties: + id: + type: integer + format: int64 + description: The unique identifier for the category + name: + type: string + description: The name for the category + description: + type: string + description: The description for the category + lastUsed: + type: string + format: date + description: The last date the category was used diff --git a/website/rest-api/src/contract/components/responses/contract.yaml b/website/rest-api/src/contract/components/responses/contract.yaml new file mode 100644 index 00000000..9a20b05f --- /dev/null +++ b/website/rest-api/src/contract/components/responses/contract.yaml @@ -0,0 +1,45 @@ +type: object +required: + - id + - name + - company + - start + - end +properties: + id: + type: integer + description: The identifier of the contract + format: int64 + name: + type: string + description: The name of the contract + example: Cable company + description: + type: string + description: The description for the contract + contractAvailable: + type: boolean + description: Indicator for an digital copy of the contract being present + fileToken: + type: string + description: The file token to get the digital copy + start: + type: string + description: The start date of the contract + format: date + end: + type: string + description: The end date of the contract + format: date + terminated: + type: boolean + description: Indicator that the contract has ended and is closed by the + user + notification: + type: boolean + description: Indicator if a pre-emptive warning is active before the contract + end date + company: + description: The company / account the contract is with + allOf: + - $ref: "#/components/schemas/account-link" diff --git a/website/rest-api/src/contract/components/responses/dated-balance.yaml b/website/rest-api/src/contract/components/responses/dated-balance.yaml new file mode 100644 index 00000000..a3f71ca2 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/dated-balance.yaml @@ -0,0 +1,9 @@ +allOf: + - $ref: '#/components/schemas/balance-response' + - type: object + required: + - date + properties: + date: + type: string + format: date diff --git a/website/rest-api/src/contract/components/responses/export-profile.yaml b/website/rest-api/src/contract/components/responses/export-profile.yaml new file mode 100644 index 00000000..cf0d65a2 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/export-profile.yaml @@ -0,0 +1,46 @@ +type: object +properties: + accounts: + type: array + items: + allOf: + - $ref: '#/components/schemas/account-response' + - type: object + properties: + icon: + type: string + description: The icon of the account, in a base64 encoded string + categories: + type: array + items: { $ref: '#/components/schemas/category-response' } + budget: + type: array + items: + type: object + properties: + period: { $ref: '#/components/schemas/date-range' } + expenses: + type: array + items: + type: object + properties: + name: { type: string } + expected: { type: number } + contract: + type: array + items: + allOf: + - $ref: '#/components/schemas/contract-response' + - type: object + properties: + contract: + type: string + description: The HEX encoded attached contract file + tags: + type: array + items: + type: string + transaction: + type: array + items: { $ref: '#/components/schemas/transaction-response' } +# todo add transaction rules diff --git a/website/rest-api/src/contract/components/responses/insight.yaml b/website/rest-api/src/contract/components/responses/insight.yaml new file mode 100644 index 00000000..50e0e6df --- /dev/null +++ b/website/rest-api/src/contract/components/responses/insight.yaml @@ -0,0 +1,53 @@ +type: object +required: + - type + - category + - severity + - score + - detected-date + - message + - metadata +properties: + type: + description: The type of insight + type: string + enum: + - UNUSUAL_AMOUNT + - UNUSUAL_FREQUENCY + - UNUSUAL_MERCHANT + - UNUSUAL_TIMING + - POTENTIAL_DUPLICATE + - BUDGET_EXCEEDED + - SPENDING_SPIKE + - UNUSUAL_LOCATION + category: + description: The category of the insight + type: string + severity: + description: The severity of the insight + type: string + enum: + - INFO + - WARNING + - ALERT + score: + description: The confidence score of the insight (0.0 to 1.0) + type: number + format: double + min: 0.0 + max: 1.0 + detected-date: + description: The date when the insight was detected + type: string + format: date + message: + description: The message describing the insight + type: string + transaction-id: + description: Any involved transaction identifier + type: integer + metadata: + description: Additional metadata for the insight + type: object + additionalProperties: + type: object diff --git a/website/rest-api/src/contract/components/responses/json-error.yaml b/website/rest-api/src/contract/components/responses/json-error.yaml new file mode 100644 index 00000000..685de80e --- /dev/null +++ b/website/rest-api/src/contract/components/responses/json-error.yaml @@ -0,0 +1,15 @@ +type: object +required: + - message +properties: + _links: + type: object + additionalProperties: true + message: + type: string + logref: + type: string + nullable: true + path: + type: string + nullable: true diff --git a/website/rest-api/src/contract/components/responses/paged-account.yaml b/website/rest-api/src/contract/components/responses/paged-account.yaml new file mode 100644 index 00000000..2b724f75 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/paged-account.yaml @@ -0,0 +1,9 @@ +allOf: + - $ref: '#/components/schemas/paged-response' + - type: object + required: + - content + properties: + content: + type: array + items: { $ref: '#/components/schemas/account-response' } diff --git a/website/rest-api/src/contract/components/responses/paged-category.yaml b/website/rest-api/src/contract/components/responses/paged-category.yaml new file mode 100644 index 00000000..788ddcfd --- /dev/null +++ b/website/rest-api/src/contract/components/responses/paged-category.yaml @@ -0,0 +1,9 @@ +allOf: + - $ref: '#/components/schemas/paged-response' + - type: object + required: + - content + properties: + content: + type: array + items: { $ref: '#/components/schemas/category-response' } diff --git a/website/rest-api/src/contract/components/responses/paged-contract.yaml b/website/rest-api/src/contract/components/responses/paged-contract.yaml new file mode 100644 index 00000000..dee0006b --- /dev/null +++ b/website/rest-api/src/contract/components/responses/paged-contract.yaml @@ -0,0 +1,9 @@ +allOf: + - $ref: '#/components/schemas/paged-response' + - type: object + required: + - content + properties: + content: + type: array + items: { $ref: '#/components/schemas/contract-response' } diff --git a/website/rest-api/src/contract/components/responses/paged-transaction.yaml b/website/rest-api/src/contract/components/responses/paged-transaction.yaml new file mode 100644 index 00000000..f236472c --- /dev/null +++ b/website/rest-api/src/contract/components/responses/paged-transaction.yaml @@ -0,0 +1,9 @@ +allOf: + - $ref: '#/components/schemas/paged-response' + - type: object + required: + - content + properties: + content: + type: array + items: { $ref: '#/components/schemas/transaction-response' } diff --git a/website/rest-api/src/contract/components/responses/partitioned-balance.yaml b/website/rest-api/src/contract/components/responses/partitioned-balance.yaml new file mode 100644 index 00000000..84074d59 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/partitioned-balance.yaml @@ -0,0 +1,10 @@ +type: object +required: + - balance + - partition +allOf: + - $ref: '#/components/schemas/balance-response' + - type: object + properties: + partition: + type: string diff --git a/website/rest-api/src/contract/components/responses/pattern.yaml b/website/rest-api/src/contract/components/responses/pattern.yaml new file mode 100644 index 00000000..ffb1e231 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/pattern.yaml @@ -0,0 +1,25 @@ +type: object +properties: + type: + type: string + enum: + - RECURRING_MONTHLY + - RECURRING_WEEKLY + - SEASONAL + - INCREASING_TREND + - DECREASING_TREND + category: + type: string + confidence: + type: number + format: double + min: 0.0 + max: 1.0 + detected-date: + type: string + format: date + metadata: + description: Additional metadata for the insight + type: object + additionalProperties: + type: object diff --git a/website/rest-api/src/contract/components/responses/saving-goal.yaml b/website/rest-api/src/contract/components/responses/saving-goal.yaml new file mode 100644 index 00000000..3c31c858 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/saving-goal.yaml @@ -0,0 +1,45 @@ +type: object +required: + - goal + - id + - reserved + - targetDate +properties: + id: + type: integer + description: The identifier of the saving goal + format: int64 + example: 132 + name: + type: string + description: The name of the saving goal + example: Car replacement + description: + type: string + description: The description of the saving goal + schedule: + description: The schedule that allocations are created automatically + $ref: "#/components/schemas/schedule-response" + goal: + type: number + description: The goal one wishes to achieve by the end date + example: 1500.4 + reserved: + type: number + description: The amount of money reserved for this saving goal + example: 200 + installments: + type: number + description: > + The amount of money allocated each interval, only when schedule is set + example: 25.5 + targetDate: + type: string + description: The date before which the goal must be met + format: date + example: 2021-01-12 + monthsLeft: + type: integer + description: The amount of months left until the target date + format: int64 + example: 23 diff --git a/website/rest-api/src/contract/components/responses/schedule.yaml b/website/rest-api/src/contract/components/responses/schedule.yaml new file mode 100644 index 00000000..7a1e2ede --- /dev/null +++ b/website/rest-api/src/contract/components/responses/schedule.yaml @@ -0,0 +1,16 @@ +type: object +description: The schedule that allocations are created automatically +required: + - interval + - periodicity +properties: + periodicity: + description: The type of the interval + example: MONTHS + allOf: + - $ref: "#/components/schemas/periodicity" + interval: + type: integer + description: The actual interval + format: int32 + example: 3 diff --git a/website/rest-api/src/contract/components/responses/transaction-schedule.yaml b/website/rest-api/src/contract/components/responses/transaction-schedule.yaml new file mode 100644 index 00000000..250f6e8e --- /dev/null +++ b/website/rest-api/src/contract/components/responses/transaction-schedule.yaml @@ -0,0 +1,31 @@ +type: object +properties: + id: + type: integer + format: int64 + name: + type: string + description: The name for the transaction schedule + description: + type: string + description: The description for the transaction schedule + activeBetween: + $ref: "#/components/schemas/date-range" + schedule: + $ref: "#/components/schemas/schedule-response" + amount: + type: number + format: double + forContract: + $ref: "#/components/schemas/contract-response" + transferBetween: + type: object + description: The source and destination accounts for the transfer + required: + - source + - destination + properties: + source: + $ref: "#/components/schemas/account-link" + destination: + $ref: "#/components/schemas/account-link" diff --git a/website/rest-api/src/contract/components/responses/transaction.yaml b/website/rest-api/src/contract/components/responses/transaction.yaml new file mode 100644 index 00000000..9d54d163 --- /dev/null +++ b/website/rest-api/src/contract/components/responses/transaction.yaml @@ -0,0 +1,92 @@ +type: object +description: A transaction representing a transfer of money between two accounts +properties: + id: + type: integer + description: The identifier of this transaction + format: int64 + example: 1 + description: + type: string + description: The description of the transaction + example: Purchase of flowers + currency: + type: string + description: The currency the transaction was in + example: EUR + amount: + type: number + description: The amount of money transferred from one account into the other + format: double + example: 30.5 + metadata: + description: The meta-information of the transaction + type: object + properties: + category: + type: string + description: The category this transaction was linked to + example: Food related expenses + budget: + type: string + description: The budget expense this transaction contributes to + example: Dining out + contract: + type: string + description: This transaction is part of this contract + example: Weekly dining + import: + type: string + description: The import job that created the transaction + example: 0b9b79faddd9ad388f3aa3b59048b7cd + tags: + type: array + description: The tags that the transaction has + example: ["food", "dining"] + items: + type: string + type: + type: string + description: The type of transaction + enum: + - CREDIT + - DEBIT + - TRANSFER + dates: + description: All dates relevant for this transaction + type: object + properties: + transaction: + type: string + description: The date this transaction was created + format: date + booked: + type: string + description: The date the transaction was recorded into the books + format: date + interest: + type: string + description: The date from which the transaction gets interest applied + format: date + destination: + description: The account where the money went to + allOf: + - $ref: "#/components/schemas/account-link" + source: + description: The account where the money came from + allOf: + - $ref: "#/components/schemas/account-link" + split: + type: array + description: "The multi-line split of the transaction, eg: purchased items" + items: + type: object + required: + - description + - amount + properties: + description: + type: string + amount: + type: number + format: double diff --git a/website/rest-api/src/contract/parameters/accounts.yaml b/website/rest-api/src/contract/parameters/accounts.yaml new file mode 100644 index 00000000..74e9e7a9 --- /dev/null +++ b/website/rest-api/src/contract/parameters/accounts.yaml @@ -0,0 +1,8 @@ +name: account +in: query +schema: + type: array + items: + type: integer + format: int64 + example: 12 diff --git a/website/rest-api/src/contract/parameters/budgets.yaml b/website/rest-api/src/contract/parameters/budgets.yaml new file mode 100644 index 00000000..ca885222 --- /dev/null +++ b/website/rest-api/src/contract/parameters/budgets.yaml @@ -0,0 +1,8 @@ +name: category +in: query +schema: + type: array + items: + type: integer + format: int64 + example: 12 diff --git a/website/rest-api/src/contract/parameters/categories.yaml b/website/rest-api/src/contract/parameters/categories.yaml new file mode 100644 index 00000000..9ff26509 --- /dev/null +++ b/website/rest-api/src/contract/parameters/categories.yaml @@ -0,0 +1,8 @@ +name: expense +in: query +schema: + type: array + items: + type: integer + format: int64 + example: 12 diff --git a/website/rest-api/src/contract/parameters/contracts.yaml b/website/rest-api/src/contract/parameters/contracts.yaml new file mode 100644 index 00000000..cb2ef2e6 --- /dev/null +++ b/website/rest-api/src/contract/parameters/contracts.yaml @@ -0,0 +1,8 @@ +name: contract +in: query +schema: + type: array + items: + type: integer + format: int64 + example: 12 diff --git a/website/rest-api/src/contract/parameters/endDate.yaml b/website/rest-api/src/contract/parameters/endDate.yaml new file mode 100644 index 00000000..2103b2e7 --- /dev/null +++ b/website/rest-api/src/contract/parameters/endDate.yaml @@ -0,0 +1,6 @@ +name: endDate +required: true +in: query +schema: + type: string + format: date diff --git a/website/rest-api/src/contract/parameters/import-id.yaml b/website/rest-api/src/contract/parameters/import-id.yaml new file mode 100644 index 00000000..967303c5 --- /dev/null +++ b/website/rest-api/src/contract/parameters/import-id.yaml @@ -0,0 +1,5 @@ +name: importSlug +required: false +in: query +schema: + type: string diff --git a/website/rest-api/src/contract/parameters/name.yaml b/website/rest-api/src/contract/parameters/name.yaml new file mode 100644 index 00000000..df6e7077 --- /dev/null +++ b/website/rest-api/src/contract/parameters/name.yaml @@ -0,0 +1,5 @@ +name: name +description: Filter based on a partial name +in: query +schema: + type: string diff --git a/website/rest-api/src/contract/parameters/number-of-results.yaml b/website/rest-api/src/contract/parameters/number-of-results.yaml new file mode 100644 index 00000000..45b46823 --- /dev/null +++ b/website/rest-api/src/contract/parameters/number-of-results.yaml @@ -0,0 +1,6 @@ +name: numberOfResults +description: The number of accounts to be returned +in: query +required: true +schema: + type: integer diff --git a/website/rest-api/src/contract/parameters/offset.yaml b/website/rest-api/src/contract/parameters/offset.yaml new file mode 100644 index 00000000..c310ac54 --- /dev/null +++ b/website/rest-api/src/contract/parameters/offset.yaml @@ -0,0 +1,6 @@ +name: offset +description: The number of rows to skip in the result set +in: query +required: true +schema: + type: integer diff --git a/website/rest-api/src/contract/parameters/startDate.yaml b/website/rest-api/src/contract/parameters/startDate.yaml new file mode 100644 index 00000000..d18e86f5 --- /dev/null +++ b/website/rest-api/src/contract/parameters/startDate.yaml @@ -0,0 +1,6 @@ +name: startDate +required: true +in: query +schema: + type: string + format: date diff --git a/website/rest-api/src/contract/parameters/tags.yaml b/website/rest-api/src/contract/parameters/tags.yaml new file mode 100644 index 00000000..281b05d5 --- /dev/null +++ b/website/rest-api/src/contract/parameters/tags.yaml @@ -0,0 +1,8 @@ +name: tag +required: false +in: query +schema: + type: array + items: + type: string + example: groceries diff --git a/website/rest-api/src/contract/responses/400-response.yaml b/website/rest-api/src/contract/responses/400-response.yaml new file mode 100644 index 00000000..1a386984 --- /dev/null +++ b/website/rest-api/src/contract/responses/400-response.yaml @@ -0,0 +1,4 @@ +description: Bad Request +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/rest-api/src/contract/responses/401-response.yaml b/website/rest-api/src/contract/responses/401-response.yaml new file mode 100644 index 00000000..cacaf0e4 --- /dev/null +++ b/website/rest-api/src/contract/responses/401-response.yaml @@ -0,0 +1,4 @@ +description: Not authenticated +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/rest-api/src/contract/responses/403-response.yaml b/website/rest-api/src/contract/responses/403-response.yaml new file mode 100644 index 00000000..e205f822 --- /dev/null +++ b/website/rest-api/src/contract/responses/403-response.yaml @@ -0,0 +1,4 @@ +description: Not authorized for resource +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/rest-api/src/contract/responses/404-response.yaml b/website/rest-api/src/contract/responses/404-response.yaml new file mode 100644 index 00000000..d083af34 --- /dev/null +++ b/website/rest-api/src/contract/responses/404-response.yaml @@ -0,0 +1,4 @@ +description: Resource not found +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/rest-api/src/contract/responses/paged-transactions.yaml b/website/rest-api/src/contract/responses/paged-transactions.yaml new file mode 100644 index 00000000..3a687c49 --- /dev/null +++ b/website/rest-api/src/contract/responses/paged-transactions.yaml @@ -0,0 +1,4 @@ +description: The transactions matching the filters in a paged way +content: + application/json: + schema: { $ref: '#/components/schemas/paged-transaction-response' } diff --git a/website/rest-api/src/contract/rest-api.yaml b/website/rest-api/src/contract/rest-api.yaml new file mode 100644 index 00000000..5a2cce74 --- /dev/null +++ b/website/rest-api/src/contract/rest-api.yaml @@ -0,0 +1,1142 @@ +openapi: 3.1.0 +info: + title: Pledger.io REST API + version: 3.0.0 + contact: + name: Jong Soft Development + url: https://github.com/pledger-io/rest-application + license: + name: MIT + url: https://opensource.org/licenses/MIT + +tags: + - name: i18n + description: i18n + - name: account-fetcher + description: Operations to fetch account information + - name: account-commands + description: Operations to manipulate account information + - name: transaction-fetcher + description: Operations to fetch transaction information + +security: + - bearerAuth: [] + +paths: + /v2/api/account-types: + get: + operationId: getAccountTypes + tags: [ system-information ] + summary: Get account types + description: > + Get a listing of all available account types in the system. + responses: + "200": + description: A list of all available account types + content: + application/json: + schema: + type: array + items: + type: string + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/export: + get: + operationId: exportUserAccount + tags: [ export ] + summary: Export profile + description: > + Create an export of all information that belongs to the authenticated + user. + responses: + "200": + description: The JSON of the export + content: + application/json: + schema: { $ref: '#/components/schemas/export-profile-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Account creation / search + #------------------------------------------------------------------- + + /v2/api/accounts: + get: + operationId: getAccounts + tags: [ account-fetcher ] + summary: Search accounts + description: > + List available accounts that the current user has access to + parameters: + - name: type + description: The account type to filter on + in: query + schema: + type: array + items: + type: string + - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/number-of-results' + - name: accountName + in: query + description: A partial name of an account to look for + schema: + type: string + responses: + "200": + description: The list of accounts + content: + application/json: + schema: { $ref: '#/components/schemas/paged-account-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createAccount + tags: [ account-command ] + summary: Create account + description: > + Create a new account for the current user + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/account-request' } + responses: + "201": + description: The created account + content: + application/json: + schema: { $ref: '#/components/schemas/account-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/accounts/top-by-spending: + get: + operationId: getTopAccountsBySpending + tags: [ account-fetcher ] + summary: Top account by spending + description: > + Returns the top spending accounts for the current user within the optional date range. + You can filter by account type or include only your own accounts using useOwnAccounts. + The response is an array ordered by highest spending, each item providing account and amount details. + parameters: + - $ref: '#/components/parameters/start-date' + - $ref: '#/components/parameters/end-date' + - name: type + description: Filter on the type of account. Must not be set when useOwnAccounts is provided. + in: query + schema: { $ref: '#/components/schemas/account-type' } + - name: useOwnAccounts + description: Filter on all of the users own accounts. If set the type filter will be ignored. + in: query + schema: + type: boolean + responses: + "200": + description: The list of top spending accounts + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/account-spending-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Account manipulations + #------------------------------------------------------------------- + + /v2/api/accounts/{id}: + parameters: + - name: id + description: The identifier of the account + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + get: + operationId: getAccountById + tags: [ account-fetcher ] + summary: Get account + description: > + Get account details by id for the current user + responses: + "200": + description: The created account + content: + application/json: + schema: { $ref: '#/components/schemas/account-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": + description: Saving goal not found + put: + operationId: updateAccountById + tags: [ account-command ] + summary: Update account + description: > + Update account details by id for the current user + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/account-request' } + responses: + "200": + description: The created account + content: + application/json: + schema: { $ref: '#/components/schemas/account-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": + description: Saving goal not found + delete: + operationId: deleteAccountById + tags: [ account-command ] + summary: Delete account + description: > + Delete account details by id for the current user + responses: + "204": + description: Confirmation that the account was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + #------------------------------------------------------------------- + # Account saving goals + #------------------------------------------------------------------- + + /v2/api/accounts/{id}/saving-goals: + parameters: + - name: id + description: The identifier of the account + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + get: + operationId: getSavingGoalsForAccount + tags: [ account-fetcher ] + summary: List saving goals + description: > + Get the saving goals for the account, note this can only be called if the account type is SAVINGS. + responses: + "200": + description: The list of saving goals + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/saving-goal-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + post: + operationId: createSavingGoalForAccount + tags: [ account-command ] + summary: Create saving goal + description: > + Create a new saving goal for the account, note this can only be called if the account type is SAVINGS. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/saving-goal-request' } + responses: + "201": + description: The created saving goal + content: + application/json: + schema: { $ref: '#/components/schemas/saving-goal-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/accounts/{id}/saving-goals/{goalId}: + parameters: + - name: id + description: The identifier of the account + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + - name: goalId + description: The identifier of the saving goal + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + put: + operationId: updateSavingGoalForAccount + tags: [ account-command ] + summary: Update saving goal + description: > + Update a saving goal for the account, note this can only be called if the account type is SAVINGS. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/saving-goal-request' } + responses: + "200": + description: The updated saving goal + content: + application/json: + schema: { $ref: '#/components/schemas/saving-goal-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + delete: + operationId: deleteSavingGoalForAccount + tags: [ account-command ] + summary: Delete saving goal + description: > + Remove a saving goal for the account, note this can only be called if the account type is SAVINGS. + responses: + "204": + description: Confirmation that the saving goal was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + /v2/api/accounts/{id}/saving-goals/{goalId}/make-reservation: + parameters: + - name: id + description: The identifier of the account + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + - name: goalId + description: The identifier of the saving goal + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + post: + operationId: makeReservationForSavingGoal + tags: [ account-command ] + summary: Reserve money saving goal + description: > + Make a reservation for the saving goal, note this can only be called if the account type is SAVINGS. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/saving-reservation-request' } + responses: + "204": + description: Confirmation that a reservation was added to the saving goal + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + #------------------------------------------------------------------- + # Transaction searching + #------------------------------------------------------------------- + + /v2/api/transactions: + get: + operationId: findTransactionBy + tags: [ transaction-fetcher ] + summary: Search transactions + description: > + Search the current user's transactions using optional filters such as date range, + account, budget/expense, category, contract, tags, description text, type, import id, + and currency. Results are returned as a paged list that can be navigated with offset + and number-of-results parameters. + parameters: + - $ref: '#/components/parameters/start-date' + - $ref: '#/components/parameters/end-date' + - $ref: '#/components/parameters/number-of-results' + - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/account-filter' + - $ref: '#/components/parameters/expense-filter' + - $ref: '#/components/parameters/category-filter' + - $ref: '#/components/parameters/contract-filter' + - $ref: '#/components/parameters/tag-filter' + - $ref: '#/components/parameters/import-id-filter' + - name: description + in: query + description: Filter on a part of the transaction description + schema: + type: string + - name: type + in: query + description: Filter on the transaction type + schema: + type: string + enum: [ 'INCOME', 'EXPENSE', 'TRANSFER' ] + - name: currency + in: query + description: Filter based on the currency code + schema: + type: string + minLength: 3 + maxLength: 3 + example: 'EUR' + responses: + "200": { $ref: '#/components/responses/paged-transaction' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createTransaction + tags: [ transaction-command ] + summary: Create transaction + description: Create a new transaction in the system + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-request' } + responses: + "201": + description: The created transaction + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Transaction control + #------------------------------------------------------------------- + + /v2/api/transactions/{id}: + parameters: + - name: id + description: The identifier of the transaction + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + get: + operationId: getTransactionById + tags: [ transaction-fetcher ] + summary: Fetch transaction + description: Fetch a single transaction from the system + responses: + "200": + description: The matching transaction + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + put: + operationId: updateTransaction + tags: [ transaction-command ] + summary: Update transaction + description: Update a transaction in the system + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-request' } + responses: + "200": + description: The updated transaction + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + patch: + operationId: splitTransaction + tags: [ transaction-command ] + summary: Split transaction + description: Provide the new split for the transaction + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + properties: + description: + type: string + amount: + type: number + responses: + "200": + description: The updated transaction + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteTransaction + tags: [ transaction-command ] + summary: Delete transaction + description: Remove a transaction from the system + responses: + "204": + description: Confirmation that a transaction was removed + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + /v2/api/balance: + post: + operationId: computeBalanceWithFilter + tags: [ statistics-balance ] + summary: Compute balance + description: > + Computes a balance for the user based on the filters applied. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/balance-request' } + responses: + "200": + description: The balance for the given filters + content: + application/json: + schema: { $ref: '#/components/schemas/balance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/balance/{partition}: + parameters: + - name: partition + in: path + required: true + schema: + type: string + enum: [ account, budget, category ] + post: + operationId: computePartitionedBalanceWithFilter + tags: [ statistics-balance ] + summary: Compute partitioned balance + description: > + Computes a balance for the user based on the filters applied. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/balance-request' } + responses: + "200": + description: The balance for the given filters + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/partitioned-balance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/balance/by-date/{type}: + parameters: + - name: type + in: path + required: true + schema: + type: string + enum: [ daily, monthly ] + post: + operationId: computeBalanceGroupedByDate + tags: [ statistics-balance ] + summary: Compute date partitioned balance + description: > + Computes the balance grouping it by date as indicated in the `type`. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/balance-request' } + responses: + "200": + description: The balance for the given filters + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/dated-balance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/detected/insight: + get: + operationId: getInsightsByFilters + tags: [ insight ] + summary: Fetch insights + description: > + Retrieve detected financial insights for a given year and optional month. + Returns an array of insights for the current user. + parameters: + - name: year + in: query + schema: + type: integer + min: 1990 + max: 9999 + - name: month + in: query + schema: + type: integer + min: 1 + max: 12 + responses: + "200": + description: The list of detected insights + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/insight-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/detected/pattern: + get: + operationId: getPatternsByFilters + tags: [ insight ] + summary: Fetch patterns + description: > + Retrieve detected spending patterns for a given year and optional month. + Returns an array of patterns for the current user. + parameters: + - name: year + in: query + schema: + type: integer + min: 1990 + max: 9999 + - name: month + in: query + schema: + type: integer + min: 1 + max: 12 + responses: + "200": + description: The list of detected patterns + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/pattern-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Category creation / search + #------------------------------------------------------------------- + + /v2/api/categories: + get: + operationId: findCategoriesBy + tags: [ category-fetcher ] + summary: Search categories + description: > + Retrieve a paged list of categories that match the optional name filter. + Use offset and number-of-results to paginate through the result set. + parameters: + - $ref: '#/components/parameters/name-filter' + - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/number-of-results' + responses: + "200": + description: The categories matching the filters + content: + application/json: + schema: { $ref: '#/components/schemas/paged-category-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createCategory + tags: [ category-command ] + summary: Create category + description: > + Create a new category using the provided request body. Returns the created + category with its generated identifier and details. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/category-request' } + responses: + "201": + description: The created category + content: + application/json: + schema: { $ref: '#/components/schemas/category-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Category command + #------------------------------------------------------------------- + + /v2/api/categories/{id}: + parameters: + - name: id + description: The identifier of the category + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + get: + operationId: getCategoryById + tags: [ category-fetcher ] + summary: Fetch category + description: > + Retrieve a single category by its identifier. Returns the full category details + if it exists and the user has access. + responses: + "200": + description: The category + content: + application/json: + schema: { $ref: '#/components/schemas/category-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + put: + operationId: updateCategory + tags: [ category-command ] + summary: Update category + description: > + Update an existing category identified by its id using the provided request body. + Returns the updated category details. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/category-request' } + responses: + "200": + description: The updated category + content: + application/json: + schema: { $ref: '#/components/schemas/category-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteCategoryById + tags: [ category-command ] + summary: Delete category + description: > + Delete a category by its identifier. On success, a 204 No Content is returned + to confirm removal. + responses: + "204": + description: Confirmation that a transaction was removed + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + #------------------------------------------------------------------- + # Contract creation / search + #------------------------------------------------------------------- + + /v2/api/contracts: + get: + operationId: findContractBy + tags: [ contract-fetcher ] + summary: Search contracts + description: > + Retrieve contracts filtered by optional name and status (ACTIVE or INACTIVE). + Returns a list of matching contracts for the current user. + parameters: + - $ref: '#/components/parameters/name-filter' + - name: status + in: query + schema: + type: string + enum: [ ACTIVE, INACTIVE ] + responses: + "200": + description: The contracts matching the filters + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/contract-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createContract + tags: [ contract-command ] + summary: Create contract + description: Create a new contract in the application. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/contract-request' } + responses: + "201": + description: The created contract + content: + application/json: + schema: { $ref: '#/components/schemas/contract-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Category command + #------------------------------------------------------------------- + + /v2/api/contracts/{id}: + parameters: + - name: id + description: The identifier of the contract + in: path + required: true + schema: + type: integer + format: int64 + example: 1234567890 + get: + operationId: getContractById + tags: [ contract-fetcher ] + summary: Fetch contract + description: Fetch a single contract by its unique identifier. + responses: + "200": + description: The contract + content: + application/json: + schema: { $ref: '#/components/schemas/contract-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + put: + operationId: updateContract + tags: [ contract-command ] + summary: Update contract + description: Update an existing contract in the application. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/contract-request' } + responses: + "200": + description: The updated category + content: + application/json: + schema: { $ref: '#/components/schemas/contract-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteContractById + tags: [ contract-command ] + summary: Delete contract + description: Marks a contract that it is no longer relevant in the application. + responses: + "204": + description: Confirmation that a contract was removed + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + /v2/api/contracts/{id}/warn-before-expiration: + parameters: + - name: id + description: The identifier of the contract + in: path + required: true + schema: + type: integer + format: int64 + example: 123 + post: + operationId: warnBeforeContractExpiry + tags: [ contract-command ] + summary: Warn before end + description: Send out a warning e-mail before the contract expiry date + responses: + "204": + description: Confirmation that a contract was removed + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + #------------------------------------------------------------------- + # Tag creation / search + #------------------------------------------------------------------- + + /v2/api/tags: + get: + operationId: findTagsBy + tags: [ tag-fetcher ] + summary: Search tags + description: > + Return a list of tag names matching the optional name filter for the current user. + parameters: + - $ref: '#/components/parameters/name-filter' + responses: + "200": + description: The tags matching the filters + content: + application/json: + schema: + type: array + items: + type: string + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createTag + tags: [ tag-command ] + summary: Create tag + description: > + Create a new tag with the given name for the current user. Returns 204 on success. + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "204": + description: Confirmation the tag was created + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/tags/{tag}: + parameters: + - name: tag + schema: + type: string + in: path + required: true + delete: + operationId: deleteTag + tags: [ tag-command ] + summary: Delete tag + description: > + Delete an existing tag by its name. Returns 204 on success; if the tag does not exist, + a 404 may be returned. + responses: + "204": + description: Confirmation the tag was deleted + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + #------------------------------------------------------------------- + # Scheduling creation / search + #------------------------------------------------------------------- + + /v2/api/schedules: + get: + operationId: findScheduleByFilter + tags: [ schedule-fetcher ] + summary: Search schedules + description: > + List transaction schedules matching optional filters on account and contract. + Returns all schedules the current user has access to. + parameters: + - name: account + description: > + Identifiers of accounts to filter for. Can be either a source or destination + for the schedule. + in: query + schema: + type: array + items: + type: integer + - name: contract + in: query + description: > + Identifiers of contracts to filter on. + schema: + type: array + items: + type: integer + responses: + "200": + description: The list of schedules based upon the filters + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/transaction-schedule-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createSchedule + tags: [ schedule-command ] + summary: Create schedule + description: > + Create a new transaction schedule using the provided request body. Returns the + created schedule including its identifier and configuration. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/schedule-request' } + responses: + "201": + description: Confirmation the schedule was created + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-schedule-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/schedules/{id}: + parameters: + - name: id + description: The identifier of the schedule + in: path + required: true + schema: + type: integer + format: int64 + example: 123 + get: + operationId: findScheduleById + tags: [ schedule-fetcher ] + summary: Fetch schedule + description: > + Retrieve a single transaction schedule by its identifier. Returns full details + of the schedule if found. + responses: + "200": + description: The schedule + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-schedule-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + patch: + operationId: updateSchedule + tags: [ schedule-command ] + summary: Update schedule + description: > + Partially update an existing transaction schedule using the provided patch + request. Returns the updated schedule. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/schedule-patch-request' } + responses: + "200": + description: The updated schedule + content: + application/json: + schema: { $ref: '#/components/schemas/transaction-schedule-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteSchedule + tags: [ schedule-command ] + summary: Delete schedule + description: > + Delete a transaction schedule by its identifier. Returns 204 on successful + deletion. + responses: + "204": + description: Confirmation the schedule was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + +components: + responses: + 400: { $ref: './responses/400-response.yaml' } + 401: { $ref: './responses/401-response.yaml' } + 403: { $ref: './responses/403-response.yaml' } + 404: { $ref: './responses/404-response.yaml' } + + paged-transaction: { $ref: './responses/paged-transactions.yaml' } + + parameters: + # pagination related parameters + number-of-results: { $ref: 'parameters/number-of-results.yaml' } + offset: { $ref: 'parameters/offset.yaml' } + + # date range related parameters + start-date: { $ref: 'parameters/startDate.yaml' } + end-date: { $ref: 'parameters/endDate.yaml' } + + # filter based parameters + account-filter: { $ref: 'parameters/accounts.yaml' } + expense-filter: { $ref: 'parameters/budgets.yaml' } + category-filter: { $ref: 'parameters/categories.yaml' } + contract-filter: { $ref: 'parameters/contracts.yaml' } + tag-filter: { $ref: 'parameters/tags.yaml' } + import-id-filter: { $ref: 'parameters/import-id.yaml' } + name-filter: { $ref: 'parameters/name.yaml' } + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: +# For both request and response + periodicity: { $ref: 'components/periodicity.yaml' } + account-type: { $ref: 'components/account-type.yaml' } + account-link: { $ref: 'components/account-link.yaml' } + date-range: { $ref: 'components/date-range.yaml' } + +# All the request objects + account-request: { $ref: 'components/requests/account.yaml' } + saving-goal-request: { $ref: 'components/requests/saving-goal.yaml' } + saving-reservation-request: { $ref: 'components/requests/saving-reservation.yaml' } + transaction-request: { $ref: 'components/requests/transaction.yaml' } + category-request: { $ref: 'components/requests/category.yaml' } + contract-request: { $ref: 'components/requests/contract.yaml' } + schedule-request: { $ref: 'components/requests/create-schedule.yaml' } + schedule-patch-request: { $ref: 'components/requests/patch-schedule.yaml' } + balance-request: { $ref: 'components/requests/balance.yaml' } + +# All the response objects + account-response: { $ref: 'components/responses/account.yaml' } + account-spending-response: { $ref: 'components/responses/account-spending.yaml' } + schedule-response: { $ref: 'components/responses/schedule.yaml' } + paged-response: { $ref: 'components/paged-response.yaml' } + paged-account-response: { $ref: 'components/responses/paged-account.yaml' } + saving-goal-response: { $ref: 'components/responses/saving-goal.yaml' } + json-error-response: { $ref: 'components/responses/json-error.yaml' } + + transaction-response: { $ref: 'components/responses/transaction.yaml' } + paged-transaction-response: { $ref: 'components/responses/paged-transaction.yaml' } + + category-response: { $ref: 'components/responses/category.yaml' } + paged-category-response: { $ref: 'components/responses/paged-category.yaml' } + + contract-response: { $ref: 'components/responses/contract.yaml' } + paged-contract-response: { $ref: 'components/responses/paged-contract.yaml' } + + transaction-schedule-response: { $ref: 'components/responses/transaction-schedule.yaml' } + + export-profile-response: { $ref: 'components/responses/export-profile.yaml' } + balance-response: { $ref: 'components/responses/balance.yaml' } + partitioned-balance-response: { $ref: 'components/responses/partitioned-balance.yaml' } + dated-balance-response: { $ref: 'components/responses/dated-balance.yaml' } + insight-response: { $ref: 'components/responses/insight.yaml' } + pattern-response: { $ref: 'components/responses/pattern.yaml' } diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountCommandController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountCommandController.java new file mode 100644 index 00000000..db523f17 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountCommandController.java @@ -0,0 +1,162 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.account.Account; +import com.jongsoft.finance.domain.account.SavingGoal; +import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.rest.model.*; +import com.jongsoft.finance.schedule.Periodicity; +import com.jongsoft.finance.security.CurrentUserProvider; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.util.Objects; +import java.util.function.Predicate; + +@Controller +public class AccountCommandController implements AccountCommandApi { + + private final Logger logger; + private final AccountProvider accountProvider; + private final CurrentUserProvider currentUserProvider; + + public AccountCommandController( + AccountProvider accountProvider, CurrentUserProvider currentUserProvider) { + this.accountProvider = accountProvider; + this.currentUserProvider = currentUserProvider; + this.logger = LoggerFactory.getLogger(AccountCommandController.class); + } + + @Override + public HttpResponse<@Valid AccountResponse> createAccount(AccountRequest accountRequest) { + logger.info("Creating bank account for user."); + accountProvider + .lookup(accountRequest.getName()) + .ifPresent(() -> StatusException.badRequest("Bank account already exists")); + + currentUserProvider + .currentUser() + .createAccount( + accountRequest.getName(), + accountRequest.getCurrency(), + accountRequest.getType()); + + var bankAccount = accountProvider + .lookup(accountRequest.getName()) + .getOrThrow(() -> StatusException.internalError("Failed to create bank account")); + + updateBankAccount(bankAccount, accountRequest); + + return HttpResponse.created(AccountMapper.toAccountResponse(bankAccount)); + } + + @Override + public HttpResponse<@Valid SavingGoalResponse> createSavingGoalForAccount( + Long id, SavingGoalRequest savingGoalRequest) { + logger.info("Creating saving goal for bank account {}.", id); + var savingGoal = lookupAccountOrThrow(id) + .createSavingGoal( + savingGoalRequest.getName(), + savingGoalRequest.getGoal(), + savingGoalRequest.getTargetDate()); + + var createdGoal = lookupAccountOrThrow(id) + .getSavingGoals() + .first(goal -> Objects.equals(savingGoal.getName(), goal.getName())) + .getOrThrow(() -> + StatusException.internalError("Could not locate created saving goal")); + createdGoal.schedule(Periodicity.MONTHS, 1); + return HttpResponse.created(AccountMapper.toSavingGoalResponse(createdGoal)); + } + + @Override + public HttpResponse deleteAccountById(Long id) { + logger.info("Deleting bank account {} for user.", id); + + lookupAccountOrThrow(id).terminate(); + + return HttpResponse.noContent(); + } + + @Override + public HttpResponse deleteSavingGoalForAccount(Long id, Long goalId) { + logger.info("Deleting saving goal {} for bank account {}.", goalId, id); + var bankAccount = lookupAccountOrThrow(id); + + lookupSavingGoalOrThrow(goalId, bankAccount).completed(); + + return HttpResponse.noContent(); + } + + @Override + public HttpResponse makeReservationForSavingGoal( + Long id, Long goalId, SavingReservationRequest savingReservationRequest) { + logger.info("Making reservation for saving goal {} for bank account {}.", goalId, id); + + var bankAccount = lookupAccountOrThrow(id); + var savingGoal = lookupSavingGoalOrThrow(goalId, bankAccount); + + savingGoal.registerPayment(BigDecimal.valueOf(savingReservationRequest.getAmount())); + + return HttpResponse.noContent(); + } + + @Override + public AccountResponse updateAccountById(Long id, AccountRequest accountRequest) { + logger.info("Updating bank account {}.", id); + var bankAccount = lookupAccountOrThrow(id); + + updateBankAccount(bankAccount, accountRequest); + + return AccountMapper.toAccountResponse(bankAccount); + } + + @Override + public SavingGoalResponse updateSavingGoalForAccount( + Long id, Long goalId, SavingGoalRequest savingGoalRequest) { + logger.info("Updating saving goal {} for bank account {}.", goalId, id); + var bankAccount = lookupAccountOrThrow(id); + var savingGoal = lookupSavingGoalOrThrow(goalId, bankAccount); + savingGoal.adjustGoal(savingGoalRequest.getGoal(), savingGoalRequest.getTargetDate()); + return AccountMapper.toSavingGoalResponse(savingGoal); + } + + private void updateBankAccount(Account bankAccount, AccountRequest accountRequest) { + if (accountRequest.getDescription() != null) { + bankAccount.rename( + accountRequest.getName(), + accountRequest.getDescription(), + accountRequest.getCurrency(), + accountRequest.getType()); + } + + if (accountRequest.getInterest() != null) { + bankAccount.interest( + accountRequest.getInterest(), + Periodicity.valueOf(accountRequest.getInterestPeriodicity().name())); + } + + bankAccount.changeAccount( + accountRequest.getIban(), accountRequest.getBic(), accountRequest.getNumber()); + } + + private Account lookupAccountOrThrow(Long id) { + return accountProvider + .lookup(id) + .filter(Predicate.not(Account::isRemove)) + .getOrThrow(() -> StatusException.notFound("Bank account is not found")); + } + + private SavingGoal lookupSavingGoalOrThrow(Long id, Account account) { + return account.getSavingGoals() + .first(goal -> Objects.equals(goal.getId(), id)) + .getOrThrow(() -> StatusException.notFound("Saving goal is not found")); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountFetcherController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountFetcherController.java new file mode 100644 index 00000000..29600c93 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountFetcherController.java @@ -0,0 +1,131 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.account.Account; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.providers.AccountTypeProvider; +import com.jongsoft.finance.providers.SettingProvider; +import com.jongsoft.finance.rest.model.*; +import com.jongsoft.lang.Collections; +import com.jongsoft.lang.Dates; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; + +@Controller +class AccountFetcherController implements AccountFetcherApi { + + private final Logger logger; + private final AccountTypeProvider accountTypeProvider; + private final AccountProvider accountProvider; + private final FilterFactory filterFactory; + private final SettingProvider settingProvider; + + AccountFetcherController( + AccountTypeProvider accountTypeProvider, + AccountProvider accountProvider, + FilterFactory filterFactory, + SettingProvider settingProvider) { + this.accountTypeProvider = accountTypeProvider; + this.accountProvider = accountProvider; + this.filterFactory = filterFactory; + this.settingProvider = settingProvider; + this.logger = LoggerFactory.getLogger(AccountFetcherController.class); + } + + @Override + public AccountResponse getAccountById(Long id) { + logger.info("Fetching bank account {}.", id); + var bankAccount = lookupAccountOrThrow(id); + + return AccountMapper.toAccountResponse(bankAccount); + } + + @Override + public PagedAccountResponse getAccounts( + Integer offset, + Integer numberOfResults, + List<@NotNull String> type, + String accountName) { + logger.info("Fetching all bank accounts, with provided filters."); + + var page = offset / numberOfResults; + var filter = filterFactory.account().page(Math.max(0, page), Math.max(1, numberOfResults)); + if (!type.isEmpty()) { + filter.types(Collections.List(type)); + } else { + filter.types(accountTypeProvider.lookup(false)); + } + if (accountName != null) { + filter.name(accountName, false); + } + + var accountResults = accountProvider.lookup(filter); + + return new PagedAccountResponse( + new PagedResponseInfo( + accountResults.total(), accountResults.pages(), accountResults.pageSize()), + accountResults.content().map(AccountMapper::toAccountResponse).toJava()); + } + + @Override + public List<@Valid SavingGoalResponse> getSavingGoalsForAccount(Long id) { + logger.info("Fetching saving goals for bank account {}.", id); + var bankAccount = lookupAccountOrThrow(id); + + return bankAccount.getSavingGoals().map(AccountMapper::toSavingGoalResponse).stream() + .toList(); + } + + @Override + public List<@Valid AccountSpendingResponse> getTopAccountsBySpending( + LocalDate startDate, LocalDate endDate, AccountType type, Boolean useOwnAccounts) { + logger.info("Fetching top accounts by spending."); + + var filter = filterFactory.account().page(0, settingProvider.getAutocompleteLimit()); + switch (type) { + case DEBIT -> filter.types(Collections.List("debtor")); + case CREDITOR -> filter.types(Collections.List("creditor")); + default -> throw StatusException.badRequest("Invalid account type"); + } + + var ascending = + switch (type) { + case DEBIT -> true; + case CREDITOR -> false; + }; + + return accountProvider + .top(filter, Dates.range(startDate, endDate), ascending) + .map(this::toAccountSpendingResponse) + .toJava(); + } + + private Account lookupAccountOrThrow(Long id) { + var bankAccount = accountProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("Bank account is not found")); + + if (bankAccount.isRemove()) { + throw StatusException.gone("Bank account has been removed from the system"); + } + return bankAccount; + } + + private AccountSpendingResponse toAccountSpendingResponse( + AccountProvider.AccountSpending accountSpending) { + return new AccountSpendingResponse( + AccountMapper.toAccountResponse(accountSpending.account()), + accountSpending.average(), + accountSpending.total()); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountMapper.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountMapper.java new file mode 100644 index 00000000..55072dae --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/AccountMapper.java @@ -0,0 +1,60 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.account.Account; +import com.jongsoft.finance.domain.account.SavingGoal; +import com.jongsoft.finance.rest.model.*; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +interface AccountMapper { + + static AccountLink toAccountLink(Account account) { + return new AccountLink(account.getId(), account.getName(), account.getType()); + } + + static AccountResponse toAccountResponse(Account account) { + var accountNumbers = new AccountResponseAllOfAccount(); + var response = new AccountResponse( + account.getId(), account.getName(), account.getType(), accountNumbers); + + response.setDescription(account.getDescription()); + if (account.getInterestPeriodicity() != null) { + response.setInterest(new AccountResponseAllOfInterest( + Periodicity.fromValue(account.getInterestPeriodicity().name()), + account.getInterest())); + } + if (account.getFirstTransaction() != null) { + response.setHistory(new AccountResponseAllOfHistory( + account.getFirstTransaction(), account.getLastTransaction())); + } + + accountNumbers.setBic(account.getBic()); + accountNumbers.setIban(account.getIban()); + accountNumbers.setNumber(account.getNumber()); + accountNumbers.setCurrency(account.getCurrency()); + + return response; + } + + static SavingGoalResponse toSavingGoalResponse(SavingGoal savingGoal) { + var response = new SavingGoalResponse( + savingGoal.getId(), + savingGoal.getGoal(), + savingGoal.getAllocated(), + savingGoal.getTargetDate()); + + response.setName(savingGoal.getName()); + response.setDescription(savingGoal.getDescription()); + if (savingGoal.getSchedule() != null) { + response.setSchedule(new ScheduleResponse( + Periodicity.fromValue(savingGoal.getSchedule().periodicity().name()), + savingGoal.getSchedule().interval())); + response.setInstallments(savingGoal.computeAllocation()); + } + response.setMonthsLeft(Math.max( + ChronoUnit.MONTHS.between(LocalDate.now(), savingGoal.getTargetDate()), 0)); + + return response; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryCommandController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryCommandController.java new file mode 100644 index 00000000..81804be7 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryCommandController.java @@ -0,0 +1,68 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.user.Category; +import com.jongsoft.finance.providers.CategoryProvider; +import com.jongsoft.finance.rest.model.CategoryRequest; +import com.jongsoft.finance.rest.model.CategoryResponse; +import com.jongsoft.finance.security.CurrentUserProvider; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Controller +public class CategoryCommandController implements CategoryCommandApi { + + private final Logger logger; + + private final CurrentUserProvider currentUserProvider; + private final CategoryProvider categoryProvider; + + public CategoryCommandController( + CurrentUserProvider currentUserProvider, CategoryProvider categoryProvider) { + this.currentUserProvider = currentUserProvider; + this.categoryProvider = categoryProvider; + this.logger = LoggerFactory.getLogger(CategoryCommandController.class); + } + + @Override + public HttpResponse<@Valid CategoryResponse> createCategory(CategoryRequest categoryRequest) { + logger.info("Creating category {}.", categoryRequest.getName()); + + currentUserProvider.currentUser().createCategory(categoryRequest.getName()); + + var category = categoryProvider + .lookup(categoryRequest.getName()) + .getOrThrow(() -> StatusException.internalError("Failed to create category")); + category.rename(categoryRequest.getName(), categoryRequest.getDescription()); + + return HttpResponse.created(CategoryMapper.toCategoryResponse(category)); + } + + @Override + public HttpResponse deleteCategoryById(Long id) { + logger.info("Deleting category {}.", id); + + lookupCategoryOrThrow(id).remove(); + return HttpResponse.noContent(); + } + + @Override + public CategoryResponse updateCategory(Long id, CategoryRequest categoryRequest) { + logger.info("Updating category {}.", id); + var category = lookupCategoryOrThrow(id); + category.rename(categoryRequest.getName(), categoryRequest.getDescription()); + return CategoryMapper.toCategoryResponse(category); + } + + private Category lookupCategoryOrThrow(long id) { + return categoryProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("No category found with id " + id)); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryFetcherController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryFetcherController.java new file mode 100644 index 00000000..fe64dbf2 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryFetcherController.java @@ -0,0 +1,61 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.CategoryProvider; +import com.jongsoft.finance.rest.model.CategoryResponse; +import com.jongsoft.finance.rest.model.PagedCategoryResponse; +import com.jongsoft.finance.rest.model.PagedResponseInfo; + +import io.micronaut.http.annotation.Controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Controller +public class CategoryFetcherController implements CategoryFetcherApi { + + private final Logger logger; + + private final CategoryProvider categoryProvider; + private final FilterFactory filterFactory; + + public CategoryFetcherController( + CategoryProvider categoryProvider, FilterFactory filterFactory) { + this.categoryProvider = categoryProvider; + this.filterFactory = filterFactory; + this.logger = LoggerFactory.getLogger(CategoryFetcherController.class); + } + + @Override + public PagedCategoryResponse findCategoriesBy( + Integer offset, Integer numberOfResults, String name) { + logger.info("Fetching all categories, with provided filters."); + var page = offset / numberOfResults; + var filter = filterFactory.category().page(Math.max(0, page), Math.max(1, numberOfResults)); + if (name != null) { + filter.label(name, false); + } + + var results = categoryProvider.lookup(filter); + + return new PagedCategoryResponse( + new PagedResponseInfo(results.total(), results.pages(), results.pageSize()), + results.content().map(CategoryMapper::toCategoryResponse).toJava()); + } + + @Override + public CategoryResponse getCategoryById(Long id) { + logger.info("Fetching category {}.", id); + + var category = categoryProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("Category not found")); + + if (category.isDelete()) { + throw StatusException.gone("Category has been removed from the system"); + } + + return CategoryMapper.toCategoryResponse(category); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryMapper.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryMapper.java new file mode 100644 index 00000000..981a8498 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/CategoryMapper.java @@ -0,0 +1,15 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.user.Category; +import com.jongsoft.finance.rest.model.CategoryResponse; + +interface CategoryMapper { + + static CategoryResponse toCategoryResponse(Category category) { + var response = new CategoryResponse(category.getId()); + response.setDescription(category.getDescription()); + response.setName(category.getLabel()); + response.setLastUsed(category.getLastActivity()); + return response; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractCommandController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractCommandController.java new file mode 100644 index 00000000..0eab8f91 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractCommandController.java @@ -0,0 +1,101 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.account.Contract; +import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.providers.ContractProvider; +import com.jongsoft.finance.rest.model.ContractRequest; +import com.jongsoft.finance.rest.model.ContractResponse; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Controller +public class ContractCommandController implements ContractCommandApi { + + private final Logger logger; + + private final AccountProvider accountProvider; + private final ContractProvider contractProvider; + + public ContractCommandController( + AccountProvider accountProvider, ContractProvider contractProvider) { + this.accountProvider = accountProvider; + this.contractProvider = contractProvider; + this.logger = LoggerFactory.getLogger(ContractCommandController.class); + } + + @Override + public HttpResponse<@Valid ContractResponse> createContract(ContractRequest contractRequest) { + logger.info("Creating contract {}.", contractRequest.getName()); + + var account = accountProvider + .lookup(contractRequest.getCompany().getId()) + .getOrThrow(() -> StatusException.badRequest("No account can be found for " + + contractRequest.getCompany().getId())); + + account.createContract( + contractRequest.getName(), + contractRequest.getDescription(), + contractRequest.getStart(), + contractRequest.getEnd()); + + var contract = contractProvider + .lookup(contractRequest.getName()) + .getOrThrow(() -> StatusException.internalError("Failed to create contract")); + + return HttpResponse.created(ContractMapper.toContractResponse(contract)); + } + + @Override + public HttpResponse deleteContractById(Long id) { + logger.info("Deleting contract {}.", id); + + locateByIdOrThrow(id).terminate(); + return HttpResponse.noContent(); + } + + @Override + public ContractResponse updateContract(Long id, ContractRequest contractRequest) { + logger.info("Updating contract {}.", id); + + var contract = locateByIdOrThrow(id); + if (contractRequest.getName() != null) { + contract.change( + contractRequest.getName(), + contractRequest.getDescription(), + contractRequest.getStart(), + contractRequest.getEnd()); + } + + if (contractRequest.getAttachmentCode() != null) { + contract.registerUpload(contractRequest.getAttachmentCode()); + } + + return ContractMapper.toContractResponse(contract); + } + + @Override + public HttpResponse warnBeforeContractExpiry(Long id) { + logger.info("Warn before contract expiry {}.", id); + + var contract = locateByIdOrThrow(id); + if (contract.isNotifyBeforeEnd()) { + throw StatusException.badRequest("Warning already scheduled for contract"); + } + + contract.warnBeforeExpires(); + return HttpResponse.noContent(); + } + + private Contract locateByIdOrThrow(Long id) { + return contractProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("Contract is not found")); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractFetcherController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractFetcherController.java new file mode 100644 index 00000000..cbe37f5e --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractFetcherController.java @@ -0,0 +1,59 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.providers.ContractProvider; +import com.jongsoft.finance.rest.model.ContractResponse; +import com.jongsoft.finance.rest.model.FindContractByStatusParameter; + +import io.micronaut.http.annotation.Controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Controller +public class ContractFetcherController implements ContractFetcherApi { + + private final Logger logger; + + private final ContractProvider contractProvider; + + public ContractFetcherController(ContractProvider contractProvider) { + this.contractProvider = contractProvider; + this.logger = LoggerFactory.getLogger(ContractFetcherController.class); + } + + @Override + public List findContractBy( + String name, FindContractByStatusParameter status) { + logger.info("Fetching all contracts, with provided filters."); + + if (name != null) { + return contractProvider + .search(name) + .map(ContractMapper::toContractResponse) + .toJava(); + } + + return contractProvider + .lookup() + .filter(contract -> switch (status) { + case ACTIVE -> !contract.isTerminated(); + case INACTIVE -> contract.isTerminated(); + }) + .map(ContractMapper::toContractResponse) + .toJava(); + } + + @Override + public ContractResponse getContractById(Long id) { + logger.info("Fetching contract {}.", id); + + var contract = contractProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("Contract is not found")); + + return ContractMapper.toContractResponse(contract); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractMapper.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractMapper.java new file mode 100644 index 00000000..4d87ee12 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ContractMapper.java @@ -0,0 +1,26 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.account.Contract; +import com.jongsoft.finance.rest.model.AccountLink; +import com.jongsoft.finance.rest.model.ContractResponse; + +interface ContractMapper { + + static ContractResponse toContractResponse(Contract contract) { + var response = new ContractResponse( + contract.getId(), + contract.getName(), + contract.getStartDate(), + contract.getEndDate(), + new AccountLink( + contract.getCompany().getId(), + contract.getCompany().getName(), + contract.getCompany().getType())); + response.setDescription(contract.getDescription()); + response.setFileToken(contract.getFileToken()); + response.setNotification(contract.isNotifyBeforeEnd()); + response.setTerminated(contract.isTerminated()); + + return response; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ExportController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ExportController.java new file mode 100644 index 00000000..0e176c2b --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ExportController.java @@ -0,0 +1,149 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.StorageService; +import com.jongsoft.finance.domain.account.Account; +import com.jongsoft.finance.domain.account.Contract; +import com.jongsoft.finance.domain.transaction.Tag; +import com.jongsoft.finance.domain.user.Budget; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.*; +import com.jongsoft.finance.rest.model.*; + +import io.micronaut.http.annotation.Controller; + +import org.bouncycastle.util.encoders.Hex; + +import java.math.BigDecimal; +import java.util.List; + +@Controller +public class ExportController implements ExportApi { + + private final AccountProvider accountProvider; + private final CategoryProvider categoryProvider; + private final ContractProvider contractProvider; + private final BudgetProvider budgetProvider; + private final TagProvider tagProvider; + private final TransactionProvider transactionProvider; + private final FilterFactory filterFactory; + + private final StorageService storageService; + + public ExportController( + AccountProvider accountProvider, + CategoryProvider categoryProvider, + ContractProvider contractProvider, + BudgetProvider budgetProvider, + TagProvider tagProvider, + TransactionProvider transactionProvider, + FilterFactory filterFactory, + StorageService storageService) { + this.accountProvider = accountProvider; + this.categoryProvider = categoryProvider; + this.contractProvider = contractProvider; + this.budgetProvider = budgetProvider; + this.tagProvider = tagProvider; + this.transactionProvider = transactionProvider; + this.filterFactory = filterFactory; + this.storageService = storageService; + } + + @Override + public ExportProfileResponse exportUserAccount() { + var response = new ExportProfileResponse(); + + // todo convert Rules + + response.accounts(accountProvider.lookup().map(this::toAccountResponse).toJava()); + response.setCategories(categoryProvider + .lookup() + .map(CategoryMapper::toCategoryResponse) + .toJava()); + response.setContract( + contractProvider.lookup().map(this::toContractResponse).toJava()); + response.setBudget(budgetProvider.lookup().map(this::toBudgetResponse).toJava()); + response.setTags(tagProvider.lookup().map(Tag::name).toJava()); + response.setTransaction(lookupRelevantTransactions()); + + return response; + } + + private List lookupRelevantTransactions() { + // we also want to export all opening balance transactions for liability accounts + var filter = filterFactory + .transaction() + .page(0, Integer.MAX_VALUE) + .description("Opening balance", true); + + return transactionProvider + .lookup(filter) + .content() + .map(TransactionMapper::toTransactionResponse) + .toJava(); + } + + private ExportProfileResponseBudgetInner toBudgetResponse(Budget budget) { + var response = new ExportProfileResponseBudgetInner(); + + response.period(new DateRange(budget.getStart(), budget.getEnd())); + for (var expense : budget.getExpenses()) { + var responseExpense = new ExportProfileResponseBudgetInnerExpensesInner(); + responseExpense.name(expense.getName()); + responseExpense.expected(BigDecimal.valueOf(expense.computeBudget())); + response.addExpensesItem(responseExpense); + } + + return response; + } + + private ExportProfileResponseContractInner toContractResponse(Contract contract) { + var response = new ExportProfileResponseContractInner( + contract.getId(), + contract.getName(), + contract.getStartDate(), + contract.getEndDate(), + new AccountLink( + contract.getCompany().getId(), + contract.getCompany().getName(), + contract.getCompany().getType())); + + if (contract.getFileToken() != null) { + response.contract(loadFromEncryptedStorage(contract.getFileToken())); + } + + response.description(contract.getDescription()); + response.notification(contract.isNotifyBeforeEnd()); + response.terminated(contract.isTerminated()); + + return response; + } + + private ExportProfileResponseAccountsInner toAccountResponse(Account account) { + var accountDetails = new AccountResponseAllOfAccount(); + var response = new ExportProfileResponseAccountsInner( + account.getId(), account.getName(), account.getType(), accountDetails); + + accountDetails.currency(account.getCurrency()); + accountDetails.bic(account.getBic()); + accountDetails.iban(account.getIban()); + accountDetails.number(account.getNumber()); + + response.description(account.getDescription()); + if (account.getInterestPeriodicity() != null) { + response.interest(new AccountResponseAllOfInterest( + Periodicity.fromValue(account.getInterestPeriodicity().name()), + account.getInterest())); + } + + if (account.getImageFileToken() != null) { + response.setIcon(loadFromEncryptedStorage(account.getImageFileToken())); + } + + return response; + } + + private String loadFromEncryptedStorage(String fileToken) { + var bytes = storageService.read(fileToken).getOrSupply(() -> new byte[0]); + return Hex.toHexString(bytes); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/InsightController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/InsightController.java new file mode 100644 index 00000000..ca6b39df --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/InsightController.java @@ -0,0 +1,49 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.providers.SpendingInsightProvider; +import com.jongsoft.finance.providers.SpendingPatternProvider; +import com.jongsoft.finance.rest.model.InsightResponse; +import com.jongsoft.finance.rest.model.PatternResponse; + +import io.micronaut.http.annotation.Controller; + +import org.slf4j.Logger; + +import java.time.YearMonth; +import java.util.List; + +@Controller +public class InsightController implements InsightApi { + + private final Logger logger; + private final SpendingInsightProvider spendingInsightProvider; + private final SpendingPatternProvider spendingPatternProvider; + + public InsightController( + SpendingInsightProvider spendingInsightProvider, + SpendingPatternProvider spendingPatternProvider) { + this.spendingInsightProvider = spendingInsightProvider; + this.spendingPatternProvider = spendingPatternProvider; + this.logger = org.slf4j.LoggerFactory.getLogger(InsightController.class); + } + + @Override + public List getInsightsByFilters(Integer year, Integer month) { + logger.info("Fetching insights by filters."); + + return spendingInsightProvider + .lookup(YearMonth.of(year, month)) + .map(InsightMapper::toInsightResponse) + .toJava(); + } + + @Override + public List getPatternsByFilters(Integer year, Integer month) { + logger.info("Fetching patterns by filters."); + + return spendingPatternProvider + .lookup(YearMonth.of(year, month)) + .map(InsightMapper::toPatternResponse) + .toJava(); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/InsightMapper.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/InsightMapper.java new file mode 100644 index 00000000..ff2c3c0b --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/InsightMapper.java @@ -0,0 +1,33 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.insight.SpendingInsight; +import com.jongsoft.finance.domain.insight.SpendingPattern; +import com.jongsoft.finance.rest.model.InsightResponse; +import com.jongsoft.finance.rest.model.InsightResponseSeverity; +import com.jongsoft.finance.rest.model.InsightResponseType; +import com.jongsoft.finance.rest.model.PatternResponse; + +import java.util.Map; + +public interface InsightMapper { + + static InsightResponse toInsightResponse(SpendingInsight insight) { + return new InsightResponse( + InsightResponseType.valueOf(insight.getType().name()), + insight.getCategory(), + InsightResponseSeverity.valueOf(insight.getSeverity().name()), + insight.getScore(), + insight.getDetectedDate(), + insight.getMessage(), + Map.copyOf(insight.getMetadata())); + } + + static PatternResponse toPatternResponse(SpendingPattern pattern) { + var response = new PatternResponse(); + response.category(pattern.getCategory()); + response.confidence(pattern.getConfidence()); + response.detectedDate(pattern.getDetectedDate()); + response.metadata(Map.copyOf(pattern.getMetadata())); + return response; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleCommandController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleCommandController.java new file mode 100644 index 00000000..39bb4ae8 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleCommandController.java @@ -0,0 +1,114 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.transaction.ScheduleValue; +import com.jongsoft.finance.domain.transaction.ScheduledTransaction; +import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.providers.TransactionScheduleProvider; +import com.jongsoft.finance.rest.model.SchedulePatchRequest; +import com.jongsoft.finance.rest.model.ScheduleRequest; +import com.jongsoft.finance.rest.model.TransactionScheduleResponse; +import com.jongsoft.finance.schedule.Periodicity; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; + +import java.time.LocalDate; + +@Controller +class ScheduleCommandController implements ScheduleCommandApi { + + private final Logger logger; + private final AccountProvider accountProvider; + private final TransactionScheduleProvider scheduleProvider; + + ScheduleCommandController( + AccountProvider accountProvider, TransactionScheduleProvider scheduleProvider) { + this.accountProvider = accountProvider; + this.scheduleProvider = scheduleProvider; + this.logger = org.slf4j.LoggerFactory.getLogger(ScheduleCommandController.class); + } + + @Override + public HttpResponse<@Valid TransactionScheduleResponse> createSchedule( + ScheduleRequest scheduleRequest) { + logger.info("Creating new transaction schedule {}.", scheduleRequest.getName()); + + var source = accountProvider + .lookup(scheduleRequest.getTransferBetween().getSource().getId()) + .getOrThrow(() -> StatusException.badRequest( + "The source account cannot be found.", "contract.source.not.found")); + var destination = accountProvider + .lookup(scheduleRequest.getTransferBetween().getDestination().getId()) + .getOrThrow(() -> StatusException.badRequest( + "The destination account cannot be found.", + "contract.destination.not.found")); + + source.createSchedule( + scheduleRequest.getName(), + new ScheduleValue( + Periodicity.valueOf( + scheduleRequest.getSchedule().getPeriodicity().name()), + scheduleRequest.getSchedule().getInterval()), + destination, + scheduleRequest.getAmount()); + + var schedule = scheduleProvider + .lookup() + .first(s -> s.getName().equals(scheduleRequest.getName())) + .getOrThrow( + () -> StatusException.internalError("Could not locate created schedule")); + return HttpResponse.created(ScheduleMapper.toScheduleResponse(schedule)); + } + + @Override + public HttpResponse deleteSchedule(Long id) { + logger.info("Deleting transaction schedule {}.", id); + + var schedule = lookupScheduledTransactionOrThrow(id); + schedule.terminate(); + return HttpResponse.noContent(); + } + + @Override + public TransactionScheduleResponse updateSchedule( + Long id, SchedulePatchRequest schedulePatchRequest) { + logger.info("Updating transaction schedule {}.", id); + var schedule = lookupScheduledTransactionOrThrow(id); + + if (schedulePatchRequest.getName() != null) { + schedule.describe( + schedulePatchRequest.getName(), schedulePatchRequest.getDescription()); + } + + if (schedulePatchRequest.getSchedule() != null) { + schedule.adjustSchedule( + Periodicity.valueOf( + schedulePatchRequest.getSchedule().getPeriodicity().name()), + schedulePatchRequest.getSchedule().getInterval()); + } + + if (schedulePatchRequest.getActiveBetween() != null) { + schedule.limit( + schedulePatchRequest.getActiveBetween().getStartDate(), + schedulePatchRequest.getActiveBetween().getEndDate()); + } + + return ScheduleMapper.toScheduleResponse(schedule); + } + + private ScheduledTransaction lookupScheduledTransactionOrThrow(Long id) { + var schedule = scheduleProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("The schedule cannot be found.")); + if (schedule.getEnd() != null && schedule.getEnd().isBefore(LocalDate.now())) { + throw StatusException.gone("The schedule has already ended."); + } + + return schedule; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleFetcherController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleFetcherController.java new file mode 100644 index 00000000..090c66d0 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleFetcherController.java @@ -0,0 +1,68 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.core.EntityRef; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.TransactionScheduleProvider; +import com.jongsoft.finance.rest.model.TransactionScheduleResponse; +import com.jongsoft.lang.Collections; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import org.slf4j.Logger; + +import java.time.LocalDate; +import java.util.List; + +@Controller +class ScheduleFetcherController implements ScheduleFetcherApi { + + private final Logger logger; + private final TransactionScheduleProvider scheduleProvider; + private final FilterFactory filterFactory; + + public ScheduleFetcherController( + TransactionScheduleProvider scheduleProvider, FilterFactory filterFactory) { + this.scheduleProvider = scheduleProvider; + this.filterFactory = filterFactory; + this.logger = org.slf4j.LoggerFactory.getLogger(ScheduleFetcherController.class); + } + + @Override + public List<@Valid TransactionScheduleResponse> findScheduleByFilter( + List<@NotNull Integer> account, List<@NotNull Integer> contract) { + logger.info("Fetching transaction schedule by filters."); + + var filter = filterFactory.schedule().activeOnly(); + + if (account != null && !account.isEmpty()) { + // todo implement missing filter + } + if (contract != null && !contract.isEmpty()) { + filter.contract(Collections.List( + contract.stream().map(id -> new EntityRef((long) id)).toList())); + } + + return scheduleProvider + .lookup(filter) + .content() + .map(ScheduleMapper::toScheduleResponse) + .toJava(); + } + + @Override + public TransactionScheduleResponse findScheduleById(Long id) { + logger.info("Fetching transaction schedule {}.", id); + var schedule = scheduleProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("The schedule cannot be found.")); + if (schedule.getEnd() != null && !schedule.getEnd().isAfter(LocalDate.now())) { + throw StatusException.gone("The schedule has already ended."); + } + + return ScheduleMapper.toScheduleResponse(schedule); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleMapper.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleMapper.java new file mode 100644 index 00000000..37971060 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/ScheduleMapper.java @@ -0,0 +1,37 @@ +package com.jongsoft.finance.rest.api; + +import static com.jongsoft.finance.rest.api.AccountMapper.toAccountLink; + +import com.jongsoft.finance.domain.transaction.ScheduledTransaction; +import com.jongsoft.finance.rest.model.*; + +interface ScheduleMapper { + + static TransactionScheduleResponse toScheduleResponse( + ScheduledTransaction scheduledTransaction) { + var schedule = new ScheduleResponse( + Periodicity.valueOf( + scheduledTransaction.getSchedule().periodicity().name()), + scheduledTransaction.getSchedule().interval()); + var response = new TransactionScheduleResponse(); + var transferBetween = new ScheduleRequestTransferBetween( + toAccountLink(scheduledTransaction.getSource()), + toAccountLink(scheduledTransaction.getDestination())); + + response.id(scheduledTransaction.getId()); + response.schedule(schedule); + response.amount(scheduledTransaction.getAmount()); + response.name(scheduledTransaction.getName()); + response.description(scheduledTransaction.getDescription()); + response.activeBetween( + new DateRange(scheduledTransaction.getStart(), scheduledTransaction.getEnd())); + response.transferBetween(transferBetween); + + if (scheduledTransaction.getContract() != null) { + response.forContract( + ContractMapper.toContractResponse(scheduledTransaction.getContract())); + } + + return response; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/StatisticsBalanceController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/StatisticsBalanceController.java new file mode 100644 index 00000000..9c154615 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/StatisticsBalanceController.java @@ -0,0 +1,163 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.core.EntityRef; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.providers.CategoryProvider; +import com.jongsoft.finance.providers.ExpenseProvider; +import com.jongsoft.finance.providers.TransactionProvider; +import com.jongsoft.finance.rest.model.*; +import com.jongsoft.lang.Collections; +import com.jongsoft.lang.Dates; +import com.jongsoft.lang.collection.Sequence; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Controller +class StatisticsBalanceController implements StatisticsBalanceApi { + + private final Logger logger; + private final FilterFactory filterFactory; + private final TransactionProvider transactionProvider; + + private final AccountProvider accountProvider; + private final ExpenseProvider expenseProvider; + private final CategoryProvider categoryProvider; + + StatisticsBalanceController( + FilterFactory filterFactory, + TransactionProvider transactionProvider, + AccountProvider accountProvider, + ExpenseProvider expenseProvider, + CategoryProvider categoryProvider) { + this.filterFactory = filterFactory; + this.transactionProvider = transactionProvider; + this.accountProvider = accountProvider; + this.expenseProvider = expenseProvider; + this.categoryProvider = categoryProvider; + this.logger = LoggerFactory.getLogger(StatisticsBalanceController.class); + } + + @Override + public List<@Valid DatedBalanceResponse> computeBalanceGroupedByDate( + ComputeBalanceGroupedByDateTypeParameter type, BalanceRequest balanceRequest) { + logger.info("Computing balance grouped by date with provided filters."); + var filter = toFilterCommand(balanceRequest); + + var balances = + switch (type) { + case DAILY -> transactionProvider.daily(filter); + case MONTHLY -> transactionProvider.monthly(filter); + }; + + return balances.map(b -> new DatedBalanceResponse(b.summary(), b.day())).toJava(); + } + + @Override + public BalanceResponse computeBalanceWithFilter(BalanceRequest balanceRequest) { + logger.info("Computing balance with provided filters."); + var filter = toFilterCommand(balanceRequest); + + var balance = transactionProvider.balance(filter).getOrSupply(() -> BigDecimal.ZERO); + + return new BalanceResponse(balance.doubleValue()); + } + + @Override + public List<@Valid PartitionedBalanceResponse> computePartitionedBalanceWithFilter( + ComputePartitionedBalanceWithFilterPartitionParameter partition, + BalanceRequest balanceRequest) { + logger.info("Computing partitioned balance with provided filters."); + + var total = transactionProvider + .balance(toFilterCommand(balanceRequest)) + .getOrSupply(() -> BigDecimal.ZERO); + + var entities = + switch (partition) { + case ACCOUNT -> accountProvider.lookup(); + case CATEGORY -> categoryProvider.lookup(); + case BUDGET -> + expenseProvider.lookup(filterFactory.expense()).content(); + }; + + var results = new ArrayList(); + for (var entity : entities) { + var filter = + switch (partition) { + case ACCOUNT -> + toFilterCommand(balanceRequest) + .accounts(toEntityRefList(List.of(entity.getId()))); + case CATEGORY -> + toFilterCommand(balanceRequest) + .categories(toEntityRefList(List.of(entity.getId()))); + case BUDGET -> + toFilterCommand(balanceRequest) + .expenses(toEntityRefList(List.of(entity.getId()))); + }; + + var balance = transactionProvider.balance(filter).getOrSupply(() -> BigDecimal.ZERO); + total = total.subtract(balance); + results.add(new PartitionedBalanceResponse(balance.doubleValue(), entity.toString())); + } + results.add(new PartitionedBalanceResponse(total.doubleValue(), "")); + + return results; + } + + private TransactionProvider.FilterCommand toFilterCommand(BalanceRequest balanceRequest) { + var filter = filterFactory + .transaction() + .range(Dates.range( + balanceRequest.getRange().getStartDate(), + balanceRequest.getRange().getEndDate())); + + if (balanceRequest.getAccounts() != null + && !balanceRequest.getAccounts().isEmpty()) { + filter.accounts(toEntityRefList(balanceRequest.getAccounts())); + } else { + filter.ownAccounts(); + } + if (balanceRequest.getCategories() != null + && !balanceRequest.getCategories().isEmpty()) { + filter.categories(toEntityRefList(balanceRequest.getCategories())); + } + if (balanceRequest.getContracts() != null + && !balanceRequest.getContracts().isEmpty()) { + filter.contracts(toEntityRefList(balanceRequest.getContracts())); + } + if (balanceRequest.getExpenses() != null + && !balanceRequest.getExpenses().isEmpty()) { + filter.expenses(toEntityRefList(balanceRequest.getExpenses())); + } + + if (balanceRequest.getCurrency() != null) { + filter.currency(balanceRequest.getCurrency()); + } + if (balanceRequest.getImportSlug() != null) { + filter.importSlug(balanceRequest.getImportSlug()); + } + if (balanceRequest.getType() != null) { + switch (balanceRequest.getType()) { + case INCOME -> filter.onlyIncome(true); + case EXPENSE -> filter.onlyIncome(false); + case ALL -> filter.hashCode(); + } + } + + return filter; + } + + private Sequence toEntityRefList(List ids) { + return Collections.List(ids.stream().map(EntityRef::new).toList()); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/SystemInformationController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/SystemInformationController.java new file mode 100644 index 00000000..7670b40b --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/SystemInformationController.java @@ -0,0 +1,30 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.providers.AccountTypeProvider; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Controller +class SystemInformationController implements SystemInformationApi { + + private final Logger logger; + private final AccountTypeProvider accountTypeProvider; + + SystemInformationController(AccountTypeProvider accountTypeProvider) { + this.accountTypeProvider = accountTypeProvider; + this.logger = LoggerFactory.getLogger(SystemInformationController.class); + } + + @Override + public List<@NotNull String> getAccountTypes() { + logger.info("Fetching all account types."); + return accountTypeProvider.lookup(false).toJava(); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TagCommandController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TagCommandController.java new file mode 100644 index 00000000..0c7697b8 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TagCommandController.java @@ -0,0 +1,49 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.providers.TagProvider; +import com.jongsoft.finance.rest.model.CreateTagRequest; +import com.jongsoft.finance.security.CurrentUserProvider; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import org.slf4j.Logger; + +@Controller +class TagCommandController implements TagCommandApi { + + private final Logger logger; + private final CurrentUserProvider currentUserProvider; + private final TagProvider tagProvider; + + TagCommandController(CurrentUserProvider currentUserProvider, TagProvider tagProvider) { + this.currentUserProvider = currentUserProvider; + this.tagProvider = tagProvider; + this.logger = org.slf4j.LoggerFactory.getLogger(TagCommandController.class); + } + + @Override + public HttpResponse createTag(CreateTagRequest createTagRequest) { + logger.info("Creating tag {}.", createTagRequest.getName()); + + if (tagProvider.lookup(createTagRequest.getName()).isPresent()) { + throw StatusException.badRequest( + "Tag with name " + createTagRequest.getName() + " already exists"); + } + + currentUserProvider.currentUser().createTag(createTagRequest.getName()); + return HttpResponse.noContent(); + } + + @Override + public HttpResponse deleteTag(String name) { + logger.info("Deleting tag {}.", name); + + var tag = tagProvider + .lookup(name) + .getOrThrow(() -> StatusException.notFound("Tag is not found")); + tag.archive(); + return HttpResponse.noContent(); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TagFetcherController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TagFetcherController.java new file mode 100644 index 00000000..1251e401 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TagFetcherController.java @@ -0,0 +1,46 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.domain.transaction.Tag; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.SettingProvider; +import com.jongsoft.finance.providers.TagProvider; + +import io.micronaut.http.annotation.Controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Controller +class TagFetcherController implements TagFetcherApi { + + private final TagProvider tagProvider; + private final FilterFactory filterFactory; + private final SettingProvider settingProvider; + private final Logger logger; + + TagFetcherController( + TagProvider tagProvider, FilterFactory filterFactory, SettingProvider settingProvider) { + this.tagProvider = tagProvider; + this.filterFactory = filterFactory; + this.settingProvider = settingProvider; + this.logger = LoggerFactory.getLogger(TagFetcherController.class); + } + + @Override + public List findTagsBy(String name) { + logger.info("Fetching all tags, with provided name."); + + if (name != null) { + var filter = filterFactory + .tag() + .name(name, false) + .page(0, settingProvider.getAutocompleteLimit()); + + return tagProvider.lookup(filter).content().map(Tag::name).toJava(); + } + + return tagProvider.lookup().map(Tag::name).toJava(); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionCommandController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionCommandController.java new file mode 100644 index 00000000..fb923acb --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionCommandController.java @@ -0,0 +1,211 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.account.Account; +import com.jongsoft.finance.domain.account.Contract; +import com.jongsoft.finance.domain.core.EntityRef; +import com.jongsoft.finance.domain.transaction.SplitRecord; +import com.jongsoft.finance.domain.transaction.Transaction; +import com.jongsoft.finance.domain.user.Category; +import com.jongsoft.finance.messaging.commands.transaction.CreateTransactionCommand; +import com.jongsoft.finance.messaging.handlers.TransactionCreationHandler; +import com.jongsoft.finance.providers.*; +import com.jongsoft.finance.rest.model.SplitTransactionRequestInner; +import com.jongsoft.finance.rest.model.TransactionRequest; +import com.jongsoft.finance.rest.model.TransactionResponse; +import com.jongsoft.lang.Collections; +import com.jongsoft.lang.Value; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Predicate; + +@Controller +class TransactionCommandController implements TransactionCommandApi { + + private final Logger logger; + + private final TransactionProvider transactionProvider; + private final TransactionCreationHandler transactionCreationHandler; + + private final CategoryProvider categoryProvider; + private final ExpenseProvider expenseProvider; + private final ContractProvider contractProvider; + private final AccountProvider accountProvider; + + TransactionCommandController( + TransactionProvider transactionProvider, + TransactionCreationHandler transactionCreationHandler, + CategoryProvider categoryProvider, + ExpenseProvider expenseProvider, + ContractProvider contractProvider, + AccountProvider accountProvider) { + this.transactionProvider = transactionProvider; + this.transactionCreationHandler = transactionCreationHandler; + this.categoryProvider = categoryProvider; + this.expenseProvider = expenseProvider; + this.contractProvider = contractProvider; + this.accountProvider = accountProvider; + this.logger = LoggerFactory.getLogger(TransactionCommandController.class); + } + + @Override + public HttpResponse<@Valid TransactionResponse> createTransaction(TransactionRequest request) { + logger.info("Creating new transaction."); + final Consumer builderConsumer = + transactionBuilder -> transactionBuilder + .currency(request.getCurrency()) + .description(request.getDescription()) + .budget(Optional.ofNullable(request.getExpense()) + .map(expenseProvider::lookup) + .map(Value::get) + .map(EntityRef.NamedEntity::name) + .orElse(null)) + .category(Optional.ofNullable(request.getCategory()) + .map(categoryProvider::lookup) + .map(Value::get) + .map(Category::getLabel) + .orElse(null)) + .contract(Optional.ofNullable(request.getContract()) + .map(contractProvider::lookup) + .map(Value::get) + .map(Contract::getName) + .orElse(null)) + .date(request.getDate()) + .bookDate(request.getBookDate()) + .interestDate(request.getInterestDate()) + .tags(Optional.ofNullable(request.getTags()) + .map(Collections::List) + .orElse(Collections.List())); + + var source = accountProvider + .lookup(request.getSource()) + .filter(Predicate.not(Account::isRemove)) + .getOrThrow(() -> StatusException.badRequest("Source account is not found.")); + var destination = accountProvider + .lookup(request.getTarget()) + .filter(Predicate.not(Account::isRemove)) + .getOrThrow(() -> StatusException.badRequest("Destination account is not found.")); + + final Transaction transaction = source.createTransaction( + destination, + request.getAmount(), + determineType(source, destination), + builderConsumer); + + var id = transactionCreationHandler.handleCreatedEvent( + new CreateTransactionCommand(transaction)); + var transactionWithId = transactionProvider + .lookup(id) + .getOrThrow(() -> + StatusException.internalError("Transaction not found after creation.")); + return HttpResponse.created(TransactionMapper.toTransactionResponse(transactionWithId)); + } + + @Override + public HttpResponse deleteTransaction(Long id) { + logger.info("Deleting transaction {}.", id); + + lookupTransactionByIdOrThrow(id).delete(); + + return HttpResponse.noContent(); + } + + @Override + public TransactionResponse splitTransaction( + Long id, List<@Valid SplitTransactionRequestInner> splitTransactionRequestInners) { + logger.info("Splitting transaction {}.", id); + + var transaction = lookupTransactionByIdOrThrow(id); + var splits = Collections.List(splitTransactionRequestInners) + .map(split -> new SplitRecord( + split.getDescription(), split.getAmount().doubleValue())); + + transaction.split(splits); + + return TransactionMapper.toTransactionResponse(transaction); + } + + @Override + public TransactionResponse updateTransaction(Long id, TransactionRequest transactionRequest) { + logger.info("Updating transaction {}.", id); + + var transaction = lookupTransactionByIdOrThrow(id); + updateAccounts(transaction, transactionRequest); + updateTransactionRelations(transaction, transactionRequest); + transaction.describe(transactionRequest.getDescription()); + transaction.book( + transaction.getDate(), transaction.getBookDate(), transaction.getInterestDate()); + if (!transaction.isSplit()) { + transaction.changeAmount( + transactionRequest.getAmount(), transactionRequest.getCurrency()); + } + + return TransactionMapper.toTransactionResponse(transaction); + } + + private void updateAccounts(Transaction transaction, TransactionRequest request) { + var source = accountProvider + .lookup(request.getSource()) + .filter(Predicate.not(Account::isRemove)) + .getOrThrow(() -> StatusException.notFound("Source account is not found.")); + var destination = accountProvider + .lookup(request.getTarget()) + .filter(Predicate.not(Account::isRemove)) + .getOrThrow(() -> StatusException.notFound("Destination account is not found.")); + + transaction.changeAccount(true, source); + transaction.changeAccount(false, destination); + } + + private void updateTransactionRelations(Transaction transaction, TransactionRequest request) { + Optional.ofNullable(request.getExpense()) + .map(expenseProvider::lookup) + .map(Value::get) + .map(EntityRef.NamedEntity::name) + .ifPresentOrElse(transaction::linkToBudget, () -> transaction.linkToBudget(null)); + Optional.ofNullable(request.getCategory()) + .map(categoryProvider::lookup) + .map(Value::get) + .map(Category::getLabel) + .ifPresentOrElse( + transaction::linkToCategory, () -> transaction.linkToCategory(null)); + Optional.ofNullable(request.getContract()) + .map(contractProvider::lookup) + .map(Value::get) + .map(Contract::getName) + .ifPresentOrElse( + transaction::linkToContract, () -> transaction.linkToContract(null)); + Optional.ofNullable(request.getTags()) + .map(Collections::List) + .ifPresentOrElse(transaction::tag, () -> transaction.tag(Collections.List())); + } + + private Transaction lookupTransactionByIdOrThrow(long id) { + var transaction = transactionProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("Transaction is not found.")); + if (transaction.isDeleted()) { + throw StatusException.gone("Transaction has been removed from the system"); + } + + return transaction; + } + + private Transaction.Type determineType(Account fromAccount, Account toAccount) { + if (fromAccount.isManaged() && toAccount.isManaged()) { + return Transaction.Type.TRANSFER; + } + + return Transaction.Type.CREDIT; + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionFetcherController.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionFetcherController.java new file mode 100644 index 00000000..1f31ac2b --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionFetcherController.java @@ -0,0 +1,116 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.core.EntityRef; +import com.jongsoft.finance.factory.FilterFactory; +import com.jongsoft.finance.providers.TransactionProvider; +import com.jongsoft.finance.rest.model.FindTransactionByTypeParameter; +import com.jongsoft.finance.rest.model.PagedResponseInfo; +import com.jongsoft.finance.rest.model.PagedTransactionResponse; +import com.jongsoft.finance.rest.model.TransactionResponse; +import com.jongsoft.lang.Collections; +import com.jongsoft.lang.Dates; + +import io.micronaut.http.annotation.Controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; + +@Controller +class TransactionFetcherController implements TransactionFetcherApi { + + private final Logger logger; + + private final TransactionProvider transactionProvider; + private final FilterFactory filterFactory; + + TransactionFetcherController( + TransactionProvider transactionProvider, FilterFactory filterFactory) { + this.transactionProvider = transactionProvider; + this.filterFactory = filterFactory; + this.logger = LoggerFactory.getLogger(TransactionFetcherController.class); + } + + @Override + public PagedTransactionResponse findTransactionBy( + LocalDate startDate, + LocalDate endDate, + Integer numberOfResults, + Integer offset, + List account, + List category, + List expense, + List contract, + List tag, + String importSlug, + String description, + FindTransactionByTypeParameter type, + String currency) { + logger.info("Fetching all transactions, with provided filters."); + var page = offset / numberOfResults; + var filter = filterFactory + .transaction() + .page(page, numberOfResults) + .range(Dates.range(startDate, endDate)); + + if (!account.isEmpty()) { + filter.accounts( + Collections.List(account.stream().map(EntityRef::new).toList())); + } + if (!category.isEmpty()) { + filter.categories( + Collections.List(category.stream().map(EntityRef::new).toList())); + } + if (!expense.isEmpty()) { + filter.expenses( + Collections.List(expense.stream().map(EntityRef::new).toList())); + } + if (!contract.isEmpty()) { + filter.contracts( + Collections.List(contract.stream().map(EntityRef::new).toList())); + } + if (!tag.isEmpty()) { + // todo not yet supported + } + if (importSlug != null) { + filter.importSlug(importSlug); + } + if (description != null) { + filter.description(description, false); + } + + if (type != null) { + switch (type) { + case EXPENSE -> filter.ownAccounts().onlyIncome(false); + case INCOME -> filter.ownAccounts().onlyIncome(true); + case TRANSFER -> filter.transfers(); + } + } + + if (currency != null) { + filter.currency(currency); + } + + var results = transactionProvider.lookup(filter); + + return new PagedTransactionResponse( + new PagedResponseInfo(results.total(), results.pages(), results.pageSize()), + results.content().map(TransactionMapper::toTransactionResponse).toJava()); + } + + @Override + public TransactionResponse getTransactionById(Long id) { + logger.info("Fetching transaction {}.", id); + var transaction = transactionProvider + .lookup(id) + .getOrThrow(() -> StatusException.notFound("Transaction is not found")); + if (transaction.isDeleted()) { + throw StatusException.gone("Transaction has been removed from the system"); + } + + return TransactionMapper.toTransactionResponse(transaction); + } +} diff --git a/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionMapper.java b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionMapper.java new file mode 100644 index 00000000..c6b064b7 --- /dev/null +++ b/website/rest-api/src/main/java/com/jongsoft/finance/rest/api/TransactionMapper.java @@ -0,0 +1,58 @@ +package com.jongsoft.finance.rest.api; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.transaction.Transaction; +import com.jongsoft.finance.rest.model.*; + +public interface TransactionMapper { + + static TransactionResponse toTransactionResponse(Transaction transaction) { + var response = new TransactionResponse(); + var metadata = new TransactionResponseMetadata(); + var dates = new TransactionResponseDates(); + var destination = transaction.computeTo(); + var source = transaction.computeFrom(); + + response.id(transaction.getId()); + response.description(transaction.getDescription()); + response.currency(transaction.getCurrency()); + response.amount(transaction.computeAmount(transaction.computeTo())); + response.metadata(metadata); + response.type( + TransactionResponseType.fromValue(transaction.computeType().name())); + response.dates(dates); + response.source(new AccountLink(source.getId(), source.getName(), source.getType())); + response.destination( + new AccountLink(destination.getId(), destination.getName(), destination.getType())); + + metadata.contract(transaction.getContract()); + metadata.budget(transaction.getBudget()); + metadata.category(transaction.getCategory()); + metadata._import(transaction.getImportSlug()); + metadata.tags(transaction.getTags().toJava()); + + dates.transaction(transaction.getDate()); + dates.booked(transaction.getBookDate()); + dates.interest(transaction.getInterestDate()); + + if (transaction.isSplit()) { + var splitFor = + switch (transaction.computeType()) { + case CREDIT -> destination; + case DEBIT -> source; + case TRANSFER -> + throw StatusException.internalError( + "Split transaction cannot be a transfer"); + }; + + transaction + .getTransactions() + .filter(t -> t.getAccount().equals(splitFor)) + .map(part -> new TransactionResponseSplitInner( + part.getDescription(), part.getAmount())) + .forEach(response::addSplitItem); + } + + return response; + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/StatusExceptionHandler.java b/website/rest-api/src/test/java/com/jongsoft/finance/StatusExceptionHandler.java new file mode 100644 index 00000000..a8658401 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/StatusExceptionHandler.java @@ -0,0 +1,29 @@ +package com.jongsoft.finance; + +import com.jongsoft.finance.core.exception.StatusException; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.hateoas.JsonError; +import io.micronaut.http.hateoas.Link; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import jakarta.inject.Singleton; + +@Produces +@Singleton +public class StatusExceptionHandler + implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, StatusException exception) { + var error = new JsonError(exception.getMessage()); + error.link(Link.SELF, Link.of(request.getUri())); + + if (exception.getLocalizationMessage() != null) { + error.link(Link.HELP, exception.getLocalizationMessage()); + } + + return HttpResponse.status(HttpStatus.valueOf(exception.getStatusCode())).body(error); + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/BankAccountTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/BankAccountTest.java new file mode 100644 index 00000000..180f025a --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/BankAccountTest.java @@ -0,0 +1,168 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.extension.PledgerContext; +import com.jongsoft.finance.rest.extension.PledgerRequests; +import com.jongsoft.finance.rest.extension.PledgerTest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.*; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +@PledgerTest +public class BankAccountTest { + + @Test + void listAllAccountTypes(PledgerContext pledgerContext, PledgerRequests requests) { + pledgerContext.withUser("bank-account-types@account.local"); + + requests.fetchAccountTypes() + .statusCode(200) + .body("$", hasSize(6)) + .body("[0]", equalTo("cash")) + .body("[1]", equalTo("credit_card")) + .body("[2]", equalTo("default")) + .body("[3]", equalTo("joined")) + .body("[4]", equalTo("joined_savings")) + .body("[5]", equalTo("savings")); + } + + @Test + void creatingNewBankAccount(PledgerContext pledgerContext, PledgerRequests requests) { + pledgerContext.withUser("bank-account-create@account.local"); + + var id = requests.createBankAccount(Map.of( + "name", "Lidl Stores", + "description", "Lidl stores in Berlin", + "iban", "NL00ABNA0417164300", + "currency", "EUR", + "type", "creditor")) + .statusCode(201) + .body("id", notNullValue()) + .body("name", equalTo("Lidl Stores")) + .body("description", equalTo("Lidl stores in Berlin")) + .body("account.currency", equalTo("EUR")) + .body("account.iban", equalTo("NL00ABNA0417164300")) + .extract() + .jsonPath().getLong("id"); + + requests.updateBankAccount(id, Map.of( + "name", "Lidl Stores", + "description", "Lidl stores in Zurich", + "iban", "NL00ABNA0417164301", + "currency", "EUR", + "type", "creditor")) + .statusCode(200) + .body("id", notNullValue()) + .body("name", equalTo("Lidl Stores")) + .body("description", equalTo("Lidl stores in Zurich")) + .body("account.currency", equalTo("EUR")) + .body("account.iban", equalTo("NL00ABNA0417164301")); + + requests.fetchBankAccount(id) + .statusCode(200) + .body("name", equalTo("Lidl Stores")) + .body("description", equalTo("Lidl stores in Zurich")); + + requests.deleteBankAccount(id) + .statusCode(204); + + requests.fetchBankAccount(id) + .statusCode(410) + .body("message", equalTo("Bank account has been removed from the system")); + } + + @Test + void createSavingsAccountWithGoals(PledgerContext pledgerContext, PledgerRequests requests) { + pledgerContext.withUser("bank-account-savings@account.local") + .withBankAccount("Savings account", "EUR", "savings"); + + var accountId = requests.searchBankAccounts(0, 1, List.of(), "Savings account") + .statusCode(200) + .body("content", hasSize(1)) + .extract().jsonPath().getLong("content[0].id"); + + var savingGoalId = requests.createSavingGoal(accountId, "Washer", 500D, LocalDate.now().plusYears(1)) + .statusCode(201) + .body("id", notNullValue()) + .body("name", equalTo("Washer")) + .body("goal", equalTo(500F)) + .body("targetDate", equalTo(LocalDate.now().plusYears(1).toString())) + .body("installments", equalTo(45.45F)) + .body("monthsLeft", equalTo(12)) + .extract().jsonPath().getInt("id"); + + requests.fetchSavingGoals(accountId) + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].id", equalTo(savingGoalId)) + .body("[0].name", equalTo("Washer")); + + requests.reserveMoneyForSavingGoal(accountId, savingGoalId, 150D) + .statusCode(204); + + requests.fetchSavingGoals(accountId) + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].id", equalTo(savingGoalId)) + .body("[0].installments", equalTo(31.82F)) + .body("[0].reserved", equalTo(150F)); + + requests.updateSavingGoal(accountId, savingGoalId, 500D, LocalDate.now().plusMonths(6)) + .statusCode(200) + .body("id", equalTo(savingGoalId)) + .body("name", equalTo("Washer")) + .body("goal", equalTo(500F)) + .body("targetDate", equalTo(LocalDate.now().plusMonths(6).toString())) + .body("installments", equalTo(70F)) + .body("monthsLeft", equalTo(6)); + + requests.deleteSavingGoal(accountId, savingGoalId) + .statusCode(204); + + requests.fetchSavingGoals(accountId) + .statusCode(200) + .body("$", hasSize(0)); + } + + @Test + void fetchingNonExistingAccount(PledgerContext pledgerContext, PledgerRequests requests) { + pledgerContext.withUser("bank-account-missing@account.local"); + + requests.fetchBankAccount(1000000L) + .statusCode(404) + .body("message", equalTo("Bank account is not found")); + } + + @Test + void searchingBankAccounts(PledgerContext pledgerContext, PledgerRequests requests) { + pledgerContext.withUser("bank-account-search@account.local") + .withBankAccount("Savings account", "EUR", "savings") + .withBankAccount("Credit card account", "EUR", "credit_card") + .withBankAccount("Checking account", "EUR", "default") + .withDebtor("Employer", "EUR") + .withCreditor("Netflix", "EUR"); + + requests.searchBankAccounts(0, 3, List.of(), "account") + .statusCode(200) + .body("info.records", equalTo(3)) + .body("info.pages", equalTo(1)) + .body("info.pageSize", equalTo(3)) + .body("content", hasSize(3)) + .body("content[0].name", equalTo("Checking account")) + .body("content[1].name", equalTo("Credit card account")) + .body("content[2].name", equalTo("Savings account")); + + requests.searchBankAccounts(0, 3, List.of("creditor"), null) + .statusCode(200) + .body("info.records", equalTo(1)) + .body("info.pages", equalTo(1)) + .body("info.pageSize", equalTo(3)) + .body("content", hasSize(1)) + .body("content[0].name", equalTo("Netflix")); + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/CategoryTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/CategoryTest.java new file mode 100644 index 00000000..43f51302 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/CategoryTest.java @@ -0,0 +1,70 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.extension.PledgerContext; +import com.jongsoft.finance.rest.extension.PledgerRequests; +import com.jongsoft.finance.rest.extension.PledgerTest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +@PledgerTest +public class CategoryTest { + + @Test + void createNewCategory(PledgerContext context, PledgerRequests requests) { + context.withUser("category-create@account.local"); + + var id = requests.createCategory("Groceries", "Grocery items") + .statusCode(201) + .body("name", equalTo("Groceries")) + .body("description", equalTo("Grocery items")) + .body("id", notNullValue()) + .extract().jsonPath().getLong("id"); + + requests.fetchCategory(id) + .statusCode(200) + .body("name", equalTo("Groceries")) + .body("description", equalTo("Grocery items")) + .body("id", equalTo((int) id)); + + requests.updateCategory(id, "Groceries", "Real items") + .statusCode(200) + .body("name", equalTo("Groceries")) + .body("description", equalTo("Real items")) + .body("id", equalTo((int) id)); + + requests.deleteCategory(id) + .statusCode(204); + + requests.fetchCategory(id) + .statusCode(410) + .body("message", equalTo("Category has been removed from the system")); + } + + @Test + void searchCategories(PledgerContext context, PledgerRequests requests) { + context.withUser("category-search@account.local") + .withCategory("Grocery") + .withCategory("Shopping") + .withCategory("Transportation"); + + requests.searchCategories(0, 3, null) + .statusCode(200) + .body("info.records", equalTo(3)) + .body("info.pages", equalTo(1)) + .body("info.pageSize", equalTo(3)) + .body("content[0].name", equalTo("Grocery")) + .body("content[1].name", equalTo("Shopping")) + .body("content[2].name", equalTo("Transportation")); + + requests.searchCategories(0, 3, "groc") + .statusCode(200) + .body("info.records", equalTo(1)) + .body("info.pages", equalTo(1)) + .body("info.pageSize", equalTo(3)) + .body("content[0].name", equalTo("Grocery")); + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/ContractTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/ContractTest.java new file mode 100644 index 00000000..b359c510 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/ContractTest.java @@ -0,0 +1,96 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.extension.PledgerContext; +import com.jongsoft.finance.rest.extension.PledgerRequests; +import com.jongsoft.finance.rest.extension.PledgerTest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.hamcrest.Matchers.*; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +@PledgerTest +public class ContractTest { + + @Test + void createContract(PledgerContext context, PledgerRequests requests) { + context.withUser("contract-create@account.local") + .withCreditor("Netflix", "EUR"); + + var accountId = requests.searchBankAccounts(0,1, List.of("creditor"), "Netflix") + .extract().jsonPath().getLong("content[0].id"); + + var contractId = requests.createContract(accountId, "Netflix Monthly", "Monthly subscription", LocalDate.now(), LocalDate.now().plusYears(1)) + .statusCode(201) + .body("id", notNullValue()) + .body("name", equalTo("Netflix Monthly")) + .body("description", equalTo("Monthly subscription")) + .body("company.id", equalTo((int)accountId)) + .body("company.name", equalTo("Netflix")) + .body("company.type", equalTo("creditor")) + .body("terminated", equalTo(false)) + .body("notification", equalTo(false)) + .body("start", equalTo(LocalDate.now().toString())) + .body("end", equalTo(LocalDate.now().plusYears(1).toString())) + .extract().jsonPath().getLong("id"); + + requests.updateContract(contractId, "Netflix Monthly", "Monthly netflix", LocalDate.now(), LocalDate.now().plusYears(2)) + .statusCode(200) + .body("name", equalTo("Netflix Monthly")) + .body("description", equalTo("Monthly netflix")) + .body("company.id", equalTo((int)accountId)) + .body("start", equalTo(LocalDate.now().toString())) + .body("end", equalTo(LocalDate.now().plusYears(2).toString())); + + requests.fetchContract(contractId) + .statusCode(200) + .body("name", equalTo("Netflix Monthly")) + .body("description", equalTo("Monthly netflix")) + .body("company.id", equalTo((int)accountId)); + + requests.warnBeforeContractExpires(contractId) + .statusCode(204); + + requests.warnBeforeContractExpires(contractId) + .statusCode(400) + .body("message", equalTo("Warning already scheduled for contract")); + + requests.updateContract(contractId, "Netflix Monthly", "Monthly netflix", LocalDate.now().minusYears(1), LocalDate.now().minusDays(1)) + .statusCode(200) + .body("name", equalTo("Netflix Monthly")) + .body("description", equalTo("Monthly netflix")) + .body("company.id", equalTo((int)accountId)) + .body("start", equalTo(LocalDate.now().minusYears(1).toString())) + .body("end", equalTo(LocalDate.now().minusDays(1).toString())); + + requests.deleteContract(contractId) + .statusCode(204); + } + + @Test + void searchForContract(PledgerContext context, PledgerRequests requests) { + context.withUser("contract-search@account.local") + .withCreditor("Amazon", "EUR") + .withCreditor("Netflix", "EUR") + .withContract("Netflix", "Monthly subscription", LocalDate.now().minusDays(1), LocalDate.now().plusYears(1)) + .withContract("Amazon", "Amazon Prime", LocalDate.now().minusYears(1), LocalDate.now().minusDays(1)); + + requests.searchContracts("monthly", null) + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].name", equalTo("Monthly subscription")); + + requests.searchContracts(null, "INACTIVE") + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].name", equalTo("Amazon Prime")); + + requests.searchContracts(null, "ACTIVE") + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].name", equalTo("Monthly subscription")); + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/ExportTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/ExportTest.java new file mode 100644 index 00000000..67442263 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/ExportTest.java @@ -0,0 +1,44 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.extension.PledgerContext; +import com.jongsoft.finance.rest.extension.PledgerRequests; +import com.jongsoft.finance.rest.extension.PledgerTest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.hamcrest.Matchers.*; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +@PledgerTest +public class ExportTest { + + @Test + void exportProfile(PledgerContext context, PledgerRequests requests) { + context.withUser("export-test@account.local") + .withStorage() + .withBankAccount("Savings account", "EUR", "savings") + .withBankAccount("Credit card account", "EUR", "credit_card") + .withBankAccount("Checking account", "EUR", "default") + .withDebtor("Employer", "EUR") + .withCreditor("Netflix", "EUR") + .withContract("Netflix", "Monthly subscription", LocalDate.now(), LocalDate.now().plusYears(1)) + .withCategory("Grocery") + .withCategory("Shopping") + .withCategory("Transportation") + .withTag("Vacation 2023") + .withTag("Vacation 2024"); + + requests.createExport() + .statusCode(200) + .body("accounts", hasSize(5)) + .body("accounts.name", hasItems("Checking account", "Credit card account", "Savings account", "Employer", "Netflix")) + .body("categories", hasSize(3)) + .body("categories.name", hasItems("Grocery", "Shopping", "Transportation")) + .body("contract", hasSize(1)) + .body("contract.name", hasItems("Monthly subscription")) + .body("tags", hasSize(2)) + .body("tags", hasItems("Vacation 2023", "Vacation 2024")); + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/TagTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/TagTest.java new file mode 100644 index 00000000..3e919aff --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/TagTest.java @@ -0,0 +1,48 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.extension.PledgerContext; +import com.jongsoft.finance.rest.extension.PledgerRequests; +import com.jongsoft.finance.rest.extension.PledgerTest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +@PledgerTest +public class TagTest { + + @Test + void createNewTag(PledgerContext context, PledgerRequests requests) { + context.withUser("tags-create@account.local"); + + requests.createTag("Holidays 2023") + .statusCode(204); + + requests.createTag("Vacation 2023") + .statusCode(204); + + requests.createTag("Vacation 2024") + .statusCode(204); + + requests.createTag("Vacation 2024") + .statusCode(400) + .body("message", equalTo("Tag with name Vacation 2024 already exists")); + + requests.searchTags("vaca") + .statusCode(200) + .body("$", hasSize(2)) + .body("[0]", equalTo("Vacation 2023")) + .body("[1]", equalTo("Vacation 2024")); + + requests.deleteTag("Vacation 2023") + .statusCode(204); + + requests.searchTags("vaca") + .statusCode(200) + .body("$", hasSize(1)) + .body("[0]", equalTo("Vacation 2024")); + } + +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/TransactionTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/TransactionTest.java new file mode 100644 index 00000000..11216a6c --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/TransactionTest.java @@ -0,0 +1,130 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.extension.PledgerContext; +import com.jongsoft.finance.rest.extension.PledgerRequests; +import com.jongsoft.finance.rest.extension.PledgerTest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.hamcrest.Matchers.*; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +@PledgerTest +public class TransactionTest { + + @Test + @DisplayName("Create a transaction schedule, update it and search for it") + void createTransactionSchedule(PledgerContext context, PledgerRequests requests) { + context.withUser("transaction-schedule-create@account.local") + .withBankAccount("Checking", "EUR", "default") + .withCreditor("Netflix", "EUR"); + + var sourceId = requests.searchBankAccounts(0, 1, List.of(), "checking") + .extract().jsonPath().getLong("content[0].id"); + var destinationId = requests.searchBankAccounts(0, 1, List.of("creditor"), "netfl") + .extract().jsonPath().getLong("content[0].id"); + + var scheduleId = requests.createScheduleMonthly(sourceId, destinationId, "Netflix Monthly Payment", 19.99) + .statusCode(201) + .body("id", notNullValue()) + .body("name", equalTo("Netflix Monthly Payment")) + .body("amount", equalTo(19.99F)) + .extract().jsonPath().getLong("id"); + + requests.patchScheduleDateRange(scheduleId, LocalDate.now(), LocalDate.now().plusMonths(12)) + .statusCode(200) + .body("activeBetween.startDate", equalTo(LocalDate.now().toString())) + .body("activeBetween.endDate", equalTo(LocalDate.now().plusMonths(12).toString())); + + requests.fetchSchedule(scheduleId) + .statusCode(200) + .body("name", equalTo("Netflix Monthly Payment")) + .body("amount", equalTo(19.99F)) + .body("transferBetween.source.id", equalTo((int)sourceId)) + .body("transferBetween.source.name", equalTo("Checking")) + .body("transferBetween.destination.id", equalTo((int)destinationId)) + .body("transferBetween.destination.name", equalTo("Netflix")) + .body("activeBetween.startDate", equalTo(LocalDate.now().toString())) + .body("activeBetween.endDate", equalTo(LocalDate.now().plusMonths(12).toString())); + + requests.deleteSchedule(scheduleId) + .statusCode(204); + + requests.fetchSchedule(scheduleId) + .statusCode(410); + } + + @Test + @DisplayName("Search for a transaction schedule") + void searchForSchedule(PledgerContext context, PledgerRequests requests) { + context.withUser("transaction-schedule-search@account.local") + .withBankAccount("Checking", "EUR", "default") + .withCreditor("Netflix", "EUR") + .withSchedule("Checking", "Netflix", "Monthly payment", 19.99, LocalDate.now(), LocalDate.now().plusMonths(12)); + + requests.searchSchedules(null, null) + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].name", equalTo("Monthly payment")) + .body("[0].amount", equalTo(19.99F)); + } + + @Test + @DisplayName("Create a transaction, update it and search for it") + void createTransaction(PledgerContext context, PledgerRequests requests) { + context.withUser("transaction-create@account.local") + .withBankAccount("Checking", "EUR", "default") + .withCreditor("Netflix", "EUR"); + + var sourceId = requests.searchBankAccounts(0, 1, List.of(), "checking") + .extract().jsonPath().getLong("content[0].id"); + var destinationId = requests.searchBankAccounts(0, 1, List.of("creditor"), "netfl") + .body("content[0].id", notNullValue()) + .extract().jsonPath().getLong("content[0].id"); + + var id = requests.createTransaction(sourceId, destinationId, 19.99, "EUR", LocalDate.now(), "Monthly payment") + .statusCode(201) + .body("id", notNullValue()) + .body("amount", equalTo(19.99F)) + .body("currency", equalTo("EUR")) + .body("dates.transaction", equalTo(LocalDate.now().toString())) + .body("description", equalTo("Monthly payment")) + .extract().jsonPath().getLong("id"); + + requests.fetchTransaction(id) + .statusCode(200); + requests.fetchTransaction(9000) + .statusCode(404); + + context.withTag("streaming") + .withCategory("Streaming Services"); + + var catId = requests.searchCategories(0, 2, "stream") + .body("content[0].id", notNullValue()) + .extract().jsonPath().getLong("content[0].id"); + + requests.updateTransaction(id, sourceId, destinationId, "Netflix payment", 19.95, LocalDate.now(), catId, null, null, "streaming") + .statusCode(200) + .body("amount", equalTo(19.95F)) + .body("currency", equalTo("EUR")) + .body("dates.transaction", equalTo(LocalDate.now().toString())) + .body("description", equalTo("Netflix payment")) + .body("metadata.category", equalTo("Streaming Services")) + .body("metadata.tags", hasItem("streaming")); + + requests.searchTransactionsForAccounts(0, 1, LocalDate.now().minusDays(5), LocalDate.now().plusDays(1), List.of(sourceId)) + .statusCode(200) + .body("content", hasSize(1)) + .body("content[0].amount", equalTo(19.95F)); + + requests.deleteTransaction(id) + .statusCode(204); + + requests.fetchTransaction(id) + .statusCode(410); + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerContext.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerContext.java new file mode 100644 index 00000000..bd03d48f --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerContext.java @@ -0,0 +1,140 @@ +package com.jongsoft.finance.rest.extension; + +import com.jongsoft.finance.StorageService; +import com.jongsoft.finance.domain.transaction.ScheduleValue; +import com.jongsoft.finance.domain.user.UserAccount; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.providers.*; +import com.jongsoft.finance.schedule.Periodicity; +import com.jongsoft.finance.security.AuthenticationFacade; +import com.jongsoft.finance.security.CurrentUserProvider; +import com.jongsoft.lang.Control; +import io.micronaut.context.ApplicationContext; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PledgerContext { + + private final List storageTokens; + private final ApplicationContext applicationContext; + + public PledgerContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + this.storageTokens = new ArrayList<>(); + applicationContext.registerSingleton(StorageService.class, mock(StorageService.class)); + } + + public PledgerContext withStorage() { + var storageService = applicationContext.getBean(StorageService.class); + Mockito.when(storageService.store(Mockito.any())).thenAnswer((Answer) invocation -> { + byte[] original = invocation.getArgument(0); + String token = UUID.randomUUID().toString(); + Mockito.when(storageService.read(token)).thenReturn(Control.Option(original)); + storageTokens.add(token); + return token; + }); + return this; + } + + public PledgerContext withUser(String user) { + applicationContext.getBean(UserProvider.class) + .lookup(new UserIdentifier(user)) + .ifNotPresent(() -> new UserAccount(user, "test123")); + when(applicationContext.getBean(AuthenticationFacade.class).authenticated()).thenReturn(user); + when(applicationContext.getBean(CurrentUserProvider.class).currentUser()) + .thenAnswer(_ -> + applicationContext.getBean(UserProvider.class) + .lookup(new UserIdentifier(user)) + .getOrThrow(() -> new RuntimeException("Cannot find user " + user))); + return this; + } + + public PledgerContext withBankAccount(String name, String currency, String type) { + var accountProvider = applicationContext.getBean(AccountProvider.class); + if (accountProvider.lookup(name).isPresent()) { + return this; + } + + applicationContext.getBean(CurrentUserProvider.class) + .currentUser() + .createAccount(name, currency, type); + return this; + } + + public PledgerContext withCreditor(String name, String currency) { + return withBankAccount(name, currency, "creditor"); + } + + public PledgerContext withDebtor(String name, String currency) { + return withBankAccount(name, currency, "debtor"); + } + + public PledgerContext withCategory(String name) { + var categoryProvider = applicationContext.getBean(CategoryProvider.class); + if (categoryProvider.lookup(name).isPresent()) { + return this; + } + + applicationContext.getBean(CurrentUserProvider.class) + .currentUser() + .createCategory(name); + return this; + } + + public PledgerContext withTag(String name) { + var tagProvider = applicationContext.getBean(TagProvider.class); + if (tagProvider.lookup(name).isPresent()) { + return this; + } + + applicationContext.getBean(CurrentUserProvider.class) + .currentUser() + .createTag(name); + return this; + } + + public PledgerContext withContract(String company, String name, LocalDate startDate, LocalDate endDate) { + var contractProvider = applicationContext.getBean(ContractProvider.class); + if (contractProvider.lookup(name).isPresent()) { + return this; + } + + var account = applicationContext.getBean(AccountProvider.class) + .lookup(company) + .getOrThrow(() -> new RuntimeException("Cannot find account " + company)); + + account.createContract(name, name, startDate, endDate); + if (endDate.isBefore(LocalDate.now())) { + contractProvider.lookup(name) + .getOrThrow(() -> new RuntimeException("Cannot find contract " + name)) + .terminate(); + } + return this; + } + + public PledgerContext withSchedule(String source, String company, String name, double amount, LocalDate startDate, LocalDate endDate) { + var account = applicationContext.getBean(AccountProvider.class).lookup(source) + .getOrThrow(() -> new RuntimeException("Cannot find account " + source)); + var destination = applicationContext.getBean(AccountProvider.class).lookup(company) + .getOrThrow(() -> new RuntimeException("Cannot find account " + company)); + + account.createSchedule(name, new ScheduleValue(Periodicity.MONTHS, 1), destination, amount); + var schedule = applicationContext.getBean(TransactionScheduleProvider.class) + .lookup().first(s -> s.getName().equals(name)).getOrThrow(() -> new RuntimeException("Cannot find schedule " + name)); + schedule.limit(startDate, endDate); + + return this; + } + + void reset() { + + } +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerRequests.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerRequests.java new file mode 100644 index 00000000..c95319a7 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerRequests.java @@ -0,0 +1,485 @@ +package com.jongsoft.finance.rest.extension; + +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import io.restassured.specification.RequestSpecification; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; + +public class PledgerRequests { + private final RequestSpecification requestSpecification; + + public PledgerRequests(RequestSpecification requestSpecification) { + this.requestSpecification = requestSpecification; + } + + public ValidatableResponse createBankAccount(Map bankAccount) { + return given(requestSpecification) + .contentType(ContentType.JSON) + .body(bankAccount) + .when() + .post("/v2/api/accounts") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse updateBankAccount(long id, Map bankAccount) { + return given(requestSpecification) + .contentType(ContentType.JSON) + .pathParam("id", id) + .body(bankAccount) + .when() + .put("/v2/api/accounts/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchBankAccount(long id) { + return given(requestSpecification) + .pathParam("id", id) + .when() + .get("/v2/api/accounts/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteBankAccount(long id) { + return given(requestSpecification) + .pathParam("id", id) + .when() + .delete("/v2/api/accounts/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createSavingGoal(long id, String name, double goal, LocalDate targetDate) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", id) + .body(Map.of("name", name, "goal", goal, "targetDate", targetDate.toString())) + .when() + .post("/v2/api/accounts/{id}/saving-goals") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse updateSavingGoal(long accountId, long savingGoalId, double goal, LocalDate targetDate) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", accountId) + .pathParam("savingGoalId", savingGoalId) + .body(Map.of("goal", goal, "targetDate", targetDate.toString())) + .when() + .put("/v2/api/accounts/{id}/saving-goals/{savingGoalId}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchSavingGoals(long accountId) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("id", accountId) + .when() + .get("/v2/api/accounts/{id}/saving-goals") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse reserveMoneyForSavingGoal(long accountId, long savingGoalId, double amount) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", accountId) + .pathParam("goal-id", savingGoalId) + .body(Map.of("amount", amount)) + .when() + .post("/v2/api/accounts/{id}/saving-goals/{goal-id}/make-reservation") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteSavingGoal(long accountId, long savingGoalId) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", accountId) + .pathParam("savingGoalId", savingGoalId) + .when() + .delete("/v2/api/accounts/{id}/saving-goals/{savingGoalId}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse searchBankAccounts(int offset, int limit, List type, String accountName) { + var request = given(requestSpecification) + .log().ifValidationFails() + .queryParam("offset", offset) + .queryParam("numberOfResults", limit); + + if (!type.isEmpty()) { + request.queryParam("type", type); + } + if (accountName != null) { + request.queryParam("accountName", accountName); + } + + return request.when() + .get("/v2/api/accounts") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createCategory(String name, String description) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .body(Map.of("name", name, "description", description)) + .when() + .post("/v2/api/categories") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchCategory(long id) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", id) + .when() + .get("/v2/api/categories/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse updateCategory(long id, String name, String description) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", id) + .body(Map.of("name", name, "description", description)) + .when() + .put("/v2/api/categories/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteCategory(long id) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", id) + .when() + .delete("/v2/api/categories/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse searchCategories(int offset, int limit, String name) { + var request = given(requestSpecification) + .log().ifValidationFails() + .queryParam("offset", offset) + .queryParam("numberOfResults", limit); + if (name != null) { + request.queryParam("name", name); + } + + return request.when() + .get("/v2/api/categories") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createContract(long accountId, String name, String description, LocalDate startDate, LocalDate endDate) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .body(Map.of( + "company", Map.of("id", accountId), + "name", name, + "description", description, + "start", startDate.toString(), + "end", endDate.toString() + )) + .when() + .post("/v2/api/contracts") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchContract(long contractId) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("id", contractId) + .when() + .get("/v2/api/contracts/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse updateContract(long contractId, String name, String description, LocalDate startDate, LocalDate endDate) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", contractId) + .body(Map.of( + "name", name, + "description", description, + "start", startDate.toString(), + "end", endDate.toString() + )) + .when() + .put("/v2/api/contracts/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse warnBeforeContractExpires(long contractId) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", contractId) + .when() + .post("/v2/api/contracts/{id}/warn-before-expiration") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteContract(long contractId) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("id", contractId) + .when() + .delete("/v2/api/contracts/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse searchContracts(String name, String status) { + var request = given(requestSpecification) + .log().ifValidationFails(); + + if (name != null) { + request.queryParam("name", name); + } + if (status != null) { + request.queryParam("status", status); + } + + return request.when() + .get("/v2/api/contracts") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createTag(String name) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .body(Map.of("name", name)) + .when() + .post("/v2/api/tags") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse searchTags(String name) { + return given(requestSpecification) + .log().ifValidationFails() + .queryParam("name", name) + .when() + .get("/v2/api/tags") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteTag(String name) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("name", name) + .when() + .delete("/v2/api/tags/{name}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchAccountTypes() { + return given(requestSpecification) + .log().ifValidationFails() + .get("/v2/api/account-types") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createExport() { + return given(requestSpecification) + .log().ifValidationFails() + .get("/v2/api/export") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createScheduleMonthly(long sourceAccount, long destinationAccount, String name, double amount) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .body(Map.of( + "name", name, + "amount", amount, + "schedule", Map.of( + "interval", 1, + "periodicity", "MONTHS"), + "transferBetween", Map.of( + "source", Map.of("id", sourceAccount), + "destination", Map.of("id", destinationAccount) + ))) + .when() + .post("/v2/api/schedules") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse patchScheduleDateRange(long scheduleId, LocalDate startDate, LocalDate endDate) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", scheduleId) + .body(Map.of( + "activeBetween", Map.of( + "startDate", startDate.toString(), + "endDate", endDate.toString()))) + .when() + .patch("/v2/api/schedules/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchSchedule(long scheduleId) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("id", scheduleId) + .when() + .get("/v2/api/schedules/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteSchedule(long scheduleId) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("id", scheduleId) + .when() + .delete("/v2/api/schedules/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse searchSchedules(List accounts, Integer contractId) { + var request = given(requestSpecification) + .log().ifValidationFails(); + if (accounts != null) { + request.queryParam("account", accounts); + } + if (contractId != null) { + request.queryParam("contract", contractId); + } + + return request.when() + .get("/v2/api/schedules") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse createTransaction(long fromAccount, long toAccount, double amount, String currency, LocalDate date, String description) { + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .body(Map.of( + "date", date.toString(), + "currency", currency, + "description", description, + "amount", amount, + "source", fromAccount, + "target", toAccount)) + .when() + .post("/v2/api/transactions") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse fetchTransaction(long id) { + return given(requestSpecification) + .log().ifValidationFails() + .pathParam("id", id) + .when() + .get("/v2/api/transactions/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse updateTransaction(long id, long fromAccount, long toAccount, String description, double amount, LocalDate date, Long categoryId, Long expenseId, Long contractId, String tag) { + var body = new HashMap(); + body.put("source", fromAccount); + body.put("target", toAccount); + body.put("date", date.toString()); + body.put("description", description); + body.put("amount", amount); + body.put("currency", "EUR"); + + if (categoryId != null) { + body.put("category", categoryId); + } + if (expenseId != null) { + body.put("expense", expenseId); + } + if (contractId != null) { + body.put("contract", contractId); + } + if (tag != null) { + body.put("tags", List.of(tag)); + } + + return given(requestSpecification) + .log().ifValidationFails() + .contentType(ContentType.JSON) + .pathParam("id", id) + .body(body) + .when() + .put("/v2/api/transactions/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse deleteTransaction(long id) { + return given(requestSpecification) + .pathParam("id", id) + .when() + .delete("/v2/api/transactions/{id}") + .then() + .log().ifValidationFails(); + } + + public ValidatableResponse searchTransactionsForAccounts(int offset, int limit, LocalDate startDate, LocalDate endDate, List accountIds) { + var request = given(requestSpecification) + .log().ifValidationFails() + .queryParam("offset", offset) + .queryParam("numberOfResults", limit) + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()); + if (accountIds != null) { + request.queryParam("account", accountIds); + } + + return request.when() + .get("/v2/api/transactions") + .then() + .log().ifValidationFails(); + } + +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerTest.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerTest.java new file mode 100644 index 00000000..25f1a0d0 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerTest.java @@ -0,0 +1,14 @@ +package com.jongsoft.finance.rest.extension; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.*; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +@ExtendWith({PledgerTestExtension.class}) +public @interface PledgerTest { + + +} diff --git a/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerTestExtension.java b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerTestExtension.java new file mode 100644 index 00000000..c2f955b1 --- /dev/null +++ b/website/rest-api/src/test/java/com/jongsoft/finance/rest/extension/PledgerTestExtension.java @@ -0,0 +1,81 @@ +package com.jongsoft.finance.rest.extension; + +import com.jongsoft.finance.core.Encoder; +import com.jongsoft.finance.domain.FinTrack; +import com.jongsoft.finance.messaging.EventBus; +import com.jongsoft.finance.security.AuthenticationFacade; +import com.jongsoft.finance.security.CurrentUserProvider; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.test.extensions.junit5.MicronautJunit5Extension; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.extension.*; + +import static org.mockito.Mockito.mock; + +public class PledgerTestExtension implements ParameterResolver, BeforeAllCallback, AfterAllCallback { + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(PledgerTestExtension.class); + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return parameterContext.getParameter() + .getType() + .isAssignableFrom(PledgerContext.class) + || parameterContext.getParameter() + .getType() + .isAssignableFrom(PledgerRequests.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + if (parameterContext.getParameter().getType().isAssignableFrom(PledgerContext.class)) { + return getStore(extensionContext) + .get(PledgerContext.class, PledgerContext.class); + } + if (parameterContext.getParameter().getType().isAssignableFrom(PledgerRequests.class)) { + return getStore(extensionContext) + .get(PledgerRequests.class, PledgerRequests.class); + } + + throw new ParameterResolutionException("Unsupported parameter type: " + parameterContext.getParameter().getType()); + } + + @Override + public void beforeAll(ExtensionContext context) { + var store = context.getRoot() + .getStore(ExtensionContext.Namespace.create(MicronautJunit5Extension.class)); + var applicationContext = store.get(ApplicationContext.class, ApplicationContext.class); + + applicationContext.registerSingleton(AuthenticationFacade.class, mock(AuthenticationFacade.class)); + applicationContext.registerSingleton(CurrentUserProvider.class, mock(CurrentUserProvider.class)); + applicationContext.registerSingleton(FinTrack.class, new FinTrack(new Encoder() { + @Override + public String encrypt(String value) { + return value; + } + + @Override + public boolean matches(String encoded, String value) { + return encoded.equals(value); + } + })); + + new EventBus(applicationContext.getBean(ApplicationEventPublisher.class)); + + getStore(context) + .put(PledgerContext.class, new PledgerContext(applicationContext)); + getStore(context) + .put(PledgerRequests.class, new PledgerRequests(applicationContext.getBean(RequestSpecification.class))); + } + + @Override + public void afterAll(ExtensionContext extensionContext) { + getStore(extensionContext) + .get(PledgerContext.class, PledgerContext.class).reset(); + } + + private static ExtensionContext.Store getStore(ExtensionContext extensionContext) { + return extensionContext.getRoot().getStore(NAMESPACE); + } +} diff --git a/website/rest-api/src/test/resources/application-test.properties b/website/rest-api/src/test/resources/application-test.properties new file mode 100644 index 00000000..67368794 --- /dev/null +++ b/website/rest-api/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +micronaut.security.enabled=false +datasources.default.url=jdbc:h2:mem:pledger-io;DB_CLOSE_DELAY=50;MODE=MariaDB + +micronaut.application.storage.location=./build/resources/test +micronaut.server.multipart.enabled=true diff --git a/fintrack-api/src/test/resources/logback.xml b/website/rest-api/src/test/resources/logback.xml similarity index 100% rename from fintrack-api/src/test/resources/logback.xml rename to website/rest-api/src/test/resources/logback.xml diff --git a/website/runtime-api/build.gradle.kts b/website/runtime-api/build.gradle.kts new file mode 100644 index 00000000..2d984181 --- /dev/null +++ b/website/runtime-api/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("io.micronaut.library") + id("io.micronaut.openapi") +} + +micronaut { + runtime("jetty") + testRuntime("junit5") + + openapi { + server(file("src/contract/runtime-api.yaml")) { + apiPackageName = "com.jongsoft.finance.rest" + modelPackageName = "com.jongsoft.finance.rest.model.runtime" + useAuth = true + useReactive = false + generatedAnnotation = false + importMapping = mapOf( + "VariableMap" to "com.jongsoft.finance.rest.model.runtime.VariableMap", + "ExternalErrorResponse" to "io.micronaut.http.hateoas.JsonError", + ) + typeMapping = mapOf( + "object+TaskVariableMap" to "VariableMap", + "json-error-response" to "ExternalErrorResponse" + ) + } + } +} + +dependencies { + implementation(mn.micronaut.http.validation) + implementation(mn.micronaut.security.annotations) + implementation(mn.micronaut.security) + + implementation(mn.micronaut.serde.api) + implementation(mn.validation) + + implementation(libs.camunda) + + implementation(project(":core")) + implementation(project(":domain")) + implementation(project(":bpmn-process")) + + testRuntimeOnly(mn.micronaut.serde.jackson) + testRuntimeOnly(mn.micronaut.jackson.databind) + testRuntimeOnly(mn.logback.classic) + testRuntimeOnly(project(":jpa-repository")) + + testImplementation(mn.micronaut.test.rest.assured) + testImplementation(mn.micronaut.test.junit5) + testImplementation(libs.bundles.junit) + testImplementation(mn.micronaut.http.server.jetty) + testImplementation(libs.lang) +} diff --git a/website/runtime-api/src/contract/components/process-instance.yaml b/website/runtime-api/src/contract/components/process-instance.yaml new file mode 100644 index 00000000..8d9cecd7 --- /dev/null +++ b/website/runtime-api/src/contract/components/process-instance.yaml @@ -0,0 +1,16 @@ +type: object +required: + - id + - process + - businessKey + - state +properties: + id: + type: string + process: + type: string + businessKey: + type: string + state: + type: string + enum: [ACTIVE, COMPLETED] diff --git a/website/runtime-api/src/contract/components/task.yaml b/website/runtime-api/src/contract/components/task.yaml new file mode 100644 index 00000000..085a0d76 --- /dev/null +++ b/website/runtime-api/src/contract/components/task.yaml @@ -0,0 +1,17 @@ +type: object +required: + - id + - name +properties: + id: + type: string + name: + type: string + definition: + type: string + form: + type: string + description: The form to use for the task + created: + type: string + format: date-time diff --git a/website/runtime-api/src/contract/components/variable-response.yaml b/website/runtime-api/src/contract/components/variable-response.yaml new file mode 100644 index 00000000..ad5d5711 --- /dev/null +++ b/website/runtime-api/src/contract/components/variable-response.yaml @@ -0,0 +1,8 @@ +type: object +properties: + id: + type: string + name: + type: string + value: + type: object diff --git a/website/runtime-api/src/contract/components/variable.yaml b/website/runtime-api/src/contract/components/variable.yaml new file mode 100644 index 00000000..1bb98bf7 --- /dev/null +++ b/website/runtime-api/src/contract/components/variable.yaml @@ -0,0 +1 @@ +x-java-type: ['com.jongsoft.finance.ProcessVariable'] diff --git a/website/runtime-api/src/contract/parameters/business-key.yaml b/website/runtime-api/src/contract/parameters/business-key.yaml new file mode 100644 index 00000000..a45d3007 --- /dev/null +++ b/website/runtime-api/src/contract/parameters/business-key.yaml @@ -0,0 +1,4 @@ +name: businessKey +in: path +schema: + type: string diff --git a/website/runtime-api/src/contract/parameters/instance-id.yaml b/website/runtime-api/src/contract/parameters/instance-id.yaml new file mode 100644 index 00000000..bfff55cf --- /dev/null +++ b/website/runtime-api/src/contract/parameters/instance-id.yaml @@ -0,0 +1,4 @@ +name: instanceId +in: path +schema: + type: string diff --git a/website/runtime-api/src/contract/parameters/process-definition.yaml b/website/runtime-api/src/contract/parameters/process-definition.yaml new file mode 100644 index 00000000..aea2a25d --- /dev/null +++ b/website/runtime-api/src/contract/parameters/process-definition.yaml @@ -0,0 +1,4 @@ +name: processDefinition +in: path +schema: + type: string diff --git a/website/runtime-api/src/contract/parameters/task-id.yaml b/website/runtime-api/src/contract/parameters/task-id.yaml new file mode 100644 index 00000000..f84f0a3e --- /dev/null +++ b/website/runtime-api/src/contract/parameters/task-id.yaml @@ -0,0 +1,4 @@ +name: taskId +in: path +schema: + type: string diff --git a/website/runtime-api/src/contract/parameters/variable-id.yaml b/website/runtime-api/src/contract/parameters/variable-id.yaml new file mode 100644 index 00000000..6cebb944 --- /dev/null +++ b/website/runtime-api/src/contract/parameters/variable-id.yaml @@ -0,0 +1,4 @@ +name: variableId +in: path +schema: + type: string diff --git a/website/runtime-api/src/contract/responses/401-response.yaml b/website/runtime-api/src/contract/responses/401-response.yaml new file mode 100644 index 00000000..cacaf0e4 --- /dev/null +++ b/website/runtime-api/src/contract/responses/401-response.yaml @@ -0,0 +1,4 @@ +description: Not authenticated +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/runtime-api/src/contract/responses/403-response.yaml b/website/runtime-api/src/contract/responses/403-response.yaml new file mode 100644 index 00000000..e205f822 --- /dev/null +++ b/website/runtime-api/src/contract/responses/403-response.yaml @@ -0,0 +1,4 @@ +description: Not authorized for resource +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/runtime-api/src/contract/responses/404-response.yaml b/website/runtime-api/src/contract/responses/404-response.yaml new file mode 100644 index 00000000..d083af34 --- /dev/null +++ b/website/runtime-api/src/contract/responses/404-response.yaml @@ -0,0 +1,4 @@ +description: Resource not found +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/runtime-api/src/contract/runtime-api.yaml b/website/runtime-api/src/contract/runtime-api.yaml new file mode 100644 index 00000000..9aad8161 --- /dev/null +++ b/website/runtime-api/src/contract/runtime-api.yaml @@ -0,0 +1,265 @@ +openapi: 3.1.0 +info: + title: Pledger.io Runtime Engine + version: 3.0.0 + contact: + name: Jong Soft Development + url: https://github.com/pledger-io/rest-application + license: + name: MIT + url: https://opensource.org/licenses/MIT + +security: + - bearerAuth: [ ] + +paths: + /v2/api/runtime-engine/{processDefinition}: + parameters: + - $ref: '#/components/parameters/process-definition' + get: + operationId: getProcessInstances + tags: [ process-engine ] + summary: Historic instances + description: > + Lists the historic executions for the provided process definition key + responses: + "200": + description: A list of process instances + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/process-instance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: startProcessInstance + tags: [ process-engine ] + summary: Start process + description: > + Starts a new process instance for the provided process definition key + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: object + responses: + "201": + description: The created process instance + content: + application/json: + schema: { $ref: '#/components/schemas/process-instance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/runtime-engine/{processDefinition}/{businessKey}: + parameters: + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/business-key' + get: + operationId: getProcessInstancesByBusinessKey + tags: [ process-engine ] + summary: History by business key + description: > + List the history executions for the provided definition key, + but only once with matching business key + responses: + "200": + description: A list of process instances + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/process-instance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/runtime-engine/{processDefinition}/{businessKey}/{instanceId}: + parameters: + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/business-key' + - $ref: '#/components/parameters/instance-id' + get: + operationId: getProcessInstance + tags: [ process-engine ] + summary: Fetch process instance + responses: + "200": + description: The process instance + content: + application/json: + schema: { $ref: '#/components/schemas/process-instance-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + delete: + operationId: deleteProcessInstance + tags: [ process-engine ] + summary: Delete process instance + responses: + "204": + description: A confirmation that the process instance was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + + /v2/api/runtime-engine/{processDefinition}/{businessKey}/{instanceId}/tasks: + parameters: + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/business-key' + - $ref: '#/components/parameters/instance-id' + get: + operationId: getTasks + tags: [ task-engine ] + summary: Search tasks for process + responses: + "200": + description: A list of tasks for the process instance + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/task-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/runtime-engine/{processDefinition}/{businessKey}/{instanceId}/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/instance-id' + - $ref: '#/components/parameters/business-key' + - $ref: '#/components/parameters/task-id' + get: + operationId: getTaskById + tags: [ task-engine ] + summary: Fetch task + responses: + "200": + description: The updated task + content: + application/json: + schema: { $ref: '#/components/schemas/task-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + post: + operationId: completeTask + tags: [ task-engine ] + summary: Complete task + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/task-variable-map' } + responses: + "204": + description: A confirmation that task was completed + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + delete: + operationId: deleteTask + tags: [ task-engine ] + summary: Delete task + responses: + "204": + description: A confirmation that task was deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + + /v2/api/runtime-engine/{processDefinition}/{businessKey}/{instanceId}/tasks/{taskId}/variables: + parameters: + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/business-key' + - $ref: '#/components/parameters/instance-id' + - $ref: '#/components/parameters/task-id' + get: + operationId: getTaskVariables + tags: [ task-engine ] + summary: Fetch task variables + parameters: + - name: variable + in: query + schema: + type: string + responses: + "200": + description: A map of all named variables of this task + content: + application/json: + schema: { $ref: '#/components/schemas/task-variable-map' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + + /v2/api/runtime-engine/{processDefinition}/{businessKey}/{instanceId}/variables: + parameters: + - $ref: '#/components/parameters/business-key' + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/instance-id' + get: + operationId: getVariables + tags: [ variable-engine ] + summary: Fetch process variables + responses: + "200": + description: A list of variables for the process instance + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/variable-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/runtime-engine/{processDefinition}/{businessKey}/{instanceId}/variables/{variableId}: + parameters: + - $ref: '#/components/parameters/process-definition' + - $ref: '#/components/parameters/business-key' + - $ref: '#/components/parameters/instance-id' + - $ref: '#/components/parameters/variable-id' + get: + operationId: getVariableById + tags: [ variable-engine ] + summary: Fetch variable + responses: + "200": + description: The variable contents + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/variable-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/403' } + +components: + securitySchemes: + bearer: + type: http + scheme: bearer + bearerFormat: JWT + + responses: + 401: { $ref: './responses/401-response.yaml' } + 403: { $ref: './responses/403-response.yaml' } + 404: { $ref: './responses/404-response.yaml' } + + parameters: + business-key: { $ref: './parameters/business-key.yaml' } + instance-id: { $ref: './parameters/instance-id.yaml' } + process-definition: { $ref: './parameters/process-definition.yaml' } + variable-id: { $ref: './parameters/variable-id.yaml' } + task-id: { $ref: './parameters/task-id.yaml' } + + schemas: + process-instance-response: { $ref: 'components/process-instance.yaml' } + task-response: { $ref: 'components/task.yaml' } + variable-response: { $ref: 'components/variable-response.yaml' } + task-variable-map: + type: object + format: TaskVariableMap + + variable: { $ref: 'components/variable.yaml' } diff --git a/website/runtime-api/src/main/java/com/jongsoft/finance/rest/ProcessEngineController.java b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/ProcessEngineController.java new file mode 100644 index 00000000..73e8be93 --- /dev/null +++ b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/ProcessEngineController.java @@ -0,0 +1,124 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.rest.model.runtime.ProcessInstanceResponse; +import com.jongsoft.finance.rest.model.runtime.ProcessInstanceResponseState; +import com.jongsoft.finance.security.AuthenticationFacade; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.camunda.bpm.engine.RuntimeService; +import org.camunda.bpm.engine.runtime.ProcessInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; + +@Controller +class ProcessEngineController implements ProcessEngineApi { + private static final String KEY_USERNAME = "username"; + + private final RuntimeService runtimeService; + private final AuthenticationFacade authenticationFacade; + private final Logger logger; + + public ProcessEngineController( + RuntimeService runtimeService, AuthenticationFacade authenticationFacade) { + this.runtimeService = runtimeService; + this.authenticationFacade = authenticationFacade; + this.logger = LoggerFactory.getLogger(getClass()); + } + + @Override + public HttpResponse deleteProcessInstance( + String processDefinition, String businessKey, String instanceId) { + logger.info("Deleting the process instance with identifier {}.", instanceId); + + runtimeService.deleteProcessInstance(instanceId, "User termination"); + return HttpResponse.noContent(); + } + + @Override + public ProcessInstanceResponse getProcessInstance( + String processDefinition, String businessKey, String instanceId) { + logger.info("Getting processes for instance {}.", instanceId); + + var processInstance = runtimeService + .createProcessInstanceQuery() + .processDefinitionKey(processDefinition) + .processInstanceId(instanceId) + .variableValueEquals(KEY_USERNAME, authenticationFacade.authenticated()) + .singleResult(); + return convert(processInstance); + } + + @Override + public List<@Valid ProcessInstanceResponse> getProcessInstances(String processDefinition) { + logger.info("Listing all processes for definition {}.", processDefinition); + + return runtimeService + .createProcessInstanceQuery() + .processDefinitionKey(processDefinition) + .variableValueEquals(KEY_USERNAME, authenticationFacade.authenticated()) + .orderByProcessInstanceId() + .desc() + .list() + .stream() + .map(this::convert) + .toList(); + } + + @Override + public List<@Valid ProcessInstanceResponse> getProcessInstancesByBusinessKey( + String processDefinition, String businessKey) { + logger.info( + "Listing all processes for definition {} and business key {}.", + processDefinition, + businessKey); + + return runtimeService + .createProcessInstanceQuery() + .processDefinitionKey(processDefinition) + .processInstanceBusinessKey(businessKey) + .variableValueEquals(KEY_USERNAME, authenticationFacade.authenticated()) + .orderByProcessInstanceId() + .desc() + .list() + .stream() + .map(this::convert) + .toList(); + } + + @Override + public HttpResponse<@Valid ProcessInstanceResponse> startProcessInstance( + String processDefinition, Map parameters) { + logger.info("Starting a new process for {}.", processDefinition); + + var instanceBuilder = runtimeService.createProcessInstanceByKey(processDefinition); + parameters.forEach(instanceBuilder::setVariable); + + if (parameters.containsKey("businessKey")) { + instanceBuilder.businessKey(parameters.get("businessKey").toString()); + } + instanceBuilder.setVariable(KEY_USERNAME, authenticationFacade.authenticated()); + + return HttpResponse.created(convert(instanceBuilder.execute())); + } + + private ProcessInstanceResponse convert(ProcessInstance processInstance) { + if (processInstance == null) { + throw StatusException.notFound("No process instance found."); + } + return new ProcessInstanceResponse( + processInstance.getProcessInstanceId(), + processInstance.getProcessDefinitionId(), + processInstance.getBusinessKey(), + processInstance.isEnded() + ? ProcessInstanceResponseState.COMPLETED + : ProcessInstanceResponseState.ACTIVE); + } +} diff --git a/website/runtime-api/src/main/java/com/jongsoft/finance/rest/TaskEngineController.java b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/TaskEngineController.java new file mode 100644 index 00000000..c9501289 --- /dev/null +++ b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/TaskEngineController.java @@ -0,0 +1,131 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.ProcessVariable; +import com.jongsoft.finance.rest.model.runtime.*; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import org.camunda.bpm.engine.TaskService; +import org.camunda.bpm.engine.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Controller +class TaskEngineController implements TaskEngineApi { + + private final Logger logger; + private final TaskService taskService; + + TaskEngineController(TaskService taskService) { + this.taskService = taskService; + this.logger = LoggerFactory.getLogger(TaskEngineController.class); + } + + @Override + public HttpResponse completeTask( + String processDefinition, + String instanceId, + String businessKey, + String taskId, + VariableMap taskVariableMap) { + logger.info("Completing task {} for process instance {}.", taskId, instanceId); + + var variables = new HashMap(); + for (var entry : taskVariableMap.getVariables().entrySet()) { + variables.put(entry.getKey(), from(entry.getValue())); + } + taskService.complete(taskId, variables); + return HttpResponse.noContent(); + } + + @Override + public HttpResponse deleteTask( + String processDefinition, String instanceId, String businessKey, String taskId) { + logger.info("Deleting task {} for process instance {}.", taskId, instanceId); + taskService.complete(taskId); + return HttpResponse.noContent(); + } + + @Override + public TaskResponse getTaskById( + String processDefinition, String instanceId, String businessKey, String taskId) { + logger.info("Getting task {} for process instance {}.", taskId, instanceId); + + var task = taskService.createTaskQuery().taskId(taskId).singleResult(); + return convert(task); + } + + @Override + public VariableMap getTaskVariables( + String processDefinition, + String businessKey, + String instanceId, + String taskId, + String variable) { + logger.info("Getting variables for task {} for process instance {}.", taskId, instanceId); + + var map = new HashMap(); + var variables = variable != null + ? Map.of(variable, taskService.getVariable(taskId, variable)) + : taskService.getVariables(taskId); + for (var entry : variables.entrySet()) { + if (entry.getKey().equals("username")) { + continue; + } + map.put(entry.getKey(), convert(entry.getValue())); + } + + return new VariableMap(map); + } + + @Override + public List getTasks( + String processDefinition, String businessKey, String instanceId) { + logger.info("Getting tasks for process instance {}.", instanceId); + return taskService + .createTaskQuery() + .processDefinitionKey(processDefinition) + .processInstanceId(instanceId) + .initializeFormKeys() + .list() + .stream() + .map(this::convert) + .toList(); + } + + private TaskResponse convert(Task task) { + var response = new TaskResponse(task.getId(), task.getName()); + response.definition(task.getTaskDefinitionKey()); + response.form(task.getFormKey()); + response.created( + ZonedDateTime.ofInstant(task.getCreateTime().toInstant(), ZoneId.of("UTC"))); + return response; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private ProcessVariable convert(Object value) { + return switch (value) { + case Collection list -> + new ListVariable(list.stream().map(this::convert).toList()); + case ProcessVariable variable -> variable; + case null -> null; + default -> new WrappedVariable(value); + }; + } + + private Object from(Object processVariable) { + return switch (processVariable) { + case ListVariable list -> list.content().stream().map(this::from).toList(); + case WrappedVariable wrapped -> wrapped.value(); + default -> processVariable; + }; + } +} diff --git a/website/runtime-api/src/main/java/com/jongsoft/finance/rest/VariableEngineController.java b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/VariableEngineController.java new file mode 100644 index 00000000..ed23c603 --- /dev/null +++ b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/VariableEngineController.java @@ -0,0 +1,60 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.rest.model.runtime.VariableResponse; + +import io.micronaut.http.annotation.Controller; + +import org.camunda.bpm.engine.RuntimeService; +import org.camunda.bpm.engine.runtime.VariableInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Controller +class VariableEngineController implements VariableEngineApi { + + private final Logger logger; + private final RuntimeService runtimeService; + + VariableEngineController(RuntimeService runtimeService) { + this.runtimeService = runtimeService; + this.logger = LoggerFactory.getLogger(VariableEngineController.class); + } + + @Override + public List getVariableById( + String processDefinition, String businessKey, String instanceId, String variableId) { + logger.info("Getting variable {} for process instance {}.", variableId, instanceId); + + return runtimeService + .createVariableInstanceQuery() + .processInstanceIdIn(instanceId) + .variableName(variableId) + .list() + .stream() + .map(this::convert) + .toList(); + } + + @Override + public List getVariables( + String businessKey, String processDefinition, String instanceId) { + logger.info("Getting variables for process instance {}.", instanceId); + return runtimeService + .createVariableInstanceQuery() + .processInstanceIdIn(instanceId) + .list() + .stream() + .map(this::convert) + .toList(); + } + + private VariableResponse convert(VariableInstance variable) { + var response = new VariableResponse(); + response.id(variable.getId()); + response.name(variable.getName()); + response.value(variable.getValue()); + return response; + } +} diff --git a/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/ListVariable.java b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/ListVariable.java new file mode 100644 index 00000000..f49eebfb --- /dev/null +++ b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/ListVariable.java @@ -0,0 +1,10 @@ +package com.jongsoft.finance.rest.model.runtime; + +import com.jongsoft.finance.ProcessVariable; + +import io.micronaut.serde.annotation.Serdeable; + +import java.util.List; + +@Serdeable +public record ListVariable(List content) implements ProcessVariable {} diff --git a/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/VariableMap.java b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/VariableMap.java new file mode 100644 index 00000000..0e20dbf6 --- /dev/null +++ b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/VariableMap.java @@ -0,0 +1,20 @@ +package com.jongsoft.finance.rest.model.runtime; + +import com.jongsoft.finance.ProcessVariable; + +import io.micronaut.serde.annotation.Serdeable; + +import java.util.Map; + +@Serdeable +public class VariableMap { + private Map variables; + + public VariableMap(Map variables) { + this.variables = variables; + } + + public Map getVariables() { + return variables; + } +} diff --git a/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/WrappedVariable.java b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/WrappedVariable.java new file mode 100644 index 00000000..26672c8f --- /dev/null +++ b/website/runtime-api/src/main/java/com/jongsoft/finance/rest/model/runtime/WrappedVariable.java @@ -0,0 +1,11 @@ +package com.jongsoft.finance.rest.model.runtime; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.jongsoft.finance.ProcessVariable; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public record WrappedVariable( + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "_type") T value) + implements ProcessVariable {} diff --git a/website/runtime-api/src/test/java/com/jongsoft/finance/rest/ProcessEngineTest.java b/website/runtime-api/src/test/java/com/jongsoft/finance/rest/ProcessEngineTest.java new file mode 100644 index 00000000..fc32c3b9 --- /dev/null +++ b/website/runtime-api/src/test/java/com/jongsoft/finance/rest/ProcessEngineTest.java @@ -0,0 +1,219 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.StorageService; +import com.jongsoft.finance.core.Encoder; +import com.jongsoft.finance.core.MailDaemon; +import com.jongsoft.finance.domain.FinTrack; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.messaging.EventBus; +import com.jongsoft.finance.messaging.commands.StartProcessCommand; +import com.jongsoft.finance.providers.AccountProvider; +import com.jongsoft.finance.providers.UserProvider; +import com.jongsoft.finance.security.AuthenticationFacade; +import com.jongsoft.finance.security.CurrentUserProvider; +import io.micronaut.context.annotation.Bean; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Objects; + +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@MicronautTest(environments = {"jpa", "h2", "test"}, transactional = false) +public class ProcessEngineTest { + + @Inject + private UserProvider userProvider; + @Inject + private AccountProvider accountProvider; + @Inject + private ApplicationEventPublisher eventPublisher; + private long accountId; + + @MockBean(AuthenticationFacade.class) + AuthenticationFacade authenticationFacade() { + var mockedFacade = mock(AuthenticationFacade.class); + when(mockedFacade.authenticated()).thenReturn("reconcile@account.local"); + return mockedFacade; + } + + @MockBean + StorageService storageService() { + return mock(StorageService.class); + } + + @MockBean + MailDaemon mailDaemon() { + return mock(MailDaemon.class); + } + + @MockBean(AuthenticationFacade.class) + CurrentUserProvider currentUserProvider(UserProvider userProvider) { + var mockedFacade = mock(CurrentUserProvider.class); + when(mockedFacade.currentUser()) + .thenAnswer(_ -> userProvider.lookup(new UserIdentifier("reconcile@account.local")).get()); + return mockedFacade; + } + + @Bean + FinTrack application() { + return new FinTrack(new Encoder() { + @Override + public String encrypt(String value) { + return value; + } + + @Override + public boolean matches(String encoded, String value) { + return Objects.equals(encoded, value); + } + }); + } + + @BeforeEach + void setup() { + new EventBus(eventPublisher); + userProvider.lookup(new UserIdentifier("reconcile@account.local")) + .ifNotPresent(() -> { + StartProcessCommand.startProcess( + "RegisterUserAccount", + Map.of("username", new UserIdentifier("reconcile@account.local"), "passwordHash", "test123") + ); + + userProvider.lookup(new UserIdentifier("reconcile@account.local")) + .get().createAccount("Checking test account", "EUR", "default"); + accountId = accountProvider.lookup("Checking test account").get().getId(); + }); + } + + @Test + @DisplayName("Start an account reconcile, fetch it and cancel the process") + void performAccountReconcileProcess(RequestSpecification spec) { + // Create the account reconcile process + var year2024 = balanceOutYear(spec, 2024, 10.0, 100.0, false); + + // verify the process exists in the system + RestAssured.given(spec) + .pathParam("processDefinition", "AccountReconcile") + .pathParam("instanceId", year2024) + .when() + .get("/v2/api/runtime-engine/{processDefinition}/13/{instanceId}") + .then() + .log().ifError() + .statusCode(200) + .body("state", equalTo("ACTIVE")) + .body("process", startsWith("AccountReconcile:1")); + + checkOpenBalancing(spec, String.valueOf(year2024)); + + var taskId = checkOpenBalanceTask(spec, year2024, true); + checkOpenTaskVariable(spec, year2024, taskId, 2024, 100, 10, 0); + + balanceOutYear(spec, 2023, 0.0, 10.0, true); + + checkOpenBalancing(spec, String.valueOf(year2024)); + + RestAssured.given(spec) + .pathParam("processDefinition", "AccountReconcile") + .pathParam("instanceId", year2024) + .pathParam("taskId", taskId) + .when() + .delete("/v2/api/runtime-engine/{processDefinition}/13/{instanceId}/tasks/{taskId}") + .then() + .log().ifValidationFails() + .statusCode(204); + + checkOpenBalanceTask(spec, year2024, false); + + // verify no processes exist for the account reconcile + RestAssured.given(spec) + .pathParam("processDefinition", "AccountReconcile") + .when() + .get("/v2/api/runtime-engine/{processDefinition}") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("$", hasSize(0)); + } + + private void checkOpenBalancing(RequestSpecification spec, String...processIds) { + RestAssured.given(spec) + .pathParam("processDefinition", "AccountReconcile") + .when() + .get("/v2/api/runtime-engine/{processDefinition}") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("id", hasItems(processIds)); + } + + private long checkOpenBalanceTask(RequestSpecification spec, int processId, boolean expectedTask) { + var taskResponse = RestAssured.given(spec) + .pathParam("processDefinition", "AccountReconcile") + .pathParam("instanceId", processId) + .when() + .get("/v2/api/runtime-engine/{processDefinition}/13/{instanceId}/tasks") + .then() + .log().ifValidationFails() + .statusCode(200); + + if (expectedTask) { + return taskResponse.body("id", hasItem(notNullValue())) + .body("name", hasItem(equalTo("Start differs warning"))) + .body("definition", hasItem(equalTo("task_reconcile_before"))) + .extract().jsonPath().getLong("[0].id"); + } else { + taskResponse.body("$", hasSize(0)); + } + + return -1; + } + + private void checkOpenTaskVariable(RequestSpecification spec, int processId, long taskId, int year, double endBalance, double startBalance, double computedStartBalance) { + RestAssured.given(spec) + .pathParam("processDefinition", "AccountReconcile") + .pathParam("instanceId", processId) + .pathParam("taskId", taskId) + .when() + .get("/v2/api/runtime-engine/{processDefinition}/13/{instanceId}/tasks/{taskId}/variables") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("variables.accountId.value", equalTo((int)accountId)) + .body("variables.endDate.value", equalTo(year + "-01-01")) + .body("variables.endBalance.value", equalTo((float)endBalance)) + .body("variables.computedStartBalance.value", equalTo((int)computedStartBalance)) + .body("variables.openBalance.value", equalTo((float)startBalance)); + } + + private int balanceOutYear(RequestSpecification spec, int year, double startBalance, double endBalance, boolean expectComplete) { + return RestAssured.given(spec) + .contentType(ContentType.JSON) + .body(Map.of( + "accountId", accountId, + "startDate", year + "-01-01", + "endDate", year + "-12-31", + "openBalance", startBalance, + "endBalance", endBalance)) + .when() + .pathParam("processDefinition", "AccountReconcile") + .post("/v2/api/runtime-engine/{processDefinition}") + .then() + .log().ifError() + .statusCode(201) + .body("state", equalTo(expectComplete ? "COMPLETED" : "ACTIVE")) + .body("process", startsWith("AccountReconcile:1")) + .extract().jsonPath().getInt("id"); + } +} diff --git a/website/runtime-api/src/test/resources/application-test.properties b/website/runtime-api/src/test/resources/application-test.properties new file mode 100644 index 00000000..178639de --- /dev/null +++ b/website/runtime-api/src/test/resources/application-test.properties @@ -0,0 +1,2 @@ +micronaut.security.enabled=false +datasources.default.url=jdbc:h2:mem:pledger-io;DB_CLOSE_DELAY=50;MODE=MariaDB diff --git a/website/runtime-api/src/test/resources/logback.xml b/website/runtime-api/src/test/resources/logback.xml new file mode 100644 index 00000000..d3eef018 --- /dev/null +++ b/website/runtime-api/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + false + + + %cyan(%d{HH:mm:ss.SSS}) %green(%-36X{correlationId}) %highlight(%-5level) %gray([%thread]) %magenta(%logger{36}) - %msg%n + + + + + + + + diff --git a/website/system-api/build.gradle.kts b/website/system-api/build.gradle.kts new file mode 100644 index 00000000..5236e79e --- /dev/null +++ b/website/system-api/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("io.micronaut.library") + id("io.micronaut.openapi") +} + +micronaut { + runtime("jetty") + testRuntime("junit5") + + openapi { + server(file("src/contract/system-api.yaml")) { + apiPackageName = "com.jongsoft.finance.rest" + modelPackageName = "com.jongsoft.finance.rest.model" + useAuth = true + useReactive = false + generatedAnnotation = false + + importMapping = mapOf( + "ExternalErrorResponse" to "io.micronaut.http.hateoas.JsonError", + ) + typeMapping = mapOf( + "json-error-response" to "ExternalErrorResponse" + ) + } + } +} + +dependencies { + implementation(mn.micronaut.http.validation) + implementation(mn.micronaut.security.annotations) + + // JWT security dependencies + implementation(mn.micronaut.security) + implementation(mn.micronaut.security.jwt) + implementation(libs.bouncy) + implementation(libs.bcpkix) + implementation(libs.bcrypt) + + implementation(mn.micronaut.serde.api) + implementation(mn.validation) + + implementation(mn.micronaut.email.javamail) + + implementation(libs.lang) + implementation(libs.otp) + + implementation(project(":core")) + implementation(project(":domain")) + + // Needed for running the tests + testRuntimeOnly(mn.micronaut.serde.jackson) + testRuntimeOnly(mn.micronaut.jackson.databind) + testRuntimeOnly(mn.logback.classic) + testRuntimeOnly(project(":bpmn-process")) + testRuntimeOnly(project(":jpa-repository")) + + testImplementation(mn.micronaut.test.rest.assured) + testImplementation(mn.micronaut.test.junit5) + testImplementation(libs.bundles.junit) + testImplementation(mn.micronaut.http.server.jetty) +} diff --git a/website/system-api/src/contract/components/date-range.yaml b/website/system-api/src/contract/components/date-range.yaml new file mode 100644 index 00000000..fa281b8d --- /dev/null +++ b/website/system-api/src/contract/components/date-range.yaml @@ -0,0 +1,11 @@ +type: object +required: + - startDate + - endDate +properties: + startDate: + type: string + format: date + endDate: + type: string + format: date diff --git a/website/system-api/src/contract/components/i18n-response.yaml b/website/system-api/src/contract/components/i18n-response.yaml new file mode 100644 index 00000000..9d2a6141 --- /dev/null +++ b/website/system-api/src/contract/components/i18n-response.yaml @@ -0,0 +1,6 @@ +type: object +example: + 'common.hello': 'Hello' + 'common.test': 'Test' +additionalProperties: + type: string diff --git a/website/system-api/src/contract/components/open-id-configuration.yaml b/website/system-api/src/contract/components/open-id-configuration.yaml new file mode 100644 index 00000000..7dac4e4c --- /dev/null +++ b/website/system-api/src/contract/components/open-id-configuration.yaml @@ -0,0 +1,12 @@ +type: object +required: + - authority + - client-id + - client-secret +properties: + authority: + type: string + client-id: + type: string + client-secret: + type: string diff --git a/website/system-api/src/contract/components/requests/currency.yaml b/website/system-api/src/contract/components/requests/currency.yaml new file mode 100644 index 00000000..6b230ce8 --- /dev/null +++ b/website/system-api/src/contract/components/requests/currency.yaml @@ -0,0 +1,16 @@ +type: object +required: + - name + - code + - symbol +properties: + name: + type: string + code: + type: string + min: 3 + max: 3 + symbol: + type: string + minLength: 1 + maxLength: 1 diff --git a/website/system-api/src/contract/components/requests/patch-currency.yaml b/website/system-api/src/contract/components/requests/patch-currency.yaml new file mode 100644 index 00000000..0018aa4c --- /dev/null +++ b/website/system-api/src/contract/components/requests/patch-currency.yaml @@ -0,0 +1,8 @@ +type: object +properties: + decimalPlaces: + type: integer + minimum: 0 + maximum: 10 + enabled: + type: boolean diff --git a/website/system-api/src/contract/components/requests/patch-multi-factor.yaml b/website/system-api/src/contract/components/requests/patch-multi-factor.yaml new file mode 100644 index 00000000..3b11d14f --- /dev/null +++ b/website/system-api/src/contract/components/requests/patch-multi-factor.yaml @@ -0,0 +1,8 @@ +discriminator: + propertyName: action + mapping: + ENABLE: '#/components/schemas/enable-mfa-request' + DISABLE: '#/components/schemas/disable-mfa-request' +oneOf: + - $ref: '#/components/schemas/enable-mfa-request' + - $ref: '#/components/schemas/disable-mfa-request' diff --git a/website/system-api/src/contract/components/requests/session.yaml b/website/system-api/src/contract/components/requests/session.yaml new file mode 100644 index 00000000..ed7384fd --- /dev/null +++ b/website/system-api/src/contract/components/requests/session.yaml @@ -0,0 +1,13 @@ +type: object +required: + - description + - expires +properties: + description: + type: string + minLength: 8 + description: The description for the session that is created + expires: + type: string + format: date + description: The date on which the long lived token should expire diff --git a/website/system-api/src/contract/components/requests/setting.yaml b/website/system-api/src/contract/components/requests/setting.yaml new file mode 100644 index 00000000..84e5047a --- /dev/null +++ b/website/system-api/src/contract/components/requests/setting.yaml @@ -0,0 +1,6 @@ +type: object +required: + - value +properties: + value: + type: object diff --git a/website/system-api/src/contract/components/requests/token.yaml b/website/system-api/src/contract/components/requests/token.yaml new file mode 100644 index 00000000..eff9b9b7 --- /dev/null +++ b/website/system-api/src/contract/components/requests/token.yaml @@ -0,0 +1,10 @@ +type: object +required: + - verificationCode +properties: + verificationCode: + maxLength: 8 + minLength: 4 + pattern: "[\\d]{6}" + type: string + description: The 2-factor verification code from a hardware device. diff --git a/website/system-api/src/contract/components/requests/user.yaml b/website/system-api/src/contract/components/requests/user.yaml new file mode 100644 index 00000000..7b99af83 --- /dev/null +++ b/website/system-api/src/contract/components/requests/user.yaml @@ -0,0 +1,8 @@ +type: object +properties: + username: + type: string + example: me@example.org + password: + type: string + example: this.password.is.to.simple diff --git a/website/system-api/src/contract/components/responses/currency.yaml b/website/system-api/src/contract/components/responses/currency.yaml new file mode 100644 index 00000000..47c9d700 --- /dev/null +++ b/website/system-api/src/contract/components/responses/currency.yaml @@ -0,0 +1,24 @@ +type: object +required: + - name + - code + - symbol + - decimalPlaces + - enabled +properties: + name: + type: string + code: + type: string + max: 3 + min: 3 + symbol: + type: string + max: 1 + min: 1 + decimalPlaces: + type: integer + minimum: 0 + maximum: 10 + enabled: + type: boolean diff --git a/website/system-api/src/contract/components/responses/file.yaml b/website/system-api/src/contract/components/responses/file.yaml new file mode 100644 index 00000000..795434bf --- /dev/null +++ b/website/system-api/src/contract/components/responses/file.yaml @@ -0,0 +1,6 @@ +type: object +required: + - fileCode +properties: + fileCode: + type: string diff --git a/website/system-api/src/contract/components/responses/session.yaml b/website/system-api/src/contract/components/responses/session.yaml new file mode 100644 index 00000000..41556dfe --- /dev/null +++ b/website/system-api/src/contract/components/responses/session.yaml @@ -0,0 +1,13 @@ +type: object +properties: + id: + type: integer + description: The unique identifier for the session + description: + type: string + description: The description of the session + token: + type: string + description: The long lived token that can be used for this session + valid: + $ref: '#/components/schemas/date-range' diff --git a/website/system-api/src/contract/components/responses/setting.yaml b/website/system-api/src/contract/components/responses/setting.yaml new file mode 100644 index 00000000..93ce1431 --- /dev/null +++ b/website/system-api/src/contract/components/responses/setting.yaml @@ -0,0 +1,13 @@ +type: object +required: + - name + - value + - type +properties: + name: + type: string + value: + type: object + type: + type: string + enum: [STRING, NUMBER, FLAG, DATE] diff --git a/website/system-api/src/contract/components/responses/user-profile.yaml b/website/system-api/src/contract/components/responses/user-profile.yaml new file mode 100644 index 00000000..53a20d9a --- /dev/null +++ b/website/system-api/src/contract/components/responses/user-profile.yaml @@ -0,0 +1,22 @@ +type: object +required: + - theme + - currency + - mfa +properties: + theme: + type: string + description: The theme to use for the user interface. + example: "light" + currency: + type: string + description: The default currency to use for the user interface. + example: "EUR" + min: 3 + max: 3 + profilePicture: + type: string + mfa: + type: boolean + description: Whether the user has enabled 2-factor authentication. + example: true diff --git a/website/system-api/src/contract/responses/400-response.yaml b/website/system-api/src/contract/responses/400-response.yaml new file mode 100644 index 00000000..1a386984 --- /dev/null +++ b/website/system-api/src/contract/responses/400-response.yaml @@ -0,0 +1,4 @@ +description: Bad Request +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/system-api/src/contract/responses/401-response.yaml b/website/system-api/src/contract/responses/401-response.yaml new file mode 100644 index 00000000..cacaf0e4 --- /dev/null +++ b/website/system-api/src/contract/responses/401-response.yaml @@ -0,0 +1,4 @@ +description: Not authenticated +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/system-api/src/contract/responses/403-response.yaml b/website/system-api/src/contract/responses/403-response.yaml new file mode 100644 index 00000000..e205f822 --- /dev/null +++ b/website/system-api/src/contract/responses/403-response.yaml @@ -0,0 +1,4 @@ +description: Not authorized for resource +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/system-api/src/contract/responses/404-response.yaml b/website/system-api/src/contract/responses/404-response.yaml new file mode 100644 index 00000000..d083af34 --- /dev/null +++ b/website/system-api/src/contract/responses/404-response.yaml @@ -0,0 +1,4 @@ +description: Resource not found +content: + application/json: + schema: { $ref: '#/components/schemas/json-error-response' } diff --git a/website/system-api/src/contract/system-api.yaml b/website/system-api/src/contract/system-api.yaml new file mode 100644 index 00000000..edf8baa4 --- /dev/null +++ b/website/system-api/src/contract/system-api.yaml @@ -0,0 +1,503 @@ +openapi: 3.1.0 +info: + title: Pledger.io System API + version: 3.0.0 + contact: + name: Jong Soft Development + url: https://github.com/pledger-io/rest-application + license: + name: MIT + url: https://opensource.org/licenses/MIT + +tags: + - name: i18n + description: i18n + +security: + - bearerAuth: [] + +paths: + + /.well-known/openid-connect: + get: + operationId: openIdConfiguration + tags: [ open-id ] + security: [] + summary: Get configuration + description: > + Get the configuration needed to setup an OpenId integration in the front-end. + responses: + "200": + description: The configuration + content: + application/json: + schema: { $ref: './components/open-id-configuration.yaml' } + /.well-known/public-key: + get: + operationId: getJwtSignature + tags: [ security ] + security: [] + summary: Get JWT signature + description: > + Get the public key used to verify the JWT signature. + responses: + "200": + description: The public key + content: + application/json: + schema: { type: string } + + /v2/api/user-account: + post: + operationId: createUser + tags: [ security ] + security: [] + summary: Create user + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/user-request' } + responses: + "204": + description: Confirmation the account was created + "400": { $ref: '#/components/responses/400' } + /v2/api/user-account/verify-2-factor: + post: + operationId: verifyTwoFactor + tags: [ security ] + summary: Verify 2-factor + description: > + Used to verify the user token against that what is expected. If + valid the user will get a new JWT with updated authorizations. + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/token-request" } + responses: + "200": + description: Confirmation the 2 factor was OK + content: + application/json: + schema: + type: object + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/user-account/{user}: + parameters: + - name: user + in: path + required: true + schema: + type: string + example: e@example.nl + get: + operationId: getProfile + tags: [ profile ] + summary: Get profile + description: > + Get the profile of the authenticated user + responses: + "200": + description: The users profile + content: + application/json: + schema: { $ref: '#/components/schemas/user-profile-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + patch: + operationId: patchProfile + tags: [ profile ] + summary: Update profile + description: > + Update a part of the users profile + requestBody: + content: + application/json: + schema: + type: object + properties: + theme: { type: string, example: 'dark' } + currency: { type: string, example: "EUR" } + password: { type: string } + responses: + "200": + description: The users profile + content: + application/json: + schema: { $ref: '#/components/schemas/user-profile-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/user-account/{user}/sessions: + parameters: + - name: user + in: path + required: true + schema: + type: string + example: e@example.nl + get: + operationId: listSessions + tags: [ profile ] + summary: List sessions + description: > + Retrieve a list of all active sessions of the user + responses: + "200": + description: A list of the users session + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/session-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createSession + tags: [ profile ] + summary: Create session + description: > + Start a new durable long term session. This can for + example be used in a mobile app. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/session-request' } + responses: + "201": + description: The created session + content: + application/json: + schema: { $ref: '#/components/schemas/session-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/user-account/{user}/sessions/{session}: + parameters: + - name: user + in: path + required: true + schema: + type: string + example: e@example.nl + - name: session + in: path + required: true + schema: + type: integer + example: 123 + delete: + operationId: revokeSession + tags: [ profile ] + summary: Revoke session + description: > + Revoke a session. This will disable any future use + of the session in any app. + responses: + "204": + description: Confirmation the session is deleted + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + /v2/api/user-account/{user}/2-factor: + parameters: + - name: user + in: path + required: true + schema: + type: string + example: e@example.nl + get: + operationId: generateQrCode + tags: [ profile ] + summary: Create QR code + description: > + Get the QR-code that can be used to active the 2-factor + authentication. + responses: + "200": + description: The content of the file + content: + '*/*': + schema: + type: string + format: byte + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + patch: + operationId: patch2Factor + tags: [ profile ] + summary: Update 2-factor + description: > + Enable or disable the 2-factor authentication + for the user. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/patch-multi-factor-request' } + responses: + "204": + description: Confirmation that the command was executed + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + + /v2/api/i18n/{languageCode}: + get: + security: [] + tags: + - i18n + summary: Get i18n + operationId: getTranslations + parameters: + - name: languageCode + in: path + required: true + schema: + type: string + example: en + responses: + "200": + description: getTranslations 200 response + content: + application/json: + schema: { $ref: './components/i18n-response.yaml' } + + /v2/api/currencies: + get: + operationId: getCurrencies + tags: [ currency ] + summary: Search currencies + responses: + "200": + description: The list of currencies + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/currency-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + post: + operationId: createCurrency + tags: [ currency ] + summary: Create currency + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/currency-request' } + responses: + "201": + description: The created currency + content: + application/json: + schema: { $ref: '#/components/schemas/currency-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/currencies/{currencyCode}: + parameters: + - name: currencyCode + description: The currency code + in: path + required: true + schema: + type: string + example: EUR + get: + operationId: getCurrencyByCode + tags: [ currency ] + summary: Fetch currency + responses: + "200": + description: The currency + content: + application/json: + schema: { $ref: '#/components/schemas/currency-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + put: + operationId: updateCurrencyByCode + tags: [ currency ] + summary: Update currency + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/currency-request' } + responses: + "200": + description: The updated currency + content: + application/json: + schema: { $ref: '#/components/schemas/currency-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + patch: + operationId: patchCurrencyByCode + tags: [ currency ] + summary: Patch currency + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/patch-currency-request' } + responses: + "200": + description: The updated currency + content: + application/json: + schema: { $ref: '#/components/schemas/currency-response' } + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + /v2/api/settings: + get: + operationId: getAllSettings + tags: [ settings ] + summary: Fetch settings + responses: + "200": + description: The list of currencies + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/setting-response' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/settings/{setting}: + parameters: + - name: setting + description: The setting name + in: path + required: true + schema: + type: string + patch: + operationId: patchSetting + tags: [ settings ] + summary: Update setting + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/setting-request' } + responses: + "204": + description: Confirmation the setting was updated + "400": { $ref: '#/components/responses/400' } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + + /v2/api/files: + post: + operationId: uploadFile + tags: [ files ] + summary: Create file + requestBody: + content: + 'multipart/form-data': + schema: + type: object + properties: + upload: + type: string + format: binary + required: true + responses: + "201": + description: The file code for the created file + content: + application/json: + schema: { $ref: "#/components/schemas/file-response" } + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + /v2/api/files/{fileCode}: + parameters: + - name: fileCode + in: path + required: true + schema: + type: string + get: + operationId: downloadFile + tags: [ files ] + summary: Download file + description: > + Download an existing file from the system. If encryption is enabled this + will throw an exception if the file was not uploaded by the user + downloading it. + responses: + "200": + description: The content of the file + content: + '*/*': + schema: + type: string + format: byte + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + delete: + operationId: deleteFile + tags: [ files ] + summary: Delete file + responses: + "204": + description: Confirmation the file was removed + "401": { $ref: '#/components/responses/401' } + "403": { $ref: '#/components/responses/403' } + "404": { $ref: '#/components/responses/404' } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + responses: + 400: { $ref: './responses/400-response.yaml' } + 401: { $ref: './responses/401-response.yaml' } + 403: { $ref: './responses/403-response.yaml' } + 404: { $ref: './responses/404-response.yaml' } + schemas: + date-range: { $ref: './components/date-range.yaml' } + token-request: { $ref: './components/requests/token.yaml' } + user-request: { $ref: './components/requests/user.yaml' } + currency-request: { $ref: './components/requests/currency.yaml' } + setting-request: { $ref: './components/requests/setting.yaml' } + patch-currency-request: { $ref: './components/requests/patch-currency.yaml' } + patch-multi-factor-request: { $ref: './components/requests/patch-multi-factor.yaml' } + session-request: { $ref: './components/requests/session.yaml' } + currency-response: { $ref: './components/responses/currency.yaml' } + setting-response: { $ref: './components/responses/setting.yaml' } + file-response: { $ref: './components/responses/file.yaml' } + user-profile-response: { $ref: './components/responses/user-profile.yaml' } + session-response: { $ref: './components/responses/session.yaml' } + + enable-mfa-request: + type: object + required: + - action + - verificationCode + properties: + verificationCode: + type: string + min: 4 + max: 8 + action: + type: string + disable-mfa-request: + type: object + required: + - action + properties: + action: + type: string + diff --git a/website/system-api/src/main/java/com/jongsoft/finance/DiskStorageService.java b/website/system-api/src/main/java/com/jongsoft/finance/DiskStorageService.java new file mode 100644 index 00000000..baf108b3 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/DiskStorageService.java @@ -0,0 +1,114 @@ +package com.jongsoft.finance; + +import com.jongsoft.finance.annotation.BusinessEventListener; +import com.jongsoft.finance.configuration.SecuritySettings; +import com.jongsoft.finance.configuration.StorageSettings; +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.user.UserAccount; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.messaging.commands.storage.ReplaceFileCommand; +import com.jongsoft.finance.providers.UserProvider; +import com.jongsoft.finance.security.AuthenticationFacade; +import com.jongsoft.finance.security.Encryption; +import com.jongsoft.lang.Control; +import com.jongsoft.lang.control.Optional; + +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.UUID; + +@Singleton +@Named("storageService") +public class DiskStorageService implements StorageService { + + private final SecuritySettings securitySettings; + private final AuthenticationFacade authenticationFacade; + private final UserProvider userProvider; + private final Path uploadRootDirectory; + private final Encryption encryption; + + public DiskStorageService( + SecuritySettings securitySettings, + AuthenticationFacade authenticationFacade, + UserProvider userProvider, + StorageSettings storageLocation) { + this.securitySettings = securitySettings; + this.authenticationFacade = authenticationFacade; + this.userProvider = userProvider; + this.encryption = new Encryption(); + + uploadRootDirectory = Path.of(storageLocation.getLocation(), "upload"); + if (Files.notExists(uploadRootDirectory)) { + Control.Try(() -> Files.createDirectory(uploadRootDirectory)); + } + } + + @Override + public String store(byte[] content) { + var token = UUID.randomUUID().toString(); + + byte[] toStore; + if (securitySettings.isEncrypt()) { + toStore = encryption.encrypt(content, getEncryptionTokenForUser()); + } else { + toStore = content; + } + + try { + Files.write( + uploadRootDirectory.resolve(token), + toStore, + StandardOpenOption.WRITE, + StandardOpenOption.CREATE_NEW); + return token; + } catch (IOException e) { + return null; + } + } + + @Override + public Optional read(String token) { + try { + var readResult = Files.readAllBytes(uploadRootDirectory.resolve(token)); + + if (securitySettings.isEncrypt()) { + readResult = encryption.decrypt(readResult, getEncryptionTokenForUser()); + } + + return Control.Option(readResult); + } catch (IOException e) { + throw StatusException.notFound("Cannot locate content for token " + token); + } catch (IllegalStateException e) { + throw StatusException.notAuthorized("Cannot access file with token " + token); + } + } + + @Override + public void remove(String token) { + try { + Files.deleteIfExists(uploadRootDirectory.resolve(token)); + } catch (IOException e) { + throw new IllegalStateException("Cannot locate content for token " + token); + } + } + + @BusinessEventListener + public void onStorageChangeEvent(ReplaceFileCommand event) { + if (event.oldFileCode() != null) { + this.remove(event.oldFileCode()); + } + } + + private String getEncryptionTokenForUser() { + return userProvider + .lookup(new UserIdentifier(authenticationFacade.authenticated())) + .map(UserAccount::getSecret) + .getOrThrow( + () -> StatusException.internalError("Cannot correctly determine user.")); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/config/ApplicationFactory.java b/website/system-api/src/main/java/com/jongsoft/finance/config/ApplicationFactory.java new file mode 100644 index 00000000..22bd6597 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/config/ApplicationFactory.java @@ -0,0 +1,17 @@ +package com.jongsoft.finance.config; + +import com.jongsoft.finance.core.Encoder; +import com.jongsoft.finance.domain.FinTrack; + +import io.micronaut.context.annotation.Factory; + +import jakarta.inject.Singleton; + +@Factory +public class ApplicationFactory { + + @Singleton + public FinTrack createApplication(Encoder encoder) { + return new FinTrack(encoder); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/config/OpenIdConfiguration.java b/website/system-api/src/main/java/com/jongsoft/finance/config/OpenIdConfiguration.java new file mode 100644 index 00000000..31a60765 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/config/OpenIdConfiguration.java @@ -0,0 +1,34 @@ +package com.jongsoft.finance.config; + +import io.micronaut.context.annotation.ConfigurationProperties; + +@ConfigurationProperties("application.openid") +public class OpenIdConfiguration { + private String authority; + private String clientId; + private String clientSecret; + + public String getAuthority() { + return authority; + } + + public void setAuthority(String authority) { + this.authority = authority; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/config/SignatureConfiguration.java b/website/system-api/src/main/java/com/jongsoft/finance/config/SignatureConfiguration.java new file mode 100644 index 00000000..5a50e641 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/config/SignatureConfiguration.java @@ -0,0 +1,95 @@ +package com.jongsoft.finance.config; + +import com.nimbusds.jose.JWSAlgorithm; + +import io.micronaut.context.annotation.Bean; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Value; +import io.micronaut.security.token.jwt.signature.SignatureGeneratorConfiguration; +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGenerator; +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGeneratorConfiguration; + +import jakarta.inject.Named; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMException; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.Security; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Optional; + +@Factory +public class SignatureConfiguration implements RSASignatureGeneratorConfiguration { + + private static final Logger log = LoggerFactory.getLogger(SignatureConfiguration.class); + private RSAPrivateKey rsaPrivateKey; + private RSAPublicKey rsaPublicKey; + + public SignatureConfiguration( + @Value("${micronaut.application.storage.location}/rsa-2048bit-key-pair.pem") + String pemPath) { + var keyPair = SignatureConfiguration.keyPair(pemPath); + if (keyPair.isPresent()) { + this.rsaPrivateKey = (RSAPrivateKey) keyPair.get().getPrivate(); + this.rsaPublicKey = (RSAPublicKey) keyPair.get().getPublic(); + } + } + + @Override + public RSAPrivateKey getPrivateKey() { + return rsaPrivateKey; + } + + @Override + public JWSAlgorithm getJwsAlgorithm() { + return JWSAlgorithm.PS256; + } + + @Override + public RSAPublicKey getPublicKey() { + return rsaPublicKey; + } + + @Bean + @Named("generator") + SignatureGeneratorConfiguration signatureGeneratorConfiguration() { + return new RSASignatureGenerator(this); + } + + static Optional keyPair(String pemPath) { + // Load BouncyCastle as JCA provider + Security.addProvider(new BouncyCastleProvider()); + + // Parse the EC key pair + try (var pemParser = + new PEMParser(new InputStreamReader(Files.newInputStream(Paths.get(pemPath))))) { + var pemKeyPair = (PEMKeyPair) pemParser.readObject(); + + // Convert to Java (JCA) format + var converter = new JcaPEMKeyConverter(); + var keyPair = converter.getKeyPair(pemKeyPair); + + return Optional.of(keyPair); + } catch (FileNotFoundException e) { + log.warn("file not found: {}", pemPath); + } catch (PEMException e) { + log.warn("PEMException {}", e.getMessage()); + } catch (IOException e) { + log.warn("IOException {}", e.getMessage()); + } + + return Optional.empty(); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/CurrencyController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/CurrencyController.java new file mode 100644 index 00000000..b72d221c --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/CurrencyController.java @@ -0,0 +1,114 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.core.Currency; +import com.jongsoft.finance.providers.CurrencyProvider; +import com.jongsoft.finance.rest.model.CurrencyRequest; +import com.jongsoft.finance.rest.model.CurrencyResponse; +import com.jongsoft.finance.rest.model.PatchCurrencyRequest; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Controller +public class CurrencyController implements CurrencyApi { + + private final CurrencyProvider currencyProvider; + private final Logger logger; + + public CurrencyController(CurrencyProvider currencyProvider) { + this.currencyProvider = currencyProvider; + this.logger = LoggerFactory.getLogger(CurrencyApi.class); + } + + @Override + public HttpResponse<@Valid CurrencyResponse> createCurrency(CurrencyRequest currencyRequest) { + logger.info("Request to create new currency with code {}.", currencyRequest.getCode()); + if (currencyProvider.lookup(currencyRequest.getCode()).isPresent()) { + throw StatusException.badRequest( + "Currency with code " + currencyRequest.getCode() + " already exists"); + } + + var currency = new Currency( + currencyRequest.getName(), + currencyRequest.getCode(), + currencyRequest.getSymbol().charAt(0)); + return HttpResponse.created(convert(currency)); + } + + @Override + public List<@Valid CurrencyResponse> getCurrencies() { + logger.info("Retrieving all currencies from the system."); + + return currencyProvider.lookup().map(this::convert).toJava(); + } + + @Override + public CurrencyResponse getCurrencyByCode(String currencyCode) { + logger.info("Retrieving currency by code {}.", currencyCode); + + return currencyProvider + .lookup(currencyCode) + .map(this::convert) + .getOrThrow(() -> + StatusException.notFound("No currency found with code " + currencyCode)); + } + + @Override + public CurrencyResponse patchCurrencyByCode( + String currencyCode, PatchCurrencyRequest patchCurrencyRequest) { + logger.info("Patching currency by code {}.", currencyCode); + var currency = currencyProvider + .lookup(currencyCode) + .getOrThrow(() -> + StatusException.notFound("No currency found with code " + currencyCode)); + + if (patchCurrencyRequest.getEnabled() != null) { + if (patchCurrencyRequest.getEnabled().equals(true)) { + currency.enable(); + } else { + currency.disable(); + } + } + + if (patchCurrencyRequest.getDecimalPlaces() != null) { + currency.accuracy(patchCurrencyRequest.getDecimalPlaces()); + } + + return convert(currency); + } + + @Override + public CurrencyResponse updateCurrencyByCode( + String currencyCode, CurrencyRequest currencyRequest) { + logger.info("Updating currency by code {}.", currencyCode); + return currencyProvider + .lookup(currencyCode) + .map(currency -> { + currency.rename( + currencyRequest.getName(), + currency.getCode(), + currencyRequest.getSymbol().charAt(0)); + return currency; + }) + .map(this::convert) + .getOrThrow(() -> + StatusException.notFound("No currency found with code " + currencyCode)); + } + + private CurrencyResponse convert(Currency currency) { + return new CurrencyResponse( + currency.getName(), + currency.getCode(), + "" + currency.getSymbol(), + currency.getDecimalPlaces(), + currency.isEnabled()); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/FilesController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/FilesController.java new file mode 100644 index 00000000..c56b901b --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/FilesController.java @@ -0,0 +1,58 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.StorageService; +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.rest.model.FileResponse; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.multipart.CompletedFileUpload; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; + +import java.io.IOException; + +@Controller +public class FilesController implements FilesApi { + + private final Logger logger; + private final StorageService storageService; + + public FilesController(StorageService storageService) { + this.storageService = storageService; + this.logger = org.slf4j.LoggerFactory.getLogger(FilesApi.class); + } + + @Override + public HttpResponse deleteFile(String fileCode) { + logger.info("Deleting file {}.", fileCode); + + storageService.remove(fileCode); + return HttpResponse.noContent(); + } + + @Override + public byte @Nullable(inherited = true) [] downloadFile(String fileCode) { + logger.info("Downloading file {}.", fileCode); + + return storageService + .read(fileCode) + .getOrThrow(() -> StatusException.notFound("No file found with code " + fileCode)); + } + + @Override + public HttpResponse<@Valid FileResponse> uploadFile(CompletedFileUpload upload) { + logger.info("Uploading file {}.", upload.getFilename()); + + try { + var token = storageService.store(upload.getBytes()); + return HttpResponse.created(new FileResponse(token)); + } catch (IOException e) { + logger.error("Failed to store the file.", e); + throw StatusException.internalError("Failed to store the file."); + } + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/I18nController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/I18nController.java new file mode 100644 index 00000000..1b9f8a13 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/I18nController.java @@ -0,0 +1,45 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; + +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +@Controller +public class I18nController implements I18nApi { + + private final Logger logger = LoggerFactory.getLogger(I18nApi.class); + + @Override + public Map getTranslations(String languageCode) { + logger.info("Get translations for language code {}.", languageCode); + + var pathPart = "en".equals(languageCode) ? "" : "_" + languageCode; + + var response = new HashMap(); + loadProperties("/i18n/messages" + pathPart + ".properties") + .forEach((key, value) -> response.put(key.toString(), value.toString())); + loadProperties("/i18n/ValidationMessages" + pathPart + ".properties") + .forEach((key, value) -> response.put(key.toString(), value.toString())); + return response; + } + + private Properties loadProperties(String messageFile) { + try { + var textKeys = new Properties(); + textKeys.load(getClass().getResourceAsStream(messageFile)); + return textKeys; + } catch (IOException e) { + throw StatusException.internalError("Failed to load the localization file."); + } + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/OpenIdController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/OpenIdController.java new file mode 100644 index 00000000..b59d89e9 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/OpenIdController.java @@ -0,0 +1,26 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.config.OpenIdConfiguration; +import com.jongsoft.finance.rest.model.OpenIdConfiguration200Response; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.annotation.Controller; + +@Controller +@Requires(env = "openid") +public class OpenIdController implements OpenIdApi { + + private final OpenIdConfiguration openIdConfiguration; + + public OpenIdController(OpenIdConfiguration openIdConfiguration) { + this.openIdConfiguration = openIdConfiguration; + } + + @Override + public OpenIdConfiguration200Response openIdConfiguration() { + return new OpenIdConfiguration200Response( + openIdConfiguration.getAuthority(), + openIdConfiguration.getClientId(), + openIdConfiguration.getClientSecret()); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/ProfileController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/ProfileController.java new file mode 100644 index 00000000..2e701783 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/ProfileController.java @@ -0,0 +1,189 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.FinTrack; +import com.jongsoft.finance.domain.user.SessionToken; +import com.jongsoft.finance.domain.user.UserAccount; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.providers.UserProvider; +import com.jongsoft.finance.rest.model.*; +import com.jongsoft.finance.security.AuthenticationFacade; +import com.jongsoft.finance.security.TwoFactorHelper; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.Currency; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Controller +public class ProfileController implements ProfileApi { + + private final Logger logger; + private final UserProvider userProvider; + private final AuthenticationFacade authenticationFacade; + private final FinTrack application; + + public ProfileController( + UserProvider userProvider, + AuthenticationFacade authenticationFacade, + FinTrack application) { + this.userProvider = userProvider; + this.authenticationFacade = authenticationFacade; + this.application = application; + this.logger = LoggerFactory.getLogger(ProfileController.class); + } + + @Override + public HttpResponse<@Valid SessionResponse> createSession( + String user, SessionRequest sessionRequest) { + logger.info("Creating long lived session for user {}.", user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot access sessions of another user."); + } + + var tokenForSession = UUID.randomUUID().toString(); + application.registerToken(user, tokenForSession, (int) ChronoUnit.SECONDS.between( + LocalDateTime.now(), sessionRequest.getExpires().atTime(LocalTime.MIN))); + + var session = userProvider + .tokens(new UserIdentifier(user)) + .filter(token -> tokenForSession.equals(token.getToken())) + .head(); + + return HttpResponse.created(convert(session)); + } + + @Override + public byte @Nullable(inherited = true) [] generateQrCode(String user) { + logger.info("Retrieving QR-code for user {}.", user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot access sessions of another user."); + } + + return userProvider + .lookup(new UserIdentifier(user)) + .map(TwoFactorHelper::build2FactorQr) + .getOrThrow( + () -> StatusException.internalError("Cannot correctly determine user.")); + } + + @Override + public UserProfileResponse getProfile(String user) { + logger.info("Retrieving profile for user {}.", user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot access profile of another user."); + } + + return userProvider + .lookup(new UserIdentifier(user)) + .map(this::convert) + .getOrThrow( + () -> StatusException.internalError("Cannot correctly determine user.")); + } + + @Override + public List<@Valid SessionResponse> listSessions(String user) { + logger.info("Retrieving sessions for user {}.", user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot access sessions of another user."); + } + + return userProvider.tokens(new UserIdentifier(user)).map(this::convert).toJava(); + } + + @Override + public HttpResponse patch2Factor( + String user, PatchMultiFactorRequest patchMultiFactorRequest) { + logger.info("Patch 2-factor for user {}.", user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot access 2-factor1 of another user."); + } + + var userAccount = userProvider + .lookup(new UserIdentifier(user)) + .getOrThrow(() -> StatusException.notFound("Cannot find user.")); + switch (patchMultiFactorRequest) { + case EnableMfaRequest e -> { + if (!TwoFactorHelper.verifySecurityCode( + userAccount.getSecret(), e.getVerificationCode())) { + throw StatusException.badRequest("Invalid verification code provided."); + } + userAccount.enableMultiFactorAuthentication(); + } + case DisableMfaRequest ignored -> userAccount.disableMultiFactorAuthentication(); + default -> throw StatusException.internalError("Invalid patch multi-factor request."); + } + + return HttpResponse.noContent(); + } + + @Override + public UserProfileResponse patchProfile(String user, PatchProfileRequest patchProfileRequest) { + logger.info("Patching profile for user {}.", user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot patch profile of another user."); + } + + var userAccount = userProvider + .lookup(new UserIdentifier(user)) + .getOrThrow(() -> StatusException.notFound("Cannot find user.")); + + Optional.ofNullable(patchProfileRequest.getCurrency()) + .map(Currency::getInstance) + .ifPresent(userAccount::changeCurrency); + Optional.ofNullable(patchProfileRequest.getTheme()).ifPresent(userAccount::changeTheme); + + var hasher = application.getHashingAlgorithm(); + Optional.ofNullable(patchProfileRequest.getPassword()) + .map(hasher::encrypt) + .ifPresent(userAccount::changePassword); + + return convert(userAccount); + } + + @Override + public HttpResponse revokeSession(String user, Integer session) { + logger.info("Revoking session {} for user {}.", session, user); + if (!user.equalsIgnoreCase(authenticationFacade.authenticated())) { + throw StatusException.forbidden("Cannot revoke sessions of another user."); + } + + var sessionToken = userProvider.tokens(new UserIdentifier(user)).stream() + .filter(token -> token.getId().intValue() == session) + .findFirst() + .orElseThrow(() -> StatusException.notFound("Invalid session ID.")); + + sessionToken.revoke(); + return HttpResponse.noContent(); + } + + private UserProfileResponse convert(UserAccount userAccount) { + return new UserProfileResponse( + userAccount.getTheme(), + userAccount.getPrimaryCurrency().getCurrencyCode(), + userAccount.isTwoFactorEnabled()); + } + + private SessionResponse convert(SessionToken sessionToken) { + var response = new SessionResponse(); + response.setId(sessionToken.getId().intValue()); + response.setDescription(sessionToken.getDescription()); + response.setToken(sessionToken.getToken()); + response.setValid(new DateRange( + sessionToken.getValidity().from().toLocalDate(), + sessionToken.getValidity().until().toLocalDate())); + return response; + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/SecurityController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/SecurityController.java new file mode 100644 index 00000000..01c2b75e --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/SecurityController.java @@ -0,0 +1,86 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.FinTrack; +import com.jongsoft.finance.domain.user.Role; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.messaging.commands.StartProcessCommand; +import com.jongsoft.finance.rest.model.TokenRequest; +import com.jongsoft.finance.rest.model.UserRequest; +import com.jongsoft.finance.security.CurrentUserProvider; +import com.jongsoft.finance.security.TwoFactorHelper; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.token.generator.AccessRefreshTokenGenerator; +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureConfiguration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +@Controller +public class SecurityController implements SecurityApi { + + private final Logger logger; + + private final RSASignatureConfiguration rsaSignatureConfiguration; + private final FinTrack application; + private final CurrentUserProvider currentUserProvider; + private final AccessRefreshTokenGenerator accessRefreshTokenGenerator; + + public SecurityController( + RSASignatureConfiguration rsaSignatureConfiguration, + FinTrack application, + CurrentUserProvider currentUserProvider, + AccessRefreshTokenGenerator accessRefreshTokenGenerator) { + this.rsaSignatureConfiguration = rsaSignatureConfiguration; + this.application = application; + this.currentUserProvider = currentUserProvider; + this.accessRefreshTokenGenerator = accessRefreshTokenGenerator; + this.logger = LoggerFactory.getLogger(SecurityApi.class); + } + + @Override + public HttpResponse createUser(UserRequest userRequest) { + StartProcessCommand.startProcess( + "RegisterUserAccount", + Map.of( + "username", + new UserIdentifier(userRequest.getUsername()), + "passwordHash", + application.getHashingAlgorithm().encrypt(userRequest.getPassword()))); + + return HttpResponse.noContent(); + } + + @Override + public String getJwtSignature() { + logger.info("Providing the Jwt signature."); + return Base64.getEncoder() + .encodeToString(rsaSignatureConfiguration.getPublicKey().getEncoded()); + } + + @Override + public Object verifyTwoFactor(TokenRequest tokenRequest) { + var currentUser = currentUserProvider.currentUser(); + var validToken = TwoFactorHelper.verifySecurityCode( + currentUser.getSecret(), tokenRequest.getVerificationCode()); + + if (!validToken) { + throw StatusException.forbidden("Invalid verification token provided."); + } + + var authentication = Authentication.build( + currentUser.getUsername().email(), + currentUser.getRoles().stream().map(Role::getName).toList()); + + return accessRefreshTokenGenerator + .generate(UUID.randomUUID().toString(), authentication) + .orElseThrow(() -> StatusException.forbidden("Invalid authentication token.")); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/rest/SettingController.java b/website/system-api/src/main/java/com/jongsoft/finance/rest/SettingController.java new file mode 100644 index 00000000..cce0222b --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/rest/SettingController.java @@ -0,0 +1,53 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.core.Setting; +import com.jongsoft.finance.providers.SettingProvider; +import com.jongsoft.finance.rest.model.SettingRequest; +import com.jongsoft.finance.rest.model.SettingResponse; +import com.jongsoft.finance.rest.model.SettingResponseType; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; + +import jakarta.validation.Valid; + +import java.util.List; + +@Controller +public class SettingController implements SettingsApi { + + private final SettingProvider settingProvider; + + public SettingController(SettingProvider settingProvider) { + this.settingProvider = settingProvider; + } + + @Override + public List<@Valid SettingResponse> getAllSettings() { + return settingProvider.lookup().map(this::convert).toJava(); + } + + @Override + public HttpResponse patchSetting(String setting, SettingRequest settingRequest) { + var existing = settingProvider + .lookup(setting) + .getOrThrow( + () -> StatusException.notFound("No setting found with name " + setting)); + + existing.update(settingRequest.getValue().toString()); + return HttpResponse.noContent(); + } + + private SettingResponse convert(Setting setting) { + var settingType = + switch (setting.getType()) { + case DATE -> SettingResponseType.DATE; + case FLAG -> SettingResponseType.FLAG; + case NUMBER -> SettingResponseType.NUMBER; + case STRING -> SettingResponseType.STRING; + }; + + return new SettingResponse(setting.getName(), setting.getValue(), settingType); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/security/CurrentUserProviderImpl.java b/website/system-api/src/main/java/com/jongsoft/finance/security/CurrentUserProviderImpl.java new file mode 100644 index 00000000..23faf4c7 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/security/CurrentUserProviderImpl.java @@ -0,0 +1,30 @@ +package com.jongsoft.finance.security; + +import com.jongsoft.finance.domain.user.UserAccount; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.providers.UserProvider; +import com.jongsoft.lang.Control; + +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +@Singleton +@Named("currentUserProvider") +public class CurrentUserProviderImpl implements CurrentUserProvider { + + private final AuthenticationFacade authenticationFacade; + private final UserProvider userProvider; + + public CurrentUserProviderImpl( + AuthenticationFacade authenticationFacade, UserProvider userProvider) { + this.authenticationFacade = authenticationFacade; + this.userProvider = userProvider; + } + + @Override + public UserAccount currentUser() { + var username = Control.Option(authenticationFacade.authenticated()); + return username.map(s -> userProvider.lookup(new UserIdentifier(s)).getOrSupply(() -> null)) + .getOrSupply(() -> null); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/security/PasswordEncoder.java b/website/system-api/src/main/java/com/jongsoft/finance/security/PasswordEncoder.java new file mode 100644 index 00000000..28f43ecf --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/security/PasswordEncoder.java @@ -0,0 +1,35 @@ +package com.jongsoft.finance.security; + +import at.favre.lib.crypto.bcrypt.BCrypt; +import at.favre.lib.crypto.bcrypt.LongPasswordStrategies; + +import com.jongsoft.finance.core.Encoder; + +import jakarta.inject.Singleton; + +import java.security.SecureRandom; + +@Singleton +public class PasswordEncoder implements Encoder { + + private static final int HASHING_STRENGTH = 10; + + private final BCrypt.Hasher hashApplier; + + public PasswordEncoder() { + this.hashApplier = BCrypt.with( + BCrypt.Version.VERSION_2A, + new SecureRandom(), + LongPasswordStrategies.hashSha512(BCrypt.Version.VERSION_2A)); + } + + public String encrypt(String password) { + return hashApplier.hashToString(HASHING_STRENGTH, password.toCharArray()); + } + + public boolean matches(String hash, String password) { + var result = BCrypt.verifyer().verify(password.toCharArray(), hash); + + return result.verified; + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/security/TwoFactorHelper.java b/website/system-api/src/main/java/com/jongsoft/finance/security/TwoFactorHelper.java new file mode 100644 index 00000000..15f33784 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/security/TwoFactorHelper.java @@ -0,0 +1,53 @@ +package com.jongsoft.finance.security; + +import com.jongsoft.finance.core.exception.StatusException; +import com.jongsoft.finance.domain.user.UserAccount; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.code.HashingAlgorithm; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.ZxingPngQrGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; + +import java.util.regex.Pattern; + +public class TwoFactorHelper { + + private static final TimeProvider timeProvider = new SystemTimeProvider(); + private static final CodeGenerator codeGenerator = new DefaultCodeGenerator(); + + private static final Pattern SECURITY_CODE_PATTER = Pattern.compile("[0-9]{6}"); + + private TwoFactorHelper() {} + + public static byte[] build2FactorQr(UserAccount userAccount) { + var qrData = new QrData.Builder() + .label("Pledger.io: " + userAccount.getUsername()) + .secret(userAccount.getSecret()) + .issuer("Pledger.io") + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build(); + try { + var generator = new ZxingPngQrGenerator(); + generator.setImageSize(150); + return generator.generate(qrData); + } catch (QrGenerationException e) { + throw StatusException.internalError("Could not successfully generate QR code"); + } + } + + public static boolean verifySecurityCode(String secret, String securityCode) { + if (securityCode != null && SECURITY_CODE_PATTER.matcher(securityCode).matches()) { + var verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + return verifier.isValidCode(secret, securityCode); + } + + return false; + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/security/filter/PledgerAuthenticationFilter.java b/website/system-api/src/main/java/com/jongsoft/finance/security/filter/PledgerAuthenticationFilter.java new file mode 100644 index 00000000..8d653b7b --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/security/filter/PledgerAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.jongsoft.finance.security.filter; + +import com.jongsoft.finance.domain.FinTrack; +import com.jongsoft.finance.messaging.InternalAuthenticationEvent; + +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.filter.ServerFilterPhase; +import io.micronaut.security.authentication.ServerAuthentication; + +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Filter("/v2/api/**") +public class PledgerAuthenticationFilter implements HttpServerFilter { + + private final Logger logger; + private final FinTrack application; + private final ApplicationEventPublisher eventPublisher; + + public PledgerAuthenticationFilter( + FinTrack application, + ApplicationEventPublisher eventPublisher) { + this.application = application; + this.eventPublisher = eventPublisher; + this.logger = LoggerFactory.getLogger(PledgerAuthenticationFilter.class); + } + + @Override + public int getOrder() { + return ServerFilterPhase.SECURITY.after(); + } + + @Override + public Publisher> doFilter( + HttpRequest request, ServerFilterChain chain) { + if (request.getUserPrincipal().isPresent()) { + var principal = request.getUserPrincipal().get(); + var userName = principal.getName(); + if (principal instanceof ServerAuthentication authentication) { + userName = handleOathUserCreation(authentication); + } + + logger.debug("User {} authenticated using HttpRequest.", userName); + eventPublisher.publishEvent(new InternalAuthenticationEvent(this, userName)); + } + + return chain.proceed(request); + } + + private String handleOathUserCreation(ServerAuthentication authentication) { + var hasEmail = authentication.getAttributes().containsKey("email"); + if (hasEmail) { + var userName = authentication.getAttributes().get("email").toString(); + application.createOathUser( + userName, authentication.getName(), List.copyOf(authentication.getRoles())); + return userName; + } + + return authentication.getName(); + } +} diff --git a/website/system-api/src/main/java/com/jongsoft/finance/security/provider/PledgerAuthenticationProvider.java b/website/system-api/src/main/java/com/jongsoft/finance/security/provider/PledgerAuthenticationProvider.java new file mode 100644 index 00000000..00195575 --- /dev/null +++ b/website/system-api/src/main/java/com/jongsoft/finance/security/provider/PledgerAuthenticationProvider.java @@ -0,0 +1,65 @@ +package com.jongsoft.finance.security.provider; + +import com.jongsoft.finance.domain.FinTrack; +import com.jongsoft.finance.domain.user.Role; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.providers.UserProvider; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.authentication.AuthenticationFailureReason; +import io.micronaut.security.authentication.AuthenticationRequest; +import io.micronaut.security.authentication.AuthenticationResponse; +import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider; + +import jakarta.inject.Singleton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +@Singleton +public class PledgerAuthenticationProvider + implements HttpRequestAuthenticationProvider> { + + private final Logger logger; + private final UserProvider userProvider; + private final FinTrack application; + + public PledgerAuthenticationProvider(UserProvider userProvider, FinTrack application) { + this.userProvider = userProvider; + this.application = application; + this.logger = LoggerFactory.getLogger(PledgerAuthenticationProvider.class); + } + + @Override + public @NonNull AuthenticationResponse authenticate( + @Nullable HttpRequest> requestContext, + @NonNull AuthenticationRequest authRequest) { + logger.info("Authentication Http request for user {}.", authRequest.getIdentity()); + + var userAccount = userProvider + .lookup(new UserIdentifier(authRequest.getIdentity())) + .getOrThrow(() -> AuthenticationResponse.exception( + AuthenticationFailureReason.USER_NOT_FOUND)); + boolean matches = application + .getHashingAlgorithm() + .matches(userAccount.getPassword(), authRequest.getSecret()); + if (!matches) { + throw AuthenticationResponse.exception( + AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH); + } + + List roles = new ArrayList<>(); + if (userAccount.isTwoFactorEnabled()) { + roles.add("PRE_VERIFICATION_USER"); + } else { + userAccount.getRoles().map(Role::getName).forEach(roles::add); + } + + return AuthenticationResponse.success(userAccount.getUsername().email(), roles); + } +} diff --git a/fintrack-api/src/main/resources/i18n/ValidationMessages.properties b/website/system-api/src/main/resources/i18n/ValidationMessages.properties similarity index 92% rename from fintrack-api/src/main/resources/i18n/ValidationMessages.properties rename to website/system-api/src/main/resources/i18n/ValidationMessages.properties index 2aa1d1d5..b12117b2 100644 --- a/fintrack-api/src/main/resources/i18n/ValidationMessages.properties +++ b/website/system-api/src/main/resources/i18n/ValidationMessages.properties @@ -8,10 +8,10 @@ Size.category.description=Description cannot be larger than 1024 characters NotNull.transaction.date=A transaction date is mandatory NotNull.transaction.currency=Currency is mandatory Size.transaction.description=Description cannot be larger than 1024 characters -NotUnique.account.name=Name is not unique in FinTrack +NotUnique.account.name=Name is not unique in Pledger.io Pattern.date=The provided date is not valid Min.account.amount=Amount should be greater than 0 -NotUnique.category.label=Name is not unique in FinTrack +NotUnique.category.label=Name is not unique in Pledger.io Min.user.name=Username should be at least 6 characters Min.user.password=Password should be at least 8 characters Mismatch.userAccount.verificationCode=The 2 step verification code was not correct. @@ -55,3 +55,6 @@ Category.name.required=The name of the category is required. Currency.code.required=Currency code is mandatory. Currency.symbol.required=Symbol for currency is mandatory. Currency.name.required=Name for currency mandatory. + +contract.source.not.found=The source account for the contract is not found. +contract.destination.not.found=The destination for the contract is not found. diff --git a/fintrack-api/src/main/resources/i18n/ValidationMessages_de.properties b/website/system-api/src/main/resources/i18n/ValidationMessages_de.properties similarity index 96% rename from fintrack-api/src/main/resources/i18n/ValidationMessages_de.properties rename to website/system-api/src/main/resources/i18n/ValidationMessages_de.properties index 5c72763f..2d242611 100644 --- a/fintrack-api/src/main/resources/i18n/ValidationMessages_de.properties +++ b/website/system-api/src/main/resources/i18n/ValidationMessages_de.properties @@ -8,10 +8,10 @@ Size.category.description=Die Beschreibung darf nicht länger als 1024 Zeichen s NotNull.transaction.date=Ein Transaktionsdatum ist obligatorisch NotNull.transaction.currency=Währung ist obligatorisch Size.transaction.description=Die Beschreibung darf nicht länger als 1024 Zeichen sein -NotUnique.account.name=Der Name ist in FinTrack nicht eindeutig +NotUnique.account.name=Der Name ist in Pledger.io nicht eindeutig Pattern.date=Das angegebene Datum ist ungültig Min.account.amount=Betrag muss größer als 0 sein -NotUnique.category.label=Der Name ist in FinTrack nicht eindeutig +NotUnique.category.label=Der Name ist in Pledger.io nicht eindeutig Min.user.name=Der Benutzername sollte aus mindestens 6 Zeichen bestehen Min.user.password=Das Passwort sollte aus mindestens 8 Zeichen bestehen Mismatch.userAccount.verificationCode=Der zweistufige Bestätigungscode war nicht korrekt. diff --git a/fintrack-api/src/main/resources/i18n/ValidationMessages_nl.properties b/website/system-api/src/main/resources/i18n/ValidationMessages_nl.properties similarity index 96% rename from fintrack-api/src/main/resources/i18n/ValidationMessages_nl.properties rename to website/system-api/src/main/resources/i18n/ValidationMessages_nl.properties index 6c97d983..d7c97fe7 100644 --- a/fintrack-api/src/main/resources/i18n/ValidationMessages_nl.properties +++ b/website/system-api/src/main/resources/i18n/ValidationMessages_nl.properties @@ -8,10 +8,10 @@ Size.category.description=Omschrijving kan niet meer dan 1024 karakters zijn NotNull.transaction.date=Een transactie datum is verplicht NotNull.transaction.currency=Valuta dient geselecteerd te zijn Size.transaction.description=Omschrijving kan niet meer dan 1024 karakters zijn -NotUnique.account.name=Rekeningnaam is niet uniek in FinTrack +NotUnique.account.name=Rekeningnaam is niet uniek in Pledger.io Pattern.date=De opgegeven datum is niet correct Min.account.amount=Bedrag dient groter dan 0 te zijn -NotUnique.category.label=De naam is niet uniek binnen FinTrack +NotUnique.category.label=De naam is niet uniek binnen Pledger.io Min.user.name=Gebruikersnaam dient minimaal 6 karakters te bevatten Min.user.password=Het wachtwoord dient minimaal 8 karakters te bevatten Mismatch.userAccount.verificationCode=De 2 staps verificatie code was onjuist. diff --git a/fintrack-api/src/main/resources/i18n/messages.properties b/website/system-api/src/main/resources/i18n/messages.properties similarity index 99% rename from fintrack-api/src/main/resources/i18n/messages.properties rename to website/system-api/src/main/resources/i18n/messages.properties index a948b9cc..d715b489 100644 --- a/fintrack-api/src/main/resources/i18n/messages.properties +++ b/website/system-api/src/main/resources/i18n/messages.properties @@ -51,6 +51,7 @@ Currency.enabled=Enabled Currency.name=Currency Currency.symbol=Symbol Import.config=Configuration +ImportConfig.type=Configuration type ImportConfig.Json.Mapping.ACCOUNT_IBAN=Account IBAN ImportConfig.Json.Mapping.AMOUNT=Amount ImportConfig.Json.Mapping.BOOK_DATE=Booking date @@ -655,3 +656,5 @@ page.reports.insights.patterns_found=patterns found severity.alert=Alert severity.warning=Warning severity.info=Informational +budget.contracts.updated.failed=Failed to update the contract information. +budget.contracts.updated.success=Contract information successfully updated. diff --git a/fintrack-api/src/main/resources/i18n/messages_de.properties b/website/system-api/src/main/resources/i18n/messages_de.properties similarity index 98% rename from fintrack-api/src/main/resources/i18n/messages_de.properties rename to website/system-api/src/main/resources/i18n/messages_de.properties index 7191854c..2e8690ff 100644 --- a/fintrack-api/src/main/resources/i18n/messages_de.properties +++ b/website/system-api/src/main/resources/i18n/messages_de.properties @@ -288,8 +288,8 @@ page.budget.contracts.active=Laufende Verträge page.budget.contracts.add=Neuer Vertrag page.budget.contracts.daterange=Vertragslaufzeit page.budget.contracts.delete.confirm=Möchten Sie den Vertrag wirklich kündigen? -page.budget.contracts.delete.failed=Der Vertrag konnte in FinTrack nicht gekündigt werden. -page.budget.contracts.delete.success=Vertrag von FinTrack erfolgreich gekündigt. +page.budget.contracts.delete.failed=Der Vertrag konnte in Pledger.io nicht gekündigt werden. +page.budget.contracts.delete.success=Vertrag von Pledger.io erfolgreich gekündigt. page.budget.contracts.inactive=Alte langfristige Verträge page.budget.contracts.title=Verfügbare Verträge page.budget.contracts.transactions=transaktionen anzeigen @@ -328,8 +328,8 @@ page.budget.schedules.title=Übersicht über geplante Transaktionen page.category.create.failed=Die Erstellung der Kategorie ist fehlgeschlagen. page.category.create.success=Die Kategorie wurde erfolgreich erstellt. page.category.delete.confirm=Möchten Sie diese Kategorie wirklich löschen? -page.category.delete.failed=Kategorie konnte nicht aus FinTrack entfernt werden. -page.category.delete.success=Kategorie erfolgreich aus FinTrack entfernen. +page.category.delete.failed=Kategorie konnte nicht aus Pledger.io entfernt werden. +page.category.delete.success=Kategorie erfolgreich aus Pledger.io entfernen. page.category.update.failed=Die Kategorie konnte nicht aktualisiert werden. page.category.update.success=Die Kategorie wurde erfolgreich aktualisiert. page.contract.action.schedule=Planen Sie Transfers @@ -456,7 +456,7 @@ page.settings.rules.group.delete.error=Fehler beim Entfernen der Transaktionsreg page.settings.rules.group.down.error=Fehler beim Verschieben der Regelgruppe nach unten. page.settings.rules.group.rename=Anderes nahme page.settings.rules.group.up.error=Fehler beim Verschieben der Regelgruppe nach oben. -page.settings.rules.help=Eine Regel hilft bei der Korrektur von Transaktionen, die in das FinTrack-System importiert werden. Dies ermöglicht es beispielsweise, während einer Importaktion automatisch eine Kategorie zuzuweisen oder Konten anzupassen. +page.settings.rules.help=Eine Regel hilft bei der Korrektur von Transaktionen, die in das Pledger.io-System importiert werden. Dies ermöglicht es beispielsweise, während einer Importaktion automatisch eine Kategorie zuzuweisen oder Konten anzupassen. page.settings.rules.import=Regeln importieren page.settings.rules.optional=Optionale Angaben page.settings.rules.update.failed=Transaktionsregel konnte nicht aktualisiert werden. diff --git a/fintrack-api/src/main/resources/i18n/messages_nl.properties b/website/system-api/src/main/resources/i18n/messages_nl.properties similarity index 100% rename from fintrack-api/src/main/resources/i18n/messages_nl.properties rename to website/system-api/src/main/resources/i18n/messages_nl.properties diff --git a/website/system-api/src/test/java/com/jongsoft/finance/rest/CurrencyTest.java b/website/system-api/src/test/java/com/jongsoft/finance/rest/CurrencyTest.java new file mode 100644 index 00000000..96f81b9b --- /dev/null +++ b/website/system-api/src/test/java/com/jongsoft/finance/rest/CurrencyTest.java @@ -0,0 +1,103 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.messaging.EventBus; +import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@MicronautTest(environments = {"jpa", "h2", "test"}) +public class CurrencyTest { + + @Replaces + @MockBean + public AuthenticationFacade authenticationFacade() { + var mockedFacade = mock(AuthenticationFacade.class); + when(mockedFacade.authenticated()).thenReturn("test@account.local"); + return mockedFacade; + } + + @BeforeEach + void setup(ApplicationEventPublisher publisher) { + new EventBus(publisher); + } + + @Test + @DisplayName("Create the KSH currency and validate it can be retrieved") + void createKSHCurrency(RequestSpecification spec) { + RestAssured.given(spec) + .contentType(ContentType.JSON) + .body(Map.of( + "name", "Kenyan Shilling", + "code", "KSH", + "symbol", "$")) + .when() + .post("/v2/api/currencies") + .then() + .log().ifError() + .statusCode(201) + .body("name", equalTo("Kenyan Shilling")) + .body("code", startsWith("KSH")) + .body("symbol", equalTo("$")) + .body("enabled", equalTo(true)) + .body("decimalPlaces", equalTo(2)); + + // check the fetch + RestAssured.given(spec) + .pathParam("code", "KSH") + .get("/v2/api/currencies/{code}") + .then() + .statusCode(200) + .body("name", equalTo("Kenyan Shilling")) + .body("code", startsWith("KSH")); + + // disable the currency + RestAssured.given(spec) + .contentType(ContentType.JSON) + .body(Map.of( + "enabled", false, + "decimalPlaces", 4)) + .when() + .pathParam("code", "KSH") + .patch("/v2/api/currencies/{code}") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("name", equalTo("Kenyan Shilling")) + .body("code", equalTo("KSH")) + .body("enabled", equalTo(false)) + .body("decimalPlaces", equalTo(4)); + + // rename the currency + RestAssured.given(spec) + .contentType(ContentType.JSON) + .body(Map.of( + "name", "Kenyan Shilling (KS)", + "symbol", "S")) + .when() + .pathParam("code", "KSH") + .put("/v2/api/currencies/{code}") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("name", equalTo("Kenyan Shilling (KS)")) + .body("code", equalTo("KSH")) + .body("symbol", equalTo("S")) + .body("enabled", equalTo(false)) + .body("decimalPlaces", equalTo(4)); + } +} diff --git a/website/system-api/src/test/java/com/jongsoft/finance/rest/FileTest.java b/website/system-api/src/test/java/com/jongsoft/finance/rest/FileTest.java new file mode 100644 index 00000000..b23a3b03 --- /dev/null +++ b/website/system-api/src/test/java/com/jongsoft/finance/rest/FileTest.java @@ -0,0 +1,93 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.domain.user.UserAccount; +import com.jongsoft.finance.domain.user.UserIdentifier; +import com.jongsoft.finance.messaging.EventBus; +import com.jongsoft.finance.providers.UserProvider; +import com.jongsoft.finance.security.AuthenticationFacade; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@MicronautTest(environments = {"jpa", "h2", "test"}) +public class FileTest { + + @Inject + private UserProvider userProvider; + @Inject + private ApplicationEventPublisher eventPublisher; + + @Replaces + @MockBean + public AuthenticationFacade authenticationFacade() { + var mockedFacade = mock(AuthenticationFacade.class); + when(mockedFacade.authenticated()).thenReturn("test@account.local"); + return mockedFacade; + } + + @BeforeEach + void setupUserAccount() { + new EventBus(eventPublisher); + userProvider.lookup(new UserIdentifier("test@account.local")) + .ifNotPresent(() -> new UserAccount("test@account.local", "test123")); + } + + @Test + void uploadAndCheckFile(RequestSpecification spec) throws IOException { + var file = getClass().getResource("/logback.xml").getFile(); + + var fileCode = + given(spec) + .multiPart("upload", new File(file)) + .when() + .post("/v2/api/files") + .then() + .log().ifError() + .statusCode(201) + .body("fileCode", notNullValue()) + .extract() + .jsonPath() + .getString("fileCode"); + + var fileContent = given(spec) + .pathParam("fileCode", fileCode) + .when() + .get("/v2/api/files/{fileCode}") + .then() + .statusCode(200) + .extract() + .asByteArray(); + + var expected = Files.readAllBytes(new File(file).toPath()); + assertThat(fileContent) + .containsExactly(expected); + + given(spec) + .pathParam("fileCode", fileCode) + .delete("/v2/api/files/{fileCode}") + .then() + .statusCode(204); + + given(spec) + .pathParam("fileCode", fileCode) + .when() + .get("/v2/api/files/{fileCode}") + .then() + .statusCode(500); + } +} diff --git a/website/system-api/src/test/java/com/jongsoft/finance/rest/I18nTest.java b/website/system-api/src/test/java/com/jongsoft/finance/rest/I18nTest.java new file mode 100644 index 00000000..ae69d4c0 --- /dev/null +++ b/website/system-api/src/test/java/com/jongsoft/finance/rest/I18nTest.java @@ -0,0 +1,44 @@ +package com.jongsoft.finance.rest; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.Matchers.equalTo; + +@MicronautTest(environments = {"jpa", "h2", "test"}) +public class I18nTest { + + @Test + void fetchDutchTranslations(RequestSpecification spec) { + spec.given() + .pathParam("languageCode", "nl") + .when() + .get("/v2/api/i18n/{languageCode}") + .then() + .statusCode(200) + .body("'common.action.edit'", equalTo("Bewerken")); + } + + @Test + void fetchEnglishTranslations(RequestSpecification spec) { + spec.given() + .pathParam("languageCode", "en") + .when() + .get("/v2/api/i18n/{languageCode}") + .then() + .statusCode(200) + .body("'common.action.edit'", equalTo("Edit")); + } + + @Test + void fetchGermanTranslations(RequestSpecification spec) { + spec.given() + .pathParam("languageCode", "de") + .when() + .get("/v2/api/i18n/{languageCode}") + .then() + .statusCode(200) + .body("'common.action.edit'", equalTo("Bearbeiten")); + } +} diff --git a/website/system-api/src/test/java/com/jongsoft/finance/rest/SecurityTest.java b/website/system-api/src/test/java/com/jongsoft/finance/rest/SecurityTest.java new file mode 100644 index 00000000..e7b88e92 --- /dev/null +++ b/website/system-api/src/test/java/com/jongsoft/finance/rest/SecurityTest.java @@ -0,0 +1,123 @@ +package com.jongsoft.finance.rest; + +import com.jongsoft.finance.core.MailDaemon; +import com.jongsoft.finance.messaging.EventBus; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.http.HttpHeaders; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.token.generator.AccessRefreshTokenGenerator; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.filter.log.LogDetail; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.mock; + +@MicronautTest(environments = {"jpa", "h2", "openid", "test", "security"}) +public class SecurityTest { + + @Inject + private AccessRefreshTokenGenerator accessRefreshTokenGenerator; + + @MockBean(MailDaemon.class) + MailDaemon mailDaemon() { + return mock(MailDaemon.class); + } + + @BeforeEach + void setup(ApplicationEventPublisher publisher) { + new EventBus(publisher); + } + + @Test + void checkTheOpenIdConfiguration(RequestSpecification spec) { + spec.given() + .get("/.well-known/openid-connect") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("authority", equalTo("http://authority.tst.local")) + .body("client-id", equalTo("my-client-id")) + .body("client-secret", equalTo("super-secret-password")); + } + + @Test + void registerNewAccount(RequestSpecification spec) { + spec.given() + .contentType(ContentType.JSON) + .body(Map.of( + "username", "test@account.local", + "password", "test123")) + .when() + .post("/v2/api/user-account") + .then() + .log().ifValidationFails() + .statusCode(204); + + // fetch profile + given(spec) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + getToken()) + .pathParam("user-account", "test@account.local") + .get("/v2/api/user-account/{user-account}") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("theme", equalTo("light")) + .body("currency", equalTo("EUR")) + .body("mfa", equalTo(false)); + + given(spec) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + getToken()) + .pathParam("user-account", "test@account.local") + .body(Map.of("description", "Long session", "expires", "2028-02-01")) + .post("/v2/api/user-account/{user-account}/sessions") + .then() + .log() + .ifValidationFails() + .statusCode(201) + .body("description", equalTo("Pledger.io Web login")) + .body("valid.startDate", equalTo(LocalDate.now().toString())) + .body("valid.endDate", equalTo("2028-01-31")); + + given(spec) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + getToken()) + .pathParam("user-account", "test@account.local") + .get("/v2/api/user-account/{user-account}/sessions") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("[0].description", equalTo("Pledger.io Web login")) + .body("[0].valid.startDate", equalTo(LocalDate.now().toString())) + .body("[0].valid.endDate", equalTo("2028-01-31")); + + // change currency to GBP + given(spec) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + getToken()) + .pathParam("user-account", "test@account.local") + .body(Map.of( + "theme", "dark", + "currency", "GBP")) + .patch("/v2/api/user-account/{user-account}") + .then() + .log().ifValidationFails(LogDetail.ALL) + .statusCode(200) + .body("theme", equalTo("dark")) + .body("currency", equalTo("GBP")) + .body("mfa", equalTo(false)); + } + + private String getToken() { + return accessRefreshTokenGenerator.generate(Authentication.build("test@account.local")) + .get() + .getAccessToken(); + } +} diff --git a/website/system-api/src/test/resources/application-security.properties b/website/system-api/src/test/resources/application-security.properties new file mode 100644 index 00000000..9741eaad --- /dev/null +++ b/website/system-api/src/test/resources/application-security.properties @@ -0,0 +1,4 @@ +micronaut.security.enabled=true +micronaut.security.token.jwt.enabled=true +micronaut.security.authentication=bearer +micronaut.security.filter.enabled=true diff --git a/website/system-api/src/test/resources/application-test.properties b/website/system-api/src/test/resources/application-test.properties new file mode 100644 index 00000000..75604f63 --- /dev/null +++ b/website/system-api/src/test/resources/application-test.properties @@ -0,0 +1,10 @@ +micronaut.security.enabled=false +datasources.default.url=jdbc:h2:mem:pledger-io;DB_CLOSE_DELAY=50;MODE=MariaDB + +# open-id configuration +application.openid.client-id=my-client-id +application.openid.client-secret=super-secret-password +application.openid.authority=http://authority.tst.local + +micronaut.application.storage.location=./build/resources/test +micronaut.server.multipart.enabled=true diff --git a/website/system-api/src/test/resources/logback.xml b/website/system-api/src/test/resources/logback.xml new file mode 100644 index 00000000..d3eef018 --- /dev/null +++ b/website/system-api/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + false + + + %cyan(%d{HH:mm:ss.SSS}) %green(%-36X{correlationId}) %highlight(%-5level) %gray([%thread]) %magenta(%logger{36}) - %msg%n + + + + + + + + diff --git a/fintrack-api/src/test/resources/rsa-2048bit-key-pair.pem b/website/system-api/src/test/resources/rsa-2048bit-key-pair.pem similarity index 100% rename from fintrack-api/src/test/resources/rsa-2048bit-key-pair.pem rename to website/system-api/src/test/resources/rsa-2048bit-key-pair.pem