Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 107 additions & 0 deletions docs/specs/issue-140-out-of-pattern-expenses.md
Original file line number Diff line number Diff line change
@@ -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<AtypicalExpenseAlert> 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.
Original file line number Diff line number Diff line change
@@ -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
) {
}
15 changes: 15 additions & 0 deletions src/main/java/com/financebot/alert/port/ExpenseHistoryPort.java
Original file line number Diff line number Diff line change
@@ -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<ExpenseByCategoryAndDateProjection> findExpensesByCategoryAndDateBetween(
Long userId,
LocalDate startDate,
LocalDate endDate
);
}
Original file line number Diff line number Diff line change
@@ -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<AtypicalExpenseAlert> 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<ExpenseByCategoryAndDateProjection> expenses = expenseHistoryPort
.findExpensesByCategoryAndDateBetween(
user.getId(),
firstReferenceMonth.atDay(1),
observedMonth.atEndOfMonth()
);

Map<String, Map<YearMonth, BigDecimal>> monthlyAmounts = groupByCategoryAndMonth(expenses);
List<AtypicalExpenseAlert> alerts = new ArrayList<>();

monthlyAmounts.forEach((category, amountsByMonth) -> {
BigDecimal observedAmount = amountsByMonth.getOrDefault(observedMonth, BigDecimal.ZERO);
List<BigDecimal> 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<String, Map<YearMonth, BigDecimal>> groupByCategoryAndMonth(
List<ExpenseByCategoryAndDateProjection> expenses
) {
Map<String, Map<YearMonth, BigDecimal>> 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;
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/financebot/config/TimeConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,7 +13,7 @@
import java.util.List;
import java.util.Optional;

public interface TransactionRepository extends JpaRepository<Transaction, Long>, JpaSpecificationExecutor<Transaction> {
public interface TransactionRepository extends JpaRepository<Transaction, Long>, JpaSpecificationExecutor<Transaction>, ExpenseHistoryPort {

Optional<Transaction> findByIdAndUserId(Long id, Long userId);

Expand Down Expand Up @@ -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<ExpenseByCategoryAndDateProjection> findExpensesByCategoryAndDateBetween(
@Param("userId") Long userId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
}
Loading
Loading