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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ FINANCEBOT_LOG_FORMAT=ecs
FINANCEBOT_RECURRING_SCHEDULER_CRON=0 0 0 * * *
FINANCEBOT_REMINDERS_PUBLISH_INTERVAL=60000
FINANCEBOT_REMINDERS_QUEUE_MESSAGE_TTL_MS=86400000
FINANCEBOT_ALERTS_ENABLED=true
FINANCEBOT_ALERTS_SCHEDULER_CRON=0 0 9 * * *
FINANCEBOT_ALERTS_QUEUE_MESSAGE_TTL_MS=604800000
FINANCEBOT_ALERTS_PUBLISH_INTERVAL=60000

# Actuator local da API
FINANCEBOT_MANAGEMENT_PORT=8082
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- Adicionados alertas financeiros explicáveis, resumos de períodos fechados e preferências individuais pelo comando `/alertas`.
- Adicionada outbox PostgreSQL para notificações Telegram, com reservas por token, confirmação de entrega e recuperação de publicação interrompida.
- Adicionado keyring versionado para leitura com chaves anteriores e escrita exclusiva com a chave ativa.
- Adicionado job opt-in de recriptografia em lotes, condicionado à confirmação explícita de backup.
- Adicionado procedimento operacional de rotação, rollback e revogação de chaves.
Expand All @@ -21,6 +23,10 @@
- A rotação falha de forma segura diante de chave ausente, ciphertext inválido ou alteração concorrente.
- Chaves antigas só podem ser revogadas depois da recriptografia e da verificação explícita dos dados.

### Tests

- Adicionados testes de idempotência, falhas de publicação, reservas, retry de ACK, entrega incerta, preferências e vínculo Telegram das notificações financeiras.

## [v1.8.0]

### Added
Expand Down
4 changes: 4 additions & 0 deletions compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ services:
FINANCEBOT_DATA_ENCRYPTION_ROTATION_INTERVAL_MS: ${FINANCEBOT_DATA_ENCRYPTION_ROTATION_INTERVAL_MS:-1000}
FINANCEBOT_SQL_LOGGING_ENABLED: ${FINANCEBOT_SQL_LOGGING_ENABLED:-false}
FINANCEBOT_REMINDERS_QUEUE_MESSAGE_TTL_MS: ${FINANCEBOT_REMINDERS_QUEUE_MESSAGE_TTL_MS:-86400000}
FINANCEBOT_ALERTS_ENABLED: ${FINANCEBOT_ALERTS_ENABLED:-true}
FINANCEBOT_ALERTS_SCHEDULER_CRON: ${FINANCEBOT_ALERTS_SCHEDULER_CRON:-0 0 9 * * *}
FINANCEBOT_ALERTS_QUEUE_MESSAGE_TTL_MS: ${FINANCEBOT_ALERTS_QUEUE_MESSAGE_TTL_MS:-604800000}
FINANCEBOT_ALERTS_PUBLISH_INTERVAL: ${FINANCEBOT_ALERTS_PUBLISH_INTERVAL:-60000}

CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
TELEGRAM_INTERNAL_TOKEN: ${TELEGRAM_INTERNAL_TOKEN}
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Servidor remoto](operations/remote-server.md)
- [Backup e restauração](operations/backup-restore.md)
- [Observabilidade](observability.md)
- [Alertas e resumos financeiros](alerts.md)

| Necessidade | Documento |
|---|---|
Expand Down
99 changes: 99 additions & 0 deletions docs/alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Alertas financeiros e resumos

A API calcula as regras e grava cada notificação em uma outbox PostgreSQL antes
de publicá-la no RabbitMQ. A fila dedicada contém somente o ID opaco da
notificação; o conteúdo financeiro fica no banco e é obtido pelo bot por uma
rota interna autenticada. O Redis não participa da deduplicação desses alertas.

## Regras e frequência

- Gasto fora do padrão: último mês fechado comparado à média dos três meses
anteriores, com aumento mínimo de 50% e R$ 100.
- Excesso de parcelas: cinco ou mais grupos de parcelas ativos.
- Orçamento apertado: comprometimento projetado de pelo menos 60% ou saldo
projetado negativo.
- Resumo semanal: semana anterior completa, de segunda a domingo.
- Resumo mensal: mês fechado anterior.

O scheduler calcula diariamente às 9h no fuso do processo. Sempre considera a
última semana e o último mês fechados, inclusive quando uma execução de
segunda-feira ou do dia 1 foi perdida. Não gera resumos de períodos terminados
antes do cadastro do usuário. A recuperação é limitada ao último período
fechado; não produz retrospectivas de todos os períodos de uma indisponibilidade
longa. Execuções repetidas geram o mesmo ID por usuário, regra e período e não
criam outro item. Cada alerta de risco é limitado a um por mês; cada categoria
atípica tem seu próprio item mensal.

## Preferências

No Telegram:

- `/alertas`: consultar as preferências individuais.
- `/alertas ligar` ou `/alertas desligar`: ativar ou desativar todos os tipos.
- `/alertas semanal ligar|desligar`: controlar somente o resumo semanal.
- `/alertas mensal ligar|desligar`: controlar somente o resumo mensal.

As preferências existentes começam ativadas. A geração respeita cada opção;
a reserva de entrega verifica novamente preferências, vínculo Telegram,
expiração e configuração global. Uma alteração não cancela uma chamada ao
Telegram que já começou. Desativação ou desvinculação antes da reserva cancela
os itens enfileirados. Reativar não recria itens já cancelados no mesmo período.
`FINANCEBOT_ALERTS_ENABLED=false` desativa globalmente a geração e novas reservas.

## Entrega e falhas

Estados persistidos: `PENDING`, `PUBLISHED`, `SENDING`, `SENT`, `UNKNOWN` e
`CANCELLED`. A publicação roda a cada 60 segundos em lotes de 100 e conserva o
item pendente diante de falha do broker. Itens publicados sem reserva são
republicados após cinco minutos. Duplicatas RabbitMQ precisam adquirir a mesma
reserva transacional; somente uma execução pode começar a entrega.

A reserva gera um token e dura dois minutos. O bot confirma o resultado com o
mesmo token; repetir essa confirmação é idempotente. Rejeições explícitas do
Telegram voltam a `PENDING` após cinco minutos, até cinco tentativas. Erros de
rede/timeout ficam `UNKNOWN`. Falhas no ACK repetem somente o ACK, até três
vezes, sem reenviar a mensagem.

**Limite de entrega:** Telegram não oferece uma chave de idempotência para
`sendMessage`. Se o envio ocorre e a confirmação se perde, não há como garantir
exactly-once. Uma reserva expirada ou resposta ambígua fica `UNKNOWN` para
reconciliação operacional e não é reenviada automaticamente. Isso evita
transformar uma falha de confirmação em mensagem duplicada. Não descrevemos
`UNKNOWN` como entrega concluída nem garantimos ausência absoluta de perda.

Para acompanhar pendências no banco, consultar somente metadados:

```sql
SELECT id, kind, status, attempts, created_at, claimed_at, expires_at
FROM financial_notifications
WHERE status IN ('UNKNOWN', 'PENDING', 'PUBLISHED', 'SENDING')
ORDER BY created_at;
```

Estados incertos devem ser conferidos com o destinatário antes de qualquer
reenvio manual. Não existe rotina automática que reenvie itens `UNKNOWN`.

## Contratos e configuração

Todas as rotas abaixo exigem `X-Internal-Service-Token`; não são contratos do
frontend:

- `POST /telegram/financial-notifications/{id}/claim`: retorna token, chat,
título e corpo; `204` quando não há entrega elegível.
- `PATCH /telegram/financial-notifications/{id}/delivery`: `{token, outcome}`;
resultados permitidos `SENT`, `PENDING` (rejeição explícita) e `UNKNOWN`.
- `GET /telegram/financial-notifications/preferences?telegramId=...`.
- `PATCH /telegram/financial-notifications/preferences?telegramId=...`:
`{alerts, weeklySummary, monthlySummary}`, todos booleanos obrigatórios.

Configuração: `FINANCEBOT_ALERTS_ENABLED`, `FINANCEBOT_ALERTS_SCHEDULER_CRON`,
`FINANCEBOT_ALERTS_PUBLISH_INTERVAL` e `FINANCEBOT_ALERTS_QUEUE_MESSAGE_TTL_MS`.
A migration V15 adiciona preferências e a outbox; nenhuma migration anterior
foi alterada. Não há nova credencial obrigatória de ambiente.

Conteúdo financeiro da outbox é confidencial, mantido em claro nesta etapa,
como os valores usados nas agregações. Acesso ao banco e aos backups deve ser
restrito. Alertas expiram na virada de período; resumos semanais em 14 dias e
mensais em 45 dias após o fechamento. Itens `SENT`/`CANCELLED` são removidos sete
dias após expirar; conteúdo `UNKNOWN` é redigido nesse mesmo prazo, preservando
metadados para reconciliação. IDs expirados não são recriados pela geração.
1 change: 1 addition & 0 deletions docs/security/data-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Este documento registra a classificação inicial dos dados do FinanceBot e a es
| código de vínculo Telegram | PostgreSQL | Restrito | Avaliar hash com expiração e uso único |
| JWT, OpenAI, Telegram e RabbitMQ credentials | ambiente/secrets | Restrito | Não persistir no banco; usar secret manager ou variáveis protegidas |
| contexto de conversa | Redis | Restrito | TTL mínimo, acesso autenticado e revisão específica de retenção |
| notificações financeiras | PostgreSQL, Telegram | Confidencial | Outbox com expiração, retenção limitada, preferências por usuário e payload RabbitMQ contendo apenas ID opaco |
| mensagens de lembrete | RabbitMQ | Restrito | TLS, autenticação, filas privadas e payload mínimo |
| logs e backups | infraestrutura | Restrito | Redação de dados, acesso mínimo, retenção e criptografia operacional |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.financebot.telegrambot.alert;

public record AlertPreferences(boolean alerts, boolean weeklySummary, boolean monthlySummary) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.financebot.telegrambot.alert;

import org.springframework.stereotype.Component;
import com.financebot.telegrambot.alert.FinancialNotificationSender.DeliveryOutcome;

@Component
public class DeliverFinancialNotificationUseCase {
private final FinancialNotificationGateway gateway;
private final FinancialNotificationSender sender;

public DeliverFinancialNotificationUseCase(FinancialNotificationGateway gateway, FinancialNotificationSender sender) {
this.gateway = gateway;
this.sender = sender;
}

public void execute(String id) {
NotificationDeliveryClaim claim = gateway.claim(id);
if (claim == null) {
return;
}
DeliveryOutcome outcome;
try {
outcome = sender.send(claim);
} catch (RuntimeException exception) {
outcome = DeliveryOutcome.UNKNOWN;
}
String state = outcome == DeliveryOutcome.REJECTED ? "PENDING"
: outcome == DeliveryOutcome.SENT ? "SENT" : "UNKNOWN";
RuntimeException failure = null;
for (int attempt = 0; attempt < 3; attempt++) {
try {
gateway.complete(id, claim.token(), state);
return;
} catch (RuntimeException exception) {
failure = exception;
}
}
// Nunca reenviar ao Telegram só porque o ACK da API falhou.
throw new IllegalStateException("Could not acknowledge financial notification", failure);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.financebot.telegrambot.alert;

final class FinancialAlertMessagingTopology {
static final String NOTIFICATION_EXCHANGE = "financebot.notifications";
static final String FINANCIAL_ALERT_NOTIFICATION_QUEUE = "financebot.notifications.financial-alert";
static final String FINANCIAL_ALERT_NOTIFICATION_ROUTING_KEY = "notification.financial-alert.telegram";
static final String FINANCIAL_ALERT_NOTIFICATION_MESSAGE_TYPE = "financial-alert-notification-v1";

private FinancialAlertMessagingTopology() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.financebot.telegrambot.alert;

public record FinancialAlertNotificationMessage(String notificationId) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.financebot.telegrambot.alert;

import lombok.RequiredArgsConstructor;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class FinancialAlertRabbitConsumer {
private final DeliverFinancialNotificationUseCase useCase;

@RabbitListener(queues = FinancialAlertMessagingTopology.FINANCIAL_ALERT_NOTIFICATION_QUEUE)
public void consume(FinancialAlertNotificationMessage message) {
if (message == null || message.notificationId() == null
|| !message.notificationId().matches("[0-9a-f]{64}")) {
return;
}
// A API mantém o item persistido e o republica se a reserva não começou.
useCase.execute(message.notificationId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.financebot.telegrambot.alert;

public interface FinancialNotificationGateway {
NotificationDeliveryClaim claim(String id);
void complete(String id, String token, String outcome);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.financebot.telegrambot.alert;

public interface FinancialNotificationSender {
DeliveryOutcome send(NotificationDeliveryClaim claim);
enum DeliveryOutcome { SENT, REJECTED, UNKNOWN }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.financebot.telegrambot.alert;

public record NotificationDeliveryClaim(String token, Long telegramId, String title, String body) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.financebot.telegrambot.alert;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.exceptions.TelegramApiRequestException;
import org.telegram.telegrambots.meta.generics.TelegramClient;

@Component
@RequiredArgsConstructor
public class TelegramFinancialNotificationSender implements FinancialNotificationSender {
private final TelegramClient telegramClient;

@Override
public DeliveryOutcome send(NotificationDeliveryClaim claim) {
try {
telegramClient.execute(SendMessage.builder().chatId(claim.telegramId())
.text("🔔 <b>" + escapeHtml(claim.title()) + "</b>\n" + escapeHtml(claim.body()))
.parseMode("HTML").build());
return DeliveryOutcome.SENT;
} catch (TelegramApiRequestException exception) {
// Rejeições 4xx são explícitas; falhas 5xx podem ter resultado ambíguo.
Integer code = exception.getErrorCode();
return code != null && code >= 400 && code < 500 ? DeliveryOutcome.REJECTED : DeliveryOutcome.UNKNOWN;
} catch (Exception exception) {
return DeliveryOutcome.UNKNOWN;
}
}

private String escapeHtml(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,26 @@
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.financebot.telegrambot.alert.AlertPreferences;
import com.financebot.telegrambot.alert.NotificationDeliveryClaim;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import java.time.Duration;

@Component
public class FinanceBotApiClient implements com.financebot.telegrambot.reminder.application.port.out.ReminderGateway {
public class FinanceBotApiClient implements com.financebot.telegrambot.reminder.application.port.out.ReminderGateway,
com.financebot.telegrambot.alert.FinancialNotificationGateway {

private final RestClient restClient;

public FinanceBotApiClient(
@Value("${financebot.api.base-url}") String baseUrl,
@Value("${financebot.api.internal-token:}") String internalToken
) {
JdkClientHttpRequestFactory requests = new JdkClientHttpRequestFactory(
java.net.http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build());
requests.setReadTimeout(Duration.ofSeconds(15));
this.restClient = RestClient.builder()
.requestFactory(requests)
.baseUrl(baseUrl)
.defaultHeader("X-Internal-Service-Token", internalToken)
.requestInterceptor((request, body, execution) -> {
Expand All @@ -28,6 +37,30 @@ public FinanceBotApiClient(
.build();
}

@Override
public NotificationDeliveryClaim claim(String id) {
return restClient.post().uri("/telegram/financial-notifications/{id}/claim", id)
.retrieve().body(NotificationDeliveryClaim.class);
}

@Override
public void complete(String id, String token, String outcome) {
restClient.patch().uri("/telegram/financial-notifications/{id}/delivery", id)
.body(new DeliveryResult(token, outcome)).retrieve().toBodilessEntity();
}

public AlertPreferences getAlertPreferences(Long telegramId) {
return restClient.get().uri("/telegram/financial-notifications/preferences?telegramId={telegramId}", telegramId)
.retrieve().body(AlertPreferences.class);
}

public AlertPreferences updateAlertPreferences(Long telegramId, AlertPreferences preferences) {
return restClient.patch().uri("/telegram/financial-notifications/preferences?telegramId={telegramId}", telegramId)
.body(preferences).retrieve().body(AlertPreferences.class);
}

private record DeliveryResult(String token, String outcome) { }

public void createTransaction(CreateTransactionFromTelegramRequest request) {
restClient.post()
.uri("/telegram/transactions")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,42 @@ public String handleStart(String telegramFirstName, String telegramUsername) {
}

public String handleHelp() {
return telegramAccountMessageFormatter.formatHelpMessage();
return telegramAccountMessageFormatter.formatHelpMessage()
+ "\n/alertas — consultar preferências\n/alertas ligar ou desligar — controlar notificações\n"
+ "/alertas semanal ligar|desligar e /alertas mensal ligar|desligar — controlar resumos";
}

public String handleAlerts(String text, Long telegramId) {
String[] parts = text.toLowerCase(java.util.Locale.ROOT).split("\\s+");
boolean valid = parts.length == 1
|| (parts.length == 2 && (parts[1].equals("ligar") || parts[1].equals("desligar")))
|| (parts.length == 3 && (parts[1].equals("semanal") || parts[1].equals("mensal"))
&& (parts[2].equals("ligar") || parts[2].equals("desligar")));
if (!valid) {
return "Use /alertas, /alertas ligar|desligar ou /alertas semanal|mensal ligar|desligar.";
}
try {
var preferences = financeBotApiClient.getAlertPreferences(telegramId);
if (parts.length == 2) {
boolean enabled = parts[1].equals("ligar");
preferences = new com.financebot.telegrambot.alert.AlertPreferences(enabled, enabled, enabled);
} else if (parts.length == 3) {
boolean enabled = parts[2].equals("ligar");
preferences = new com.financebot.telegrambot.alert.AlertPreferences(preferences.alerts(),
parts[1].equals("semanal") ? enabled : preferences.weeklySummary(),
parts[1].equals("mensal") ? enabled : preferences.monthlySummary());
}
if (parts.length > 1) {
preferences = financeBotApiClient.updateAlertPreferences(telegramId, preferences);
}
return "Alertas: " + (preferences.alerts() ? "ligados" : "desligados")
+ "; resumo semanal: " + (preferences.weeklySummary() ? "ligado" : "desligado")
+ "; resumo mensal: " + (preferences.monthlySummary() ? "ligado" : "desligado") + ".";
} catch (RestClientResponseException exception) {
return telegramBotErrorMapper.mapDefaultBotErrors(exception);
} catch (Exception exception) {
return "Não foi possível consultar ou atualizar os alertas agora. Tente novamente.";
}
}

public String handleGreeting(String telegramFirstName, String telegramUsername) {
Expand Down
Loading
Loading