From f0f0724495ce25ebd47286493171245f7a6449f3 Mon Sep 17 00:00:00 2001 From: Bryan Pinheiro Date: Mon, 14 Sep 2026 15:53:44 -0300 Subject: [PATCH 1/2] docs(alerts): specify atypical expense detection --- .../issue-140-out-of-pattern-expenses.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/specs/issue-140-out-of-pattern-expenses.md diff --git a/docs/specs/issue-140-out-of-pattern-expenses.md b/docs/specs/issue-140-out-of-pattern-expenses.md new file mode 100644 index 0000000..feceb50 --- /dev/null +++ b/docs/specs/issue-140-out-of-pattern-expenses.md @@ -0,0 +1,107 @@ +# Especificação — Issue #140: detectar gasto fora do padrão + +## 1. Metadata + +- **Status:** Approved — escopo derivado da issue #119 e autorizado para implementação nesta conversa +- **Autor:** Bryan Pinheiro / Codex +- **Data:** 2026-09-14 +- **Issue:** #140 (subtask da #119) +- **Revisores:** pendente no pull request + +## Context + +A issue #119 precisa identificar despesas que merecem uma explicação antes de +serem enviadas pelo Telegram. A primeira fatia deve produzir uma evidência +determinística e legível; entrega, deduplicação e preferências do usuário serão +tratadas nas subtasks seguintes. + +Para evitar alertas instáveis durante um mês incompleto, a análise usa o último +mês fechado. A referência é a média da mesma categoria nos três meses fechados +anteriores. Sem três meses de histórico, não há evidência suficiente para +classificar o gasto como atípico. + +## Functional Requirements + +- **FR-1:** O detector MUST analisar somente transações do tipo `EXPENSE`. +- **FR-2:** O período observado MUST ser o último mês fechado em relação ao relógio injetado. +- **FR-3:** A referência MUST usar a média mensal da mesma categoria nos três meses fechados anteriores. +- **FR-4:** Uma categoria MUST gerar candidato somente quando o histórico tiver três meses e o valor observado for pelo menos 50% maior que a média, com diferença mínima de R$ 100,00. +- **FR-5:** O resultado MUST explicar categoria, período observado, valor observado, média histórica e percentual de variação. +- **FR-6:** O detector MUST retornar resultados ordenados pela maior variação percentual e limitar a três categorias. +- **FR-7:** A regra MUST ser independente de Telegram, RabbitMQ, OpenAI e persistência de notificações. + +## Non-Functional Requirements + +- **NFR-1:** Valores monetários MUST usar `BigDecimal`. +- **NFR-2:** O relógio MUST ser injetável para permitir testes determinísticos. +- **NFR-3:** A consulta MUST filtrar por usuário, período e tipo no banco; o serviço não deve carregar transações fora da janela analisada. +- **NFR-4:** Nenhuma migration ou alteração de contrato HTTP é necessária nesta fatia. + +## Acceptance Criteria + +### AC-1: detectar categoria acima do padrão (FR-1, FR-2, FR-3, FR-4) + +**Given:** três meses anteriores com média de R$ 200,00 e o último mês fechado com R$ 350,00 na categoria. +**When:** o detector é executado. +**Then:** retorna um candidato para essa categoria, pois o aumento é de 75% e R$ 150,00. + +### AC-2: explicar o resultado (FR-5) + +**Given:** um candidato detectado. +**When:** seus dados são consultados. +**Then:** categoria, mês observado, valor atual, média, percentual e explicação textual estão preenchidos. + +### AC-3: não alertar sem histórico suficiente ou sem limiar (FR-3, FR-4) + +**Given:** menos de três meses de histórico, aumento inferior a 50% ou diferença inferior a R$ 100,00. +**When:** o detector é executado. +**Then:** não retorna candidato para a categoria. + +### AC-4: ordenar e limitar resultados (FR-6) + +**Given:** mais de três categorias acima do limiar. +**When:** o detector é executado. +**Then:** retorna no máximo três resultados, do maior para o menor percentual. + +### AC-5: isolar a análise (FR-7, NFR-4) + +**Given:** execução do detector. +**When:** a regra é avaliada. +**Then:** nenhuma chamada a Telegram, RabbitMQ, OpenAI ou gravação de notificação ocorre. + +## Edge Cases + +- **EC-1:** Valor histórico igual a zero não gera percentual de variação nem candidato. +- **EC-2:** Categorias sem transações em um dos três meses de referência não têm histórico completo e não geram candidato. +- **EC-3:** Valores nulos retornados pelo repository devem ser tratados como zero ou ignorados sem lançar erro. +- **EC-4:** Usuário nulo deve ser rejeitado com `IllegalArgumentException`. +- **EC-5:** Datas futuras ficam fora da janela fechada analisada. + +## API Contracts + +Não há endpoint HTTP nesta subtask. O contrato interno é: + +```java +List detect(User user) +``` + +## Data Models + +`AtypicalExpenseAlert` é um record imutável em memória: + +| Campo | Tipo | Restrições | +| --- | --- | --- | +| categoryName | String | obrigatório | +| observedMonth | YearMonth | último mês fechado | +| observedAmount | BigDecimal | >= 0 | +| historicalAverage | BigDecimal | > 0 | +| variationPercentage | BigDecimal | percentual com 2 casas | +| explanation | String | texto em português | + +## Out of Scope + +- **OS-1:** Persistir alertas ou controlar duplicidade. +- **OS-2:** Preferências de ativação, frequência ou opt-out. +- **OS-3:** Enviar mensagens pelo Telegram ou RabbitMQ. +- **OS-4:** Detectar parcelas, orçamento apertado ou gerar resumos. +- **OS-5:** Criar endpoint público. From ee7646f7f031bfa805bdcac08d18ed1e86cb2b4c Mon Sep 17 00:00:00 2001 From: Bryan Pinheiro Date: Mon, 14 Sep 2026 15:53:49 -0300 Subject: [PATCH 2/2] feat(alerts): detect atypical expenses --- .../alert/domain/AtypicalExpenseAlert.java | 14 ++ .../alert/port/ExpenseHistoryPort.java | 15 ++ .../service/AtypicalExpenseDetector.java | 113 +++++++++++++++ .../com/financebot/config/TimeConfig.java | 15 ++ .../ExpenseByCategoryAndDateProjection.java | 13 ++ .../repository/TransactionRepository.java | 17 ++- .../service/AtypicalExpenseDetectorTest.java | 130 ++++++++++++++++++ 7 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/financebot/alert/domain/AtypicalExpenseAlert.java create mode 100644 src/main/java/com/financebot/alert/port/ExpenseHistoryPort.java create mode 100644 src/main/java/com/financebot/alert/service/AtypicalExpenseDetector.java create mode 100644 src/main/java/com/financebot/config/TimeConfig.java create mode 100644 src/main/java/com/financebot/transaction/repository/ExpenseByCategoryAndDateProjection.java create mode 100644 src/test/java/com/financebot/alert/service/AtypicalExpenseDetectorTest.java diff --git a/src/main/java/com/financebot/alert/domain/AtypicalExpenseAlert.java b/src/main/java/com/financebot/alert/domain/AtypicalExpenseAlert.java new file mode 100644 index 0000000..277d15d --- /dev/null +++ b/src/main/java/com/financebot/alert/domain/AtypicalExpenseAlert.java @@ -0,0 +1,14 @@ +package com.financebot.alert.domain; + +import java.math.BigDecimal; +import java.time.YearMonth; + +public record AtypicalExpenseAlert( + String categoryName, + YearMonth observedMonth, + BigDecimal observedAmount, + BigDecimal historicalAverage, + BigDecimal variationPercentage, + String explanation +) { +} diff --git a/src/main/java/com/financebot/alert/port/ExpenseHistoryPort.java b/src/main/java/com/financebot/alert/port/ExpenseHistoryPort.java new file mode 100644 index 0000000..a13ce6c --- /dev/null +++ b/src/main/java/com/financebot/alert/port/ExpenseHistoryPort.java @@ -0,0 +1,15 @@ +package com.financebot.alert.port; + +import com.financebot.transaction.repository.ExpenseByCategoryAndDateProjection; + +import java.time.LocalDate; +import java.util.List; + +public interface ExpenseHistoryPort { + + List findExpensesByCategoryAndDateBetween( + Long userId, + LocalDate startDate, + LocalDate endDate + ); +} diff --git a/src/main/java/com/financebot/alert/service/AtypicalExpenseDetector.java b/src/main/java/com/financebot/alert/service/AtypicalExpenseDetector.java new file mode 100644 index 0000000..c000194 --- /dev/null +++ b/src/main/java/com/financebot/alert/service/AtypicalExpenseDetector.java @@ -0,0 +1,113 @@ +package com.financebot.alert.service; + +import com.financebot.alert.domain.AtypicalExpenseAlert; +import com.financebot.alert.port.ExpenseHistoryPort; +import com.financebot.transaction.repository.ExpenseByCategoryAndDateProjection; +import com.financebot.user.domain.User; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Clock; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class AtypicalExpenseDetector { + + private static final int REFERENCE_MONTHS = 3; + private static final BigDecimal VARIATION_THRESHOLD = new BigDecimal("1.50"); + private static final BigDecimal MINIMUM_DIFFERENCE = new BigDecimal("100.00"); + private static final int MAX_ALERTS = 3; + + private final ExpenseHistoryPort expenseHistoryPort; + private final Clock clock; + + public AtypicalExpenseDetector(ExpenseHistoryPort expenseHistoryPort, Clock clock) { + this.expenseHistoryPort = expenseHistoryPort; + this.clock = clock; + } + + public List detect(User user) { + if (user == null || user.getId() == null) { + throw new IllegalArgumentException("User with id is required"); + } + + YearMonth observedMonth = YearMonth.now(clock).minusMonths(1); + YearMonth firstReferenceMonth = observedMonth.minusMonths(REFERENCE_MONTHS); + List expenses = expenseHistoryPort + .findExpensesByCategoryAndDateBetween( + user.getId(), + firstReferenceMonth.atDay(1), + observedMonth.atEndOfMonth() + ); + + Map> monthlyAmounts = groupByCategoryAndMonth(expenses); + List alerts = new ArrayList<>(); + + monthlyAmounts.forEach((category, amountsByMonth) -> { + BigDecimal observedAmount = amountsByMonth.getOrDefault(observedMonth, BigDecimal.ZERO); + List referenceAmounts = new ArrayList<>(); + for (int offset = REFERENCE_MONTHS; offset >= 1; offset--) { + YearMonth referenceMonth = observedMonth.minusMonths(offset); + BigDecimal amount = amountsByMonth.get(referenceMonth); + if (amount == null) { + return; + } + referenceAmounts.add(amount); + } + + BigDecimal historicalAverage = referenceAmounts.stream() + .reduce(BigDecimal.ZERO, BigDecimal::add) + .divide(BigDecimal.valueOf(REFERENCE_MONTHS), 2, RoundingMode.HALF_UP); + BigDecimal difference = observedAmount.subtract(historicalAverage); + if (historicalAverage.signum() <= 0 + || difference.compareTo(MINIMUM_DIFFERENCE) < 0 + || observedAmount.compareTo(historicalAverage.multiply(VARIATION_THRESHOLD)) < 0) { + return; + } + + BigDecimal variationPercentage = difference + .multiply(BigDecimal.valueOf(100)) + .divide(historicalAverage, 2, RoundingMode.HALF_UP); + alerts.add(new AtypicalExpenseAlert( + category, + observedMonth, + observedAmount.setScale(2, RoundingMode.HALF_UP), + historicalAverage, + variationPercentage, + "A categoria " + category + " ficou " + variationPercentage + + "% acima da média dos três meses anteriores." + )); + }); + + return alerts.stream() + .sorted(Comparator.comparing(AtypicalExpenseAlert::variationPercentage).reversed()) + .limit(MAX_ALERTS) + .toList(); + } + + private Map> groupByCategoryAndMonth( + List expenses + ) { + Map> grouped = new HashMap<>(); + if (expenses == null) { + return grouped; + } + + expenses.forEach(expense -> { + if (expense == null || expense.getCategoryName() == null || expense.getDate() == null + || expense.getAmount() == null) { + return; + } + grouped.computeIfAbsent(expense.getCategoryName(), ignored -> new HashMap<>()) + .merge(YearMonth.from(expense.getDate()), expense.getAmount(), BigDecimal::add); + }); + return grouped; + } +} diff --git a/src/main/java/com/financebot/config/TimeConfig.java b/src/main/java/com/financebot/config/TimeConfig.java new file mode 100644 index 0000000..6cbe006 --- /dev/null +++ b/src/main/java/com/financebot/config/TimeConfig.java @@ -0,0 +1,15 @@ +package com.financebot.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class TimeConfig { + + @Bean + public Clock applicationClock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/com/financebot/transaction/repository/ExpenseByCategoryAndDateProjection.java b/src/main/java/com/financebot/transaction/repository/ExpenseByCategoryAndDateProjection.java new file mode 100644 index 0000000..45e6f48 --- /dev/null +++ b/src/main/java/com/financebot/transaction/repository/ExpenseByCategoryAndDateProjection.java @@ -0,0 +1,13 @@ +package com.financebot.transaction.repository; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public interface ExpenseByCategoryAndDateProjection { + + BigDecimal getAmount(); + + LocalDate getDate(); + + String getCategoryName(); +} diff --git a/src/main/java/com/financebot/transaction/repository/TransactionRepository.java b/src/main/java/com/financebot/transaction/repository/TransactionRepository.java index 1e4cb63..8e0989f 100644 --- a/src/main/java/com/financebot/transaction/repository/TransactionRepository.java +++ b/src/main/java/com/financebot/transaction/repository/TransactionRepository.java @@ -1,5 +1,6 @@ package com.financebot.transaction.repository; +import com.financebot.alert.port.ExpenseHistoryPort; import com.financebot.transaction.domain.Transaction; import com.financebot.transaction.domain.TransactionType; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,7 +13,7 @@ import java.util.List; import java.util.Optional; -public interface TransactionRepository extends JpaRepository, JpaSpecificationExecutor { +public interface TransactionRepository extends JpaRepository, JpaSpecificationExecutor, ExpenseHistoryPort { Optional findByIdAndUserId(Long id, Long userId); @@ -202,4 +203,18 @@ Long countInstallmentsByUserBetweenDates( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate ); + + @Query(""" + select t.amount as amount, t.date as date, t.category.name as categoryName + from Transaction t + where t.user.id = :userId + and t.type = com.financebot.transaction.domain.TransactionType.EXPENSE + and t.date between :startDate and :endDate + order by t.date asc + """) + List findExpensesByCategoryAndDateBetween( + @Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/src/test/java/com/financebot/alert/service/AtypicalExpenseDetectorTest.java b/src/test/java/com/financebot/alert/service/AtypicalExpenseDetectorTest.java new file mode 100644 index 0000000..6feb869 --- /dev/null +++ b/src/test/java/com/financebot/alert/service/AtypicalExpenseDetectorTest.java @@ -0,0 +1,130 @@ +package com.financebot.alert.service; + +import com.financebot.alert.port.ExpenseHistoryPort; +import com.financebot.transaction.repository.ExpenseByCategoryAndDateProjection; +import com.financebot.user.domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AtypicalExpenseDetectorTest { + + private final FakeExpenseHistoryPort expenseHistoryPort = new FakeExpenseHistoryPort(); + private AtypicalExpenseDetector detector; + private User user; + + @BeforeEach + void setUp() { + Clock clock = Clock.fixed(Instant.parse("2026-09-14T12:00:00Z"), ZoneOffset.UTC); + detector = new AtypicalExpenseDetector(expenseHistoryPort, clock); + user = new User(); + user.setId(1L); + } + + @Test + void shouldDetectExpenseAboveHistoricalPattern() { + expenseHistoryPort.expenses = expenses("Mercado", List.of("200", "200", "200", "350")); + + var alerts = detector.detect(user); + + assertThat(alerts).singleElement().satisfies(alert -> { + assertThat(alert.categoryName()).isEqualTo("Mercado"); + assertThat(alert.observedMonth()).isEqualTo(YearMonth.of(2026, 8)); + assertThat(alert.observedAmount()).isEqualByComparingTo("350"); + assertThat(alert.historicalAverage()).isEqualByComparingTo("200"); + assertThat(alert.variationPercentage()).isEqualByComparingTo("75.00"); + assertThat(alert.explanation()).contains("75.00%"); + }); + } + + @Test + void shouldIgnoreCategoryWithoutCompleteHistory() { + expenseHistoryPort.expenses = expenses("Mercado", List.of("200", "200", "200")); + + assertThat(detector.detect(user)).isEmpty(); + } + + @Test + void shouldIgnoreIncreaseBelowThreshold() { + expenseHistoryPort.expenses = expenses("Mercado", List.of("200", "200", "200", "290")); + + assertThat(detector.detect(user)).isEmpty(); + } + + @Test + void shouldOrderAndLimitAlerts() { + List expenses = new ArrayList<>(); + expenses.addAll(expenses("Casa", List.of("100", "100", "100", "300"))); + expenses.addAll(expenses("Lazer", List.of("100", "100", "100", "250"))); + expenses.addAll(expenses("Mercado", List.of("200", "200", "200", "350"))); + expenses.addAll(expenses("Transporte", List.of("100", "100", "100", "220"))); + expenseHistoryPort.expenses = expenses; + + var alerts = detector.detect(user); + + assertThat(alerts).hasSize(3); + assertThat(alerts).extracting(alert -> alert.categoryName()) + .containsExactly("Casa", "Lazer", "Transporte"); + } + + @Test + void shouldRejectUserWithoutId() { + User userWithoutId = new User(); + + assertThatThrownBy(() -> detector.detect(userWithoutId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User with id is required"); + } + + private List expenses(String category, List amounts) { + List result = new ArrayList<>(); + for (int index = 0; index < amounts.size(); index++) { + YearMonth month = YearMonth.of(2026, 5 + index); + result.add(projection(category, month.atDay(10), amounts.get(index))); + } + return result; + } + + private ExpenseByCategoryAndDateProjection projection(String category, LocalDate date, String amount) { + return new ExpenseByCategoryAndDateProjection() { + @Override + public BigDecimal getAmount() { + return new BigDecimal(amount); + } + + @Override + public LocalDate getDate() { + return date; + } + + @Override + public String getCategoryName() { + return category; + } + }; + } + + private static final class FakeExpenseHistoryPort implements ExpenseHistoryPort { + private List expenses = List.of(); + + @Override + public List findExpensesByCategoryAndDateBetween( + Long userId, + LocalDate startDate, + LocalDate endDate + ) { + return expenses; + } + } +}