Skip to content

Spending pattern analysis #124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jun 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6571674
Simplify embedding store structure and integrate seasonal pattern det…
gjong Jun 12, 2025
8e533ac
Refactor rule automation and enhance database schema.
gjong Jun 13, 2025
bbe1331
Add infrastructure for Analyze Job scheduling and processing
gjong Jun 13, 2025
9888c3e
Refactor spending detection and add comprehensive unit tests
gjong Jun 14, 2025
1d4c179
Move DB config into repository module.
gjong Jun 15, 2025
c732667
Add the option to disable the spending analytics.
gjong Jun 15, 2025
ce0f46e
Repair broken tests.
gjong Jun 15, 2025
8209326
Resolve conflict between test config and production h2 config.
gjong Jun 15, 2025
9e8969a
Resolve conflict between test config and production h2 config.
gjong Jun 15, 2025
82fa537
Add the contract expire warning e-mail.
gjong Jun 15, 2025
e189355
Correct the tags for the OpenApi documentation of the REST API.
gjong Jun 17, 2025
13e474c
Add missing tests for the spending insight classes.
gjong Jun 17, 2025
7366fac
Remove unused variable in ImporterTransactionResource
gjong Jun 17, 2025
d6fdbdb
Ensure that the statistics data is cleaned after the run.
gjong Jun 17, 2025
f10a556
Reduce risk of column name type for the spending insight.
gjong Jun 17, 2025
45fae59
Apply checkstyles.
gjong Jun 17, 2025
91ceb7a
Add monthly report mailing for spending insights and patterns.
gjong Jun 21, 2025
b7ec7c2
Refactor code to simplify multiline lambda expressions.
gjong Jun 21, 2025
ba3b1bb
Add Mockito configuration and enhance spending detectors
gjong Jun 21, 2025
101edb3
Refactor command handling to avoid nested object dependencies
gjong Jun 21, 2025
786e694
Add unit test for EmbeddingStoreFiller transaction consumption
gjong Jun 21, 2025
189e50e
Add spending insights and patterns endpoints with tests
gjong Jun 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions bpmn-process/src/main/java/com/jongsoft/finance/ProcessMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,19 @@ public ProcessMapper(ObjectMapper objectMapper) {

public <T> String writeSafe(T entity) {
return Control.Try(() -> objectMapper.writeValueAsString(entity))
.recover(
x -> {
log.warn("Could not serialize entity {}", entity, x);
return null;
})
.recover(x -> {
log.warn("Could not serialize entity {}", entity, x);
return null;
})
.get();
}

public <T> T readSafe(String json, Class<T> clazz) {
return Control.Try(() -> objectMapper.readValue(json, clazz))
.recover(
x -> {
log.warn("Could not deserialize json {}", json, x);
return null;
})
.recover(x -> {
log.warn("Could not deserialize json {}", json, x);
return null;
})
.get();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,11 @@ public ProcessEngine processEngine() throws IOException {
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)));
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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,26 @@ public Resolver createResolver(VariableScope variableScope) {

protected synchronized Set<String> getKeySet() {
if (keySet == null) {
keySet =
applicationContext.getAllBeanDefinitions().stream()
.filter(
beanDefinition ->
!beanDefinition.getClass().getName().startsWith("io.micronaut."))
.map(this::getBeanName)
.collect(Collectors.toSet());
keySet = 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);
});
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ public boolean isReadOnly(ELContext context, Object base, Object property) {
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.");
throw new ProcessEngineException("Cannot set value of '"
+ property
+ "', it resolves to a bean defined in the Micronaut"
+ " application-context.");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public void execute(DelegateExecution execution) throws Exception {
}

if (execution.hasVariableLocal("onlyIncome")) {
boolean onlyIncome = execution.<BooleanValue>getVariableLocalTyped("onlyIncome").getValue();
boolean onlyIncome =
execution.<BooleanValue>getVariableLocalTyped("onlyIncome").getValue();
requestBuilder.onlyIncome(onlyIncome);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,48 +46,39 @@ public class ProcessAccountCreationDelegate implements JavaDelegate, JavaBean {

@Override
public void execute(DelegateExecution execution) {
var accountJson =
mapper.readSafe(
execution.<StringValue>getVariableLocalTyped("account").getValue(), AccountJson.class);
var accountJson = mapper.readSafe(
execution.<StringValue>getVariableLocalTyped("account").getValue(), AccountJson.class);

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,10 @@ public void execute(DelegateExecution execution) {
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(x -> true);
matchedAccount = accountProvider
.lookup(accountFilterFactory.account().iban(iban, true))
.content()
.first(ignored -> true);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,17 @@ public void execute(DelegateExecution execution) throws Exception {
amount);

Account toReconcile = accountProvider.lookup(accountId).get();
Account reconcileAccount =
accountProvider
.lookup(SystemAccountTypes.RECONCILE)
.getOrThrow(() -> StatusException.badRequest("Reconcile account not found"));
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 transaction = toReconcile.createTransaction(
reconcileAccount, amount.abs().doubleValue(), type, t -> t.description(
"Reconcile transaction")
.currency(toReconcile.getCurrency())
.date(transactionDate));

creationHandler.handleCreatedEvent(new CreateTransactionCommand(transaction));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,20 @@ public void execute(DelegateExecution execution) {
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();
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();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ public class ProcessBudgetCreateDelegate implements JavaDelegate, JavaBean {

@Override
public void execute(DelegateExecution execution) {
var budgetJson =
mapper.readSafe(
execution.<StringValue>getVariableLocalTyped("budget").getValue(), BudgetJson.class);
var budgetJson = mapper.readSafe(
execution.<StringValue>getVariableLocalTyped("budget").getValue(), BudgetJson.class);

log.debug(
"{}: Processing budget creation from json for period '{}'",
Expand All @@ -64,48 +63,38 @@ public void execute(DelegateExecution execution) {
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()));
.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()));
.send(new CreateBudgetCommand(Budget.builder()
.start(start)
.expectedIncome(budgetJson.getExpectedIncome())
.build()));
}

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())));
.forEach(e -> Control.Option(budget.determineExpense(e.getName()))
.ifPresent(currentExpense -> currentExpense.updateExpense(e.getUpperBound()))
.elseRun(
() -> budget.createExpense(e.getName(), e.getLowerBound(), e.getUpperBound())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ public void execute(DelegateExecution execution) throws Exception {

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));
.ifPresent(
budget -> execution.setVariable("expenses", budget.getExpenses().toJava()))
.elseThrow(() -> new IllegalStateException(
"Budget cannot be found for year " + year + " and month " + month));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public void execute(DelegateExecution execution) throws Exception {

category = categoryProvider.lookup(label).get();
} else {
category = categoryProvider.lookup((Long) execution.getVariableLocal("id")).get();
category =
categoryProvider.lookup((Long) execution.getVariableLocal("id")).get();
}

execution.setVariable("category", category);
Expand Down
Loading
Loading