diff --git a/.env.example b/.env.example
index 5574776..f194481 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1911969..0f58102 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
@@ -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
diff --git a/compose.prod.yml b/compose.prod.yml
index 542ea74..e346f2f 100644
--- a/compose.prod.yml
+++ b/compose.prod.yml
@@ -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}
diff --git a/docs/README.md b/docs/README.md
index 3fd896e..4849e0f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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 |
|---|---|
diff --git a/docs/alerts.md b/docs/alerts.md
new file mode 100644
index 0000000..6712548
--- /dev/null
+++ b/docs/alerts.md
@@ -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.
diff --git a/docs/security/data-protection.md b/docs/security/data-protection.md
index 9006c58..b2e1a15 100644
--- a/docs/security/data-protection.md
+++ b/docs/security/data-protection.md
@@ -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 |
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/AlertPreferences.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/AlertPreferences.java
new file mode 100644
index 0000000..39e6f56
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/AlertPreferences.java
@@ -0,0 +1,3 @@
+package com.financebot.telegrambot.alert;
+
+public record AlertPreferences(boolean alerts, boolean weeklySummary, boolean monthlySummary) { }
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/DeliverFinancialNotificationUseCase.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/DeliverFinancialNotificationUseCase.java
new file mode 100644
index 0000000..752ffa8
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/DeliverFinancialNotificationUseCase.java
@@ -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);
+ }
+}
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertMessagingTopology.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertMessagingTopology.java
new file mode 100644
index 0000000..72a98ac
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertMessagingTopology.java
@@ -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() {
+ }
+}
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertNotificationMessage.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertNotificationMessage.java
new file mode 100644
index 0000000..bca47f4
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertNotificationMessage.java
@@ -0,0 +1,3 @@
+package com.financebot.telegrambot.alert;
+
+public record FinancialAlertNotificationMessage(String notificationId) { }
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertRabbitConsumer.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertRabbitConsumer.java
new file mode 100644
index 0000000..5264482
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialAlertRabbitConsumer.java
@@ -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());
+ }
+}
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialNotificationGateway.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialNotificationGateway.java
new file mode 100644
index 0000000..c6d6dc0
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialNotificationGateway.java
@@ -0,0 +1,6 @@
+package com.financebot.telegrambot.alert;
+
+public interface FinancialNotificationGateway {
+ NotificationDeliveryClaim claim(String id);
+ void complete(String id, String token, String outcome);
+}
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialNotificationSender.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialNotificationSender.java
new file mode 100644
index 0000000..1928206
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/FinancialNotificationSender.java
@@ -0,0 +1,6 @@
+package com.financebot.telegrambot.alert;
+
+public interface FinancialNotificationSender {
+ DeliveryOutcome send(NotificationDeliveryClaim claim);
+ enum DeliveryOutcome { SENT, REJECTED, UNKNOWN }
+}
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/NotificationDeliveryClaim.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/NotificationDeliveryClaim.java
new file mode 100644
index 0000000..3d7f476
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/NotificationDeliveryClaim.java
@@ -0,0 +1,3 @@
+package com.financebot.telegrambot.alert;
+
+public record NotificationDeliveryClaim(String token, Long telegramId, String title, String body) { }
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/TelegramFinancialNotificationSender.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/TelegramFinancialNotificationSender.java
new file mode 100644
index 0000000..b36d859
--- /dev/null
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/alert/TelegramFinancialNotificationSender.java
@@ -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("🔔 " + escapeHtml(claim.title()) + "\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("&", "&").replace("<", "<").replace(">", ">");
+ }
+}
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/client/FinanceBotApiClient.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/client/FinanceBotApiClient.java
index 96e1c61..ceb35f3 100644
--- a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/client/FinanceBotApiClient.java
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/client/FinanceBotApiClient.java
@@ -8,9 +8,14 @@
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;
@@ -18,7 +23,11 @@ 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) -> {
@@ -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")
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/handler/TelegramBasicCommandHandler.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/handler/TelegramBasicCommandHandler.java
index ce144cd..877abf4 100644
--- a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/handler/TelegramBasicCommandHandler.java
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/handler/TelegramBasicCommandHandler.java
@@ -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) {
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfig.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfig.java
index 8a55db7..9b89ee2 100644
--- a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfig.java
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfig.java
@@ -1,5 +1,6 @@
package com.financebot.telegrambot.reminder.adapter.in.messaging;
+import com.financebot.telegrambot.alert.FinancialAlertNotificationMessage;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
@@ -33,12 +34,29 @@ Binding reminderNotificationBinding(Queue reminderNotificationQueue,
.with(ReminderMessagingTopology.REMINDER_NOTIFICATION_ROUTING_KEY);
}
+ @Bean
+ Queue financialAlertNotificationQueue() {
+ return new Queue("financebot.notifications.financial-alert", true);
+ }
+
+ @Bean
+ Binding financialAlertNotificationBinding(
+ Queue financialAlertNotificationQueue,
+ TopicExchange reminderNotificationExchange
+ ) {
+ return BindingBuilder.bind(financialAlertNotificationQueue)
+ .to(reminderNotificationExchange)
+ .with("notification.financial-alert.telegram");
+ }
+
@Bean
MessageConverter rabbitMessageConverter() {
DefaultJacksonJavaTypeMapper typeMapper = new DefaultJacksonJavaTypeMapper();
typeMapper.setIdClassMapping(Map.of(
ReminderMessagingTopology.REMINDER_NOTIFICATION_MESSAGE_TYPE,
- ReminderNotificationMessage.class
+ ReminderNotificationMessage.class,
+ "financial-alert-notification-v1",
+ FinancialAlertNotificationMessage.class
));
JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter();
converter.setJavaTypeMapper(typeMapper);
diff --git a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/router/TelegramCommandRouter.java b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/router/TelegramCommandRouter.java
index 2b57012..764437d 100644
--- a/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/router/TelegramCommandRouter.java
+++ b/financebot-telegram-bot/src/main/java/com/financebot/telegrambot/router/TelegramCommandRouter.java
@@ -36,6 +36,10 @@ public String route(
String normalizedMessage = messageText.trim();
+ if (telegramCommandMatcher.startsWithCommand(normalizedMessage, "/alertas")) {
+ return telegramBasicCommandHandler.handleAlerts(normalizedMessage, telegramId);
+ }
+
if (telegramCommandMatcher.startsWithCommand(normalizedMessage, "/start", "/iniciar")) {
return telegramBasicCommandHandler.handleStart(telegramFirstName, telegramUsername);
}
diff --git a/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/alert/DeliverFinancialNotificationUseCaseTest.java b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/alert/DeliverFinancialNotificationUseCaseTest.java
new file mode 100644
index 0000000..263e2c8
--- /dev/null
+++ b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/alert/DeliverFinancialNotificationUseCaseTest.java
@@ -0,0 +1,91 @@
+package com.financebot.telegrambot.alert;
+
+import org.junit.jupiter.api.Test;
+import com.financebot.telegrambot.alert.FinancialNotificationSender.DeliveryOutcome;
+import static org.assertj.core.api.Assertions.*;
+
+class DeliverFinancialNotificationUseCaseTest {
+ private final FakeGateway gateway = new FakeGateway();
+ private int sends;
+
+ @Test
+ void sendsAndAcknowledgesWithReservationToken() {
+ execute(DeliveryOutcome.SENT);
+ assertThat(sends).isEqualTo(1);
+ assertThat(gateway.outcome).isEqualTo("SENT");
+ assertThat(gateway.ackToken).isEqualTo("token");
+ }
+
+ @Test
+ void duplicateOrCancelledNotificationIsSkipped() {
+ gateway.claim = null;
+ execute(DeliveryOutcome.SENT);
+ assertThat(sends).isZero();
+ assertThat(gateway.outcome).isNull();
+ }
+
+ @Test
+ void explicitRejectionAllowsOutboxRetry() {
+ execute(DeliveryOutcome.REJECTED);
+ assertThat(gateway.outcome).isEqualTo("PENDING");
+ }
+
+ @Test
+ void timeoutDoesNotCauseAutomaticResend() {
+ execute(DeliveryOutcome.UNKNOWN);
+ assertThat(sends).isEqualTo(1);
+ assertThat(gateway.outcome).isEqualTo("UNKNOWN");
+ }
+
+ @Test
+ void ackFailuresRetryOnlyAckAndNeverSendAgain() {
+ gateway.ackFailures = 2;
+ execute(DeliveryOutcome.SENT);
+ assertThat(sends).isEqualTo(1);
+ assertThat(gateway.acks).isEqualTo(3);
+ assertThat(gateway.outcome).isEqualTo("SENT");
+ }
+
+ @Test
+ void permanentAckFailureLeavesLeaseForReconciliation() {
+ gateway.ackFailures = 3;
+ assertThatThrownBy(() -> execute(DeliveryOutcome.SENT)).isInstanceOf(IllegalStateException.class);
+ assertThat(sends).isEqualTo(1);
+ }
+
+ @Test
+ void senderExceptionIsUncertain() {
+ new DeliverFinancialNotificationUseCase(gateway, claim -> { throw new IllegalStateException(); }).execute("id");
+ assertThat(gateway.outcome).isEqualTo("UNKNOWN");
+ }
+
+ @Test
+ void backendUnavailableDoesNotSend() {
+ gateway.claimFailure = true;
+ assertThatThrownBy(() -> execute(DeliveryOutcome.SENT)).isInstanceOf(IllegalStateException.class);
+ assertThat(sends).isZero();
+ }
+
+ private void execute(DeliveryOutcome outcome) {
+ new DeliverFinancialNotificationUseCase(gateway, claim -> { sends++; return outcome; }).execute("id");
+ }
+
+ static class FakeGateway implements FinancialNotificationGateway {
+ NotificationDeliveryClaim claim = new NotificationDeliveryClaim("token", 123L, "Título", "Corpo");
+ String outcome;
+ String ackToken;
+ int acks;
+ int ackFailures;
+ boolean claimFailure;
+ public NotificationDeliveryClaim claim(String id) {
+ if (claimFailure) throw new IllegalStateException("API unavailable");
+ return claim;
+ }
+ public void complete(String id, String token, String outcome) {
+ acks++;
+ if (ackFailures-- > 0) throw new IllegalStateException("API unavailable");
+ this.outcome = outcome;
+ this.ackToken = token;
+ }
+ }
+}
diff --git a/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/alert/TelegramFinancialNotificationSenderTest.java b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/alert/TelegramFinancialNotificationSenderTest.java
new file mode 100644
index 0000000..1f75db1
--- /dev/null
+++ b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/alert/TelegramFinancialNotificationSenderTest.java
@@ -0,0 +1,54 @@
+package com.financebot.telegrambot.alert;
+
+import org.junit.jupiter.api.Test;
+import org.telegram.telegrambots.meta.generics.TelegramClient;
+import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
+import org.telegram.telegrambots.meta.exceptions.TelegramApiRequestException;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.assertj.core.api.Assertions.*;
+import com.financebot.telegrambot.alert.FinancialNotificationSender.DeliveryOutcome;
+
+class TelegramFinancialNotificationSenderTest {
+ @Test
+ void sendsEscapedFinancialText() throws Exception {
+ TelegramClient client = mock(TelegramClient.class);
+ new TelegramFinancialNotificationSender(client)
+ .send(new NotificationDeliveryClaim("token", 123L, "", "A & B"));
+ var captor = org.mockito.ArgumentCaptor.forClass(SendMessage.class);
+ verify(client).execute(captor.capture());
+ assertThat(captor.getValue().getText()).contains("<Título>", "A & B");
+ assertThat(captor.getValue().getChatId()).isEqualTo("123");
+ }
+
+ @Test
+ void explicitTelegramRejectionCanBeRetried() throws Exception {
+ TelegramClient client = mock(TelegramClient.class);
+ TelegramApiRequestException rejection = mock(TelegramApiRequestException.class);
+ when(rejection.getErrorCode()).thenReturn(429);
+ when(client.execute(any(SendMessage.class))).thenThrow(rejection);
+ assertThat(new TelegramFinancialNotificationSender(client)
+ .send(new NotificationDeliveryClaim("token", 123L, "Título", "Corpo")))
+ .isEqualTo(DeliveryOutcome.REJECTED);
+ }
+
+ @Test
+ void serverErrorIsUncertainRatherThanAutomaticallyRetried() throws Exception {
+ TelegramClient client = mock(TelegramClient.class);
+ TelegramApiRequestException rejection = mock(TelegramApiRequestException.class);
+ when(rejection.getErrorCode()).thenReturn(500);
+ when(client.execute(any(SendMessage.class))).thenThrow(rejection);
+ assertThat(new TelegramFinancialNotificationSender(client)
+ .send(new NotificationDeliveryClaim("token", 123L, "Título", "Corpo")))
+ .isEqualTo(DeliveryOutcome.UNKNOWN);
+ }
+
+ @Test
+ void networkFailureIsUncertain() throws Exception {
+ TelegramClient client = mock(TelegramClient.class);
+ when(client.execute(any(SendMessage.class))).thenThrow(new RuntimeException("timeout"));
+ assertThat(new TelegramFinancialNotificationSender(client)
+ .send(new NotificationDeliveryClaim("token", 123L, "Título", "Corpo")))
+ .isEqualTo(DeliveryOutcome.UNKNOWN);
+ }
+}
diff --git a/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/handler/TelegramAlertPreferencesHandlerTest.java b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/handler/TelegramAlertPreferencesHandlerTest.java
new file mode 100644
index 0000000..8c53fdf
--- /dev/null
+++ b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/handler/TelegramAlertPreferencesHandlerTest.java
@@ -0,0 +1,63 @@
+package com.financebot.telegrambot.handler;
+
+import com.financebot.telegrambot.alert.AlertPreferences;
+import com.financebot.telegrambot.client.FinanceBotApiClient;
+import com.financebot.telegrambot.service.TelegramPendingConfirmationService;
+import com.financebot.telegrambot.service.TelegramPendingQueryService;
+import com.financebot.telegrambot.formatter.TelegramAccountMessageFormatter;
+import com.financebot.telegrambot.support.TelegramBotErrorMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TelegramAlertPreferencesHandlerTest {
+ @Mock FinanceBotApiClient client;
+ @Mock TelegramPendingConfirmationService confirmations;
+ @Mock TelegramPendingQueryService queries;
+ @Mock TelegramAccountMessageFormatter formatter;
+ @Mock TelegramBotErrorMapper errors;
+ @InjectMocks TelegramBasicCommandHandler handler;
+
+ @Test
+ void disableAllUpdatesOnlyAuthenticatedChatPreferences() {
+ when(client.getAlertPreferences(123L)).thenReturn(new AlertPreferences(true, true, true));
+ when(client.updateAlertPreferences(123L, new AlertPreferences(false, false, false)))
+ .thenReturn(new AlertPreferences(false, false, false));
+ assertThat(handler.handleAlerts("/alertas desligar", 123L)).contains("Alertas: desligados", "semanal: desligado");
+ verify(client).updateAlertPreferences(123L, new AlertPreferences(false, false, false));
+ }
+
+ @Test
+ void weeklyChangePreservesOtherPreferences() {
+ when(client.getAlertPreferences(123L)).thenReturn(new AlertPreferences(true, true, false));
+ when(client.updateAlertPreferences(123L, new AlertPreferences(true, false, false)))
+ .thenReturn(new AlertPreferences(true, false, false));
+ handler.handleAlerts("/alertas semanal desligar", 123L);
+ verify(client).updateAlertPreferences(123L, new AlertPreferences(true, false, false));
+ }
+
+ @Test
+ void queryDoesNotChangePreferences() {
+ when(client.getAlertPreferences(123L)).thenReturn(new AlertPreferences(true, false, true));
+ assertThat(handler.handleAlerts("/alertas", 123L)).contains("semanal: desligado", "mensal: ligado");
+ verify(client, never()).updateAlertPreferences(anyLong(), any());
+ }
+
+ @Test
+ void invalidCommandDoesNotChangePreferences() {
+ assertThat(handler.handleAlerts("/alertas talvez", 123L)).startsWith("Use /alertas");
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void backendFailureIsExplainedWithoutPretendingPreferencesChanged() {
+ when(client.getAlertPreferences(123L)).thenThrow(new IllegalStateException("API unavailable"));
+ assertThat(handler.handleAlerts("/alertas desligar", 123L)).contains("Não foi possível");
+ verify(client, never()).updateAlertPreferences(anyLong(), any());
+ }
+}
diff --git a/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfigTest.java b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfigTest.java
index 8a30992..ca5b186 100644
--- a/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfigTest.java
+++ b/financebot-telegram-bot/src/test/java/com/financebot/telegrambot/reminder/adapter/in/messaging/ReminderRabbitConfigTest.java
@@ -11,6 +11,16 @@
class ReminderRabbitConfigTest {
+ @Test
+ void readsFinancialNotificationContractContainingOnlyOpaqueId() {
+ MessageProperties properties = new MessageProperties();
+ properties.setContentType(MessageProperties.CONTENT_TYPE_JSON);
+ properties.setHeader("__TypeId__", "financial-alert-notification-v1");
+ Message message = new Message("{\"notificationId\":\"opaque-id\"}".getBytes(StandardCharsets.UTF_8), properties);
+ Object result = new ReminderRabbitConfig().rabbitMessageConverter().fromMessage(message);
+ assertThat(result).isEqualTo(new com.financebot.telegrambot.alert.FinancialAlertNotificationMessage("opaque-id"));
+ }
+
@Test
void readsVersionedReminderNotificationContract() {
MessageProperties properties = new MessageProperties();
diff --git a/src/main/java/com/financebot/alert/adapter/FinancialAlertScheduler.java b/src/main/java/com/financebot/alert/adapter/FinancialAlertScheduler.java
new file mode 100644
index 0000000..1dbe3f0
--- /dev/null
+++ b/src/main/java/com/financebot/alert/adapter/FinancialAlertScheduler.java
@@ -0,0 +1,120 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.service.FinancialNotificationService;
+import com.financebot.alert.domain.NotificationKind;
+
+import com.financebot.alert.domain.AtypicalExpenseAlert;
+import com.financebot.alert.domain.ExcessiveInstallmentAlert;
+import com.financebot.alert.domain.FinancialSummary;
+import com.financebot.alert.domain.TightBudgetAlert;
+import com.financebot.alert.service.AtypicalExpenseDetector;
+import com.financebot.alert.service.ExcessiveInstallmentDetector;
+import com.financebot.alert.service.FinancialSummaryService;
+import com.financebot.alert.service.TightBudgetDetector;
+import com.financebot.analysis.service.FinancialAnalysisService;
+import com.financebot.user.domain.User;
+import com.financebot.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.YearMonth;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Slice;
+
+@Component
+@RequiredArgsConstructor
+public class FinancialAlertScheduler {
+ private static final Logger LOGGER = LoggerFactory.getLogger(FinancialAlertScheduler.class);
+
+ private final UserRepository userRepository;
+ private final AtypicalExpenseDetector atypicalExpenseDetector;
+ private final ExcessiveInstallmentDetector excessiveInstallmentDetector;
+ private final TightBudgetDetector tightBudgetDetector;
+ private final FinancialSummaryService financialSummaryService;
+ private final FinancialAnalysisService financialAnalysisService;
+ private final FinancialNotificationService notificationService;
+ private final Clock clock;
+ @Value("${financebot.alerts.enabled:true}")
+ private boolean alertsEnabled;
+
+ @Scheduled(cron = "${financebot.alerts.scheduler.cron:0 0 9 * * *}")
+ public void publishAlerts() {
+ if (!alertsEnabled) {
+ return;
+ }
+ int page = 0;
+ Slice users;
+ do {
+ users = userRepository.findByTelegramIdIsNotNullOrderByIdAsc(PageRequest.of(page++, 100));
+ for (User user : users) {
+ if (user.isFinancialAlertsEnabled()) {
+ safely(user, () -> atypicalExpenseDetector.detect(user).forEach(alert -> publishAtypical(user, alert)));
+ safely(user, () -> publishExcessiveInstallment(user));
+ safely(user, () -> publishTightBudget(user));
+ }
+ // Sempre considerar o último período fechado: recupera uma execução perdida.
+ if (user.isWeeklySummaryEnabled()) {
+ safely(user, () -> publishSummary(user, financialSummaryService.previousCompletedWeek(user)));
+ }
+ if (user.isMonthlySummaryEnabled()) {
+ safely(user, () -> publishSummary(user, financialSummaryService.previousCompletedMonth(user)));
+ }
+ }
+ } while (users.hasNext());
+ }
+
+ @Scheduled(fixedDelayString = "${financebot.alerts.publish-interval:60000}",
+ initialDelayString = "${financebot.alerts.publish-interval:60000}")
+ public void publishPending() {
+ notificationService.publishDue();
+ }
+
+ private void safely(User user, Runnable action) {
+ try {
+ action.run();
+ } catch (RuntimeException exception) {
+ LOGGER.warn("Financial notification generation failed for userId={}", user.getId());
+ }
+ }
+
+ private void publishAtypical(User user, AtypicalExpenseAlert alert) {
+ String key = "atypical-expense:" + alert.categoryName() + ":" + alert.observedMonth();
+ notificationService.enqueue(user, NotificationKind.ALERT, "Gasto fora do padrão", alert.explanation(),
+ key, alert.observedMonth().plusMonths(2).atDay(1).atStartOfDay());
+ }
+
+ private void publishExcessiveInstallment(User user) {
+ ExcessiveInstallmentAlert alert = excessiveInstallmentDetector.detect(user);
+ if (alert != null) {
+ publish(user, "Parcelas em excesso", alert.explanation(), "excessive-installments:" + YearMonth.now(clock));
+ }
+ }
+
+ private void publishTightBudget(User user) {
+ TightBudgetAlert alert = tightBudgetDetector.detect(financialAnalysisService.getFinancialCommitment(user));
+ if (alert != null) {
+ publish(user, "Orçamento apertado", alert.explanation(), "tight-budget:" + YearMonth.now(clock));
+ }
+ }
+
+ private void publishSummary(User user, FinancialSummary summary) {
+ if (user.getCreatedAt() != null && user.getCreatedAt().toLocalDate().isAfter(summary.endDate())) {
+ return;
+ }
+ NotificationKind kind = NotificationKind.valueOf(summary.periodType());
+ String label = kind == NotificationKind.WEEKLY ? "semanal" : "mensal";
+ notificationService.enqueue(user, kind, "Resumo financeiro " + label, summary.explanation(),
+ summary.periodType() + ":" + summary.endDate(),
+ summary.endDate().plusDays(kind == NotificationKind.WEEKLY ? 14 : 45).atStartOfDay());
+ }
+
+ private void publish(User user, String title, String body, String periodKey) {
+ notificationService.enqueue(user, NotificationKind.ALERT, title, body, periodKey,
+ YearMonth.now(clock).plusMonths(1).atDay(1).atStartOfDay());
+ }
+}
diff --git a/src/main/java/com/financebot/alert/adapter/FinancialNotificationEntity.java b/src/main/java/com/financebot/alert/adapter/FinancialNotificationEntity.java
new file mode 100644
index 0000000..8f9064a
--- /dev/null
+++ b/src/main/java/com/financebot/alert/adapter/FinancialNotificationEntity.java
@@ -0,0 +1,44 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.domain.NotificationKind;
+import com.financebot.alert.domain.NotificationStatus;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "financial_notifications")
+@Getter
+@Setter
+public class FinancialNotificationEntity {
+ @Id
+ @Column(length = 64)
+ private String id;
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+ @Column(name = "telegram_id", nullable = false)
+ private Long telegramId;
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private NotificationKind kind;
+ @Column(nullable = false, length = 200)
+ private String title;
+ @Column(nullable = false, columnDefinition = "text")
+ private String body;
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private NotificationStatus status;
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+ @Column(name = "expires_at", nullable = false)
+ private LocalDateTime expiresAt;
+ @Column(name = "next_attempt_at", nullable = false)
+ private LocalDateTime nextAttemptAt;
+ @Column(name = "claimed_at")
+ private LocalDateTime claimedAt;
+ @Column(name = "delivery_token", length = 36)
+ private String deliveryToken;
+ @Column(nullable = false)
+ private int attempts;
+}
diff --git a/src/main/java/com/financebot/alert/adapter/FinancialNotificationRepository.java b/src/main/java/com/financebot/alert/adapter/FinancialNotificationRepository.java
new file mode 100644
index 0000000..a401752
--- /dev/null
+++ b/src/main/java/com/financebot/alert/adapter/FinancialNotificationRepository.java
@@ -0,0 +1,42 @@
+package com.financebot.alert.adapter;
+
+import jakarta.persistence.LockModeType;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.*;
+import org.springframework.data.repository.query.Param;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+public interface FinancialNotificationRepository extends JpaRepository {
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select n from FinancialNotificationEntity n where n.id = :id")
+ Optional lockById(@Param("id") String id);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("""
+ select n from FinancialNotificationEntity n
+ where n.status in (com.financebot.alert.domain.NotificationStatus.PENDING,
+ com.financebot.alert.domain.NotificationStatus.PUBLISHED,
+ com.financebot.alert.domain.NotificationStatus.SENDING)
+ and n.nextAttemptAt <= :now
+ order by n.createdAt, n.id
+ """)
+ List findDue(@Param("now") LocalDateTime now, Pageable pageable);
+
+ @Modifying
+ @Query("""
+ delete from FinancialNotificationEntity n
+ where n.expiresAt < :cutoff
+ and n.status in (com.financebot.alert.domain.NotificationStatus.SENT,
+ com.financebot.alert.domain.NotificationStatus.CANCELLED)
+ """)
+ int deleteExpiredTerminal(@Param("cutoff") LocalDateTime cutoff);
+
+ @Modifying
+ @Query("""
+ update FinancialNotificationEntity n set n.title = 'Conteúdo expirado', n.body = 'Conteúdo expirado'
+ where n.status = com.financebot.alert.domain.NotificationStatus.UNKNOWN and n.expiresAt < :cutoff
+ """)
+ int redactExpiredUncertain(@Param("cutoff") LocalDateTime cutoff);
+}
diff --git a/src/main/java/com/financebot/alert/adapter/RabbitFinancialAlertNotificationPublisher.java b/src/main/java/com/financebot/alert/adapter/RabbitFinancialAlertNotificationPublisher.java
new file mode 100644
index 0000000..f74032e
--- /dev/null
+++ b/src/main/java/com/financebot/alert/adapter/RabbitFinancialAlertNotificationPublisher.java
@@ -0,0 +1,36 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.application.FinancialAlertNotificationEvent;
+import com.financebot.alert.application.FinancialAlertNotificationPublisher;
+import com.financebot.infra.messaging.QueueNames;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component
+public class RabbitFinancialAlertNotificationPublisher implements FinancialAlertNotificationPublisher {
+
+ private final RabbitTemplate rabbitTemplate;
+ private final long messageTtlMs;
+
+ public RabbitFinancialAlertNotificationPublisher(
+ RabbitTemplate rabbitTemplate,
+ @Value("${financebot.alerts.queue-message-ttl-ms:604800000}") long messageTtlMs
+ ) {
+ this.rabbitTemplate = rabbitTemplate;
+ this.messageTtlMs = messageTtlMs;
+ }
+
+ @Override
+ public void publish(FinancialAlertNotificationEvent event) {
+ rabbitTemplate.convertAndSend(
+ QueueNames.NOTIFICATION_EXCHANGE,
+ QueueNames.FINANCIAL_ALERT_NOTIFICATION_ROUTING_KEY,
+ event,
+ message -> {
+ message.getMessageProperties().setExpiration(Long.toString(messageTtlMs));
+ return message;
+ }
+ );
+ }
+}
diff --git a/src/main/java/com/financebot/alert/adapter/TelegramFinancialNotificationController.java b/src/main/java/com/financebot/alert/adapter/TelegramFinancialNotificationController.java
new file mode 100644
index 0000000..b367db6
--- /dev/null
+++ b/src/main/java/com/financebot/alert/adapter/TelegramFinancialNotificationController.java
@@ -0,0 +1,44 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.application.AlertPreferences;
+import com.financebot.alert.service.FinancialNotificationService;
+import com.financebot.alert.application.NotificationDeliveryClaim;
+import com.financebot.alert.domain.NotificationStatus;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/telegram/financial-notifications")
+@RequiredArgsConstructor
+public class TelegramFinancialNotificationController {
+ private final FinancialNotificationService service;
+
+ @PostMapping("/{id}/claim")
+ public ResponseEntity claim(@PathVariable String id) {
+ NotificationDeliveryClaim claim = service.claim(id);
+ return claim == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(claim);
+ }
+
+ @PatchMapping("/{id}/delivery")
+ public void complete(@PathVariable String id, @RequestBody @Valid DeliveryResult result) {
+ service.complete(id, result.token(), result.outcome());
+ }
+
+ @GetMapping("/preferences")
+ public AlertPreferences preferences(@RequestParam Long telegramId) {
+ return service.preferences(telegramId);
+ }
+
+ @PatchMapping("/preferences")
+ public AlertPreferences updatePreferences(@RequestParam Long telegramId, @RequestBody @Valid PreferencesRequest request) {
+ return service.updatePreferences(telegramId,
+ new AlertPreferences(request.alerts(), request.weeklySummary(), request.monthlySummary()));
+ }
+
+ public record DeliveryResult(@NotBlank String token, @NotNull NotificationStatus outcome) { }
+ public record PreferencesRequest(@NotNull Boolean alerts, @NotNull Boolean weeklySummary, @NotNull Boolean monthlySummary) { }
+}
diff --git a/src/main/java/com/financebot/alert/application/AlertPreferences.java b/src/main/java/com/financebot/alert/application/AlertPreferences.java
new file mode 100644
index 0000000..c7b42cd
--- /dev/null
+++ b/src/main/java/com/financebot/alert/application/AlertPreferences.java
@@ -0,0 +1,4 @@
+package com.financebot.alert.application;
+
+public record AlertPreferences(boolean alerts, boolean weeklySummary, boolean monthlySummary) {
+}
diff --git a/src/main/java/com/financebot/alert/application/FinancialAlertNotificationEvent.java b/src/main/java/com/financebot/alert/application/FinancialAlertNotificationEvent.java
new file mode 100644
index 0000000..0b32cbb
--- /dev/null
+++ b/src/main/java/com/financebot/alert/application/FinancialAlertNotificationEvent.java
@@ -0,0 +1,4 @@
+package com.financebot.alert.application;
+
+public record FinancialAlertNotificationEvent(String notificationId) {
+}
diff --git a/src/main/java/com/financebot/alert/application/FinancialAlertNotificationPublisher.java b/src/main/java/com/financebot/alert/application/FinancialAlertNotificationPublisher.java
new file mode 100644
index 0000000..41cd97a
--- /dev/null
+++ b/src/main/java/com/financebot/alert/application/FinancialAlertNotificationPublisher.java
@@ -0,0 +1,6 @@
+package com.financebot.alert.application;
+
+public interface FinancialAlertNotificationPublisher {
+
+ void publish(FinancialAlertNotificationEvent event);
+}
diff --git a/src/main/java/com/financebot/alert/application/NotificationDeliveryClaim.java b/src/main/java/com/financebot/alert/application/NotificationDeliveryClaim.java
new file mode 100644
index 0000000..45b06ae
--- /dev/null
+++ b/src/main/java/com/financebot/alert/application/NotificationDeliveryClaim.java
@@ -0,0 +1,4 @@
+package com.financebot.alert.application;
+
+public record NotificationDeliveryClaim(String token, Long telegramId, String title, String body) {
+}
diff --git a/src/main/java/com/financebot/alert/domain/NotificationKind.java b/src/main/java/com/financebot/alert/domain/NotificationKind.java
new file mode 100644
index 0000000..5ac7005
--- /dev/null
+++ b/src/main/java/com/financebot/alert/domain/NotificationKind.java
@@ -0,0 +1,5 @@
+package com.financebot.alert.domain;
+
+public enum NotificationKind {
+ ALERT, WEEKLY, MONTHLY
+}
diff --git a/src/main/java/com/financebot/alert/domain/NotificationStatus.java b/src/main/java/com/financebot/alert/domain/NotificationStatus.java
new file mode 100644
index 0000000..0007e5e
--- /dev/null
+++ b/src/main/java/com/financebot/alert/domain/NotificationStatus.java
@@ -0,0 +1,5 @@
+package com.financebot.alert.domain;
+
+public enum NotificationStatus {
+ PENDING, PUBLISHED, SENDING, SENT, UNKNOWN, CANCELLED
+}
diff --git a/src/main/java/com/financebot/alert/service/FinancialNotificationService.java b/src/main/java/com/financebot/alert/service/FinancialNotificationService.java
new file mode 100644
index 0000000..5048ca6
--- /dev/null
+++ b/src/main/java/com/financebot/alert/service/FinancialNotificationService.java
@@ -0,0 +1,198 @@
+package com.financebot.alert.service;
+
+import com.financebot.alert.application.AlertPreferences;
+import com.financebot.alert.application.FinancialAlertNotificationEvent;
+import com.financebot.alert.application.FinancialAlertNotificationPublisher;
+import com.financebot.alert.application.NotificationDeliveryClaim;
+
+import com.financebot.alert.adapter.FinancialNotificationEntity;
+import com.financebot.alert.adapter.FinancialNotificationRepository;
+import com.financebot.alert.domain.NotificationKind;
+import com.financebot.alert.domain.NotificationStatus;
+import com.financebot.user.domain.User;
+import com.financebot.user.repository.UserRepository;
+import jakarta.persistence.EntityNotFoundException;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.util.HexFormat;
+import java.util.Objects;
+import java.util.UUID;
+
+@Service
+public class FinancialNotificationService {
+ private final FinancialNotificationRepository notifications;
+ private final UserRepository users;
+ private final FinancialAlertNotificationPublisher publisher;
+ private final Clock clock;
+ private final boolean enabled;
+
+ public FinancialNotificationService(FinancialNotificationRepository notifications, UserRepository users,
+ FinancialAlertNotificationPublisher publisher, Clock clock,
+ @Value("${financebot.alerts.enabled:true}") boolean enabled) {
+ this.notifications = notifications;
+ this.users = users;
+ this.publisher = publisher;
+ this.clock = clock;
+ this.enabled = enabled;
+ }
+
+ @Transactional
+ public void enqueue(User snapshot, NotificationKind kind, String title, String body,
+ String periodKey, LocalDateTime expiresAt) {
+ User user = users.lockById(snapshot.getId()).orElseThrow(EntityNotFoundException::new);
+ if (!enabled || user.getTelegramId() == null || !allows(user, kind) || !expiresAt.isAfter(now())) {
+ return;
+ }
+ String id = notificationId(user.getId(), periodKey);
+ if (notifications.existsById(id)) {
+ return;
+ }
+ FinancialNotificationEntity notification = new FinancialNotificationEntity();
+ notification.setId(id);
+ notification.setUserId(user.getId());
+ notification.setTelegramId(user.getTelegramId());
+ notification.setKind(kind);
+ notification.setTitle(title);
+ notification.setBody(body);
+ notification.setStatus(NotificationStatus.PENDING);
+ notification.setCreatedAt(now());
+ notification.setExpiresAt(expiresAt);
+ notification.setNextAttemptAt(now());
+ notifications.save(notification);
+ }
+
+ @Transactional
+ public int publishDue() {
+ notifications.deleteExpiredTerminal(now().minusDays(7));
+ notifications.redactExpiredUncertain(now().minusDays(7));
+ int published = 0;
+ for (FinancialNotificationEntity notification : notifications.findDue(now(), PageRequest.of(0, 100))) {
+ if (notification.getStatus() == NotificationStatus.SENDING) {
+ // A reserva expirada pode ter enviado ao Telegram: não repetir automaticamente.
+ notification.setStatus(NotificationStatus.UNKNOWN);
+ continue;
+ }
+ if (!isEligible(notification)) {
+ notification.setStatus(NotificationStatus.CANCELLED);
+ continue;
+ }
+ try {
+ publisher.publish(new FinancialAlertNotificationEvent(notification.getId()));
+ notification.setStatus(NotificationStatus.PUBLISHED);
+ notification.setNextAttemptAt(now().plusMinutes(5));
+ published++;
+ } catch (RuntimeException exception) {
+ notification.setStatus(NotificationStatus.PENDING);
+ notification.setNextAttemptAt(now().plusMinutes(1));
+ }
+ }
+ return published;
+ }
+
+ @Transactional
+ public NotificationDeliveryClaim claim(String id) {
+ FinancialNotificationEntity notification = notifications.lockById(id).orElse(null);
+ if (notification == null || notification.getStatus() == NotificationStatus.SENT
+ || notification.getStatus() == NotificationStatus.UNKNOWN
+ || notification.getStatus() == NotificationStatus.CANCELLED) {
+ return null;
+ }
+ if (notification.getStatus() == NotificationStatus.SENDING) {
+ if (!notification.getNextAttemptAt().isAfter(now())) {
+ notification.setStatus(NotificationStatus.UNKNOWN);
+ }
+ return null;
+ }
+ if (notification.getStatus() != NotificationStatus.PUBLISHED || !isEligible(notification)) {
+ if (!isEligible(notification)) {
+ notification.setStatus(NotificationStatus.CANCELLED);
+ }
+ return null;
+ }
+ notification.setStatus(NotificationStatus.SENDING);
+ notification.setDeliveryToken(UUID.randomUUID().toString());
+ notification.setClaimedAt(now());
+ notification.setNextAttemptAt(now().plusMinutes(2));
+ notification.setAttempts(notification.getAttempts() + 1);
+ return new NotificationDeliveryClaim(notification.getDeliveryToken(), notification.getTelegramId(),
+ notification.getTitle(), notification.getBody());
+ }
+
+ @Transactional
+ public void complete(String id, String token, NotificationStatus outcome) {
+ if (outcome != NotificationStatus.SENT && outcome != NotificationStatus.PENDING
+ && outcome != NotificationStatus.UNKNOWN) {
+ throw new IllegalArgumentException("Invalid delivery outcome");
+ }
+ FinancialNotificationEntity notification = notifications.lockById(id).orElseThrow(EntityNotFoundException::new);
+ if (!Objects.equals(token, notification.getDeliveryToken()) || token == null) {
+ throw new IllegalArgumentException("Invalid delivery token");
+ }
+ if (notification.getStatus() == NotificationStatus.SENT) {
+ return; // ACK repetido é idempotente.
+ }
+ if (notification.getStatus() != NotificationStatus.SENDING && notification.getStatus() != NotificationStatus.UNKNOWN) {
+ return;
+ }
+ notification.setStatus(outcome == NotificationStatus.PENDING && notification.getAttempts() >= 5
+ ? NotificationStatus.CANCELLED : outcome);
+ notification.setNextAttemptAt(now().plusMinutes(5));
+ }
+
+ @Transactional(readOnly = true)
+ public AlertPreferences preferences(Long telegramId) {
+ User user = findUser(telegramId);
+ return new AlertPreferences(user.isFinancialAlertsEnabled(), user.isWeeklySummaryEnabled(), user.isMonthlySummaryEnabled());
+ }
+
+ @Transactional
+ public AlertPreferences updatePreferences(Long telegramId, AlertPreferences preferences) {
+ User user = users.lockById(findUser(telegramId).getId()).orElseThrow(EntityNotFoundException::new);
+ user.setFinancialAlertsEnabled(preferences.alerts());
+ user.setWeeklySummaryEnabled(preferences.weeklySummary());
+ user.setMonthlySummaryEnabled(preferences.monthlySummary());
+ return preferences;
+ }
+
+ private User findUser(Long telegramId) {
+ return users.findByTelegramId(telegramId).orElseThrow(() -> new EntityNotFoundException("Telegram user not found"));
+ }
+
+ private boolean isEligible(FinancialNotificationEntity notification) {
+ if (!enabled || !notification.getExpiresAt().isAfter(now())) {
+ return false;
+ }
+ return users.findById(notification.getUserId())
+ .filter(user -> Objects.equals(user.getTelegramId(), notification.getTelegramId()))
+ .filter(user -> allows(user, notification.getKind())).isPresent();
+ }
+
+ private boolean allows(User user, NotificationKind kind) {
+ return switch (kind) {
+ case ALERT -> user.isFinancialAlertsEnabled();
+ case WEEKLY -> user.isWeeklySummaryEnabled();
+ case MONTHLY -> user.isMonthlySummaryEnabled();
+ };
+ }
+
+ private LocalDateTime now() {
+ return LocalDateTime.now(clock);
+ }
+
+ public static String notificationId(Long userId, String periodKey) {
+ try {
+ byte[] value = (userId + ":" + periodKey).getBytes(StandardCharsets.UTF_8);
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException(exception);
+ }
+ }
+}
diff --git a/src/main/java/com/financebot/infra/messaging/QueueNames.java b/src/main/java/com/financebot/infra/messaging/QueueNames.java
index e99fe5a..015841b 100644
--- a/src/main/java/com/financebot/infra/messaging/QueueNames.java
+++ b/src/main/java/com/financebot/infra/messaging/QueueNames.java
@@ -5,6 +5,9 @@ public final class QueueNames {
public static final String REMINDER_NOTIFICATION_QUEUE = "financebot.notifications.telegram";
public static final String REMINDER_NOTIFICATION_ROUTING_KEY = "notification.reminder.telegram";
public static final String REMINDER_NOTIFICATION_MESSAGE_TYPE = "reminder-notification-v1";
+ public static final String FINANCIAL_ALERT_NOTIFICATION_QUEUE = "financebot.notifications.financial-alert";
+ public static final String FINANCIAL_ALERT_NOTIFICATION_ROUTING_KEY = "notification.financial-alert.telegram";
+ public static final String FINANCIAL_ALERT_NOTIFICATION_MESSAGE_TYPE = "financial-alert-notification-v1";
private QueueNames() {
}
diff --git a/src/main/java/com/financebot/infra/messaging/RabbitMQConfig.java b/src/main/java/com/financebot/infra/messaging/RabbitMQConfig.java
index f50403e..29dabdc 100644
--- a/src/main/java/com/financebot/infra/messaging/RabbitMQConfig.java
+++ b/src/main/java/com/financebot/infra/messaging/RabbitMQConfig.java
@@ -1,6 +1,7 @@
package com.financebot.infra.messaging;
import com.financebot.reminder.adapter.out.messaging.ReminderNotificationMessage;
+import com.financebot.alert.application.FinancialAlertNotificationEvent;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
@@ -26,6 +27,21 @@ Queue reminderNotificationQueue() {
return new Queue(QueueNames.REMINDER_NOTIFICATION_QUEUE, true);
}
+ @Bean
+ Queue financialAlertNotificationQueue() {
+ return new Queue(QueueNames.FINANCIAL_ALERT_NOTIFICATION_QUEUE, true);
+ }
+
+ @Bean
+ Binding financialAlertNotificationBinding(
+ Queue financialAlertNotificationQueue,
+ TopicExchange notificationExchange
+ ) {
+ return BindingBuilder.bind(financialAlertNotificationQueue)
+ .to(notificationExchange)
+ .with(QueueNames.FINANCIAL_ALERT_NOTIFICATION_ROUTING_KEY);
+ }
+
@Bean
Binding reminderNotificationBinding(Queue reminderNotificationQueue, TopicExchange notificationExchange) {
return BindingBuilder.bind(reminderNotificationQueue)
@@ -38,7 +54,9 @@ MessageConverter rabbitMessageConverter() {
DefaultJacksonJavaTypeMapper typeMapper = new DefaultJacksonJavaTypeMapper();
typeMapper.setIdClassMapping(Map.of(
QueueNames.REMINDER_NOTIFICATION_MESSAGE_TYPE,
- ReminderNotificationMessage.class
+ ReminderNotificationMessage.class,
+ QueueNames.FINANCIAL_ALERT_NOTIFICATION_MESSAGE_TYPE,
+ FinancialAlertNotificationEvent.class
));
JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter();
converter.setJavaTypeMapper(typeMapper);
diff --git a/src/main/java/com/financebot/security/config/SecurityConfig.java b/src/main/java/com/financebot/security/config/SecurityConfig.java
index b62d361..1967f5c 100644
--- a/src/main/java/com/financebot/security/config/SecurityConfig.java
+++ b/src/main/java/com/financebot/security/config/SecurityConfig.java
@@ -80,6 +80,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) {
.requestMatchers(HttpMethod.POST, "/telegram/installments/count").permitAll()
.requestMatchers(HttpMethod.POST, "/telegram/installments/purchase-capacity").permitAll()
.requestMatchers(HttpMethod.POST, "/telegram/reminders", "/telegram/reminders/pending/claim").permitAll()
+ .requestMatchers(HttpMethod.POST, "/telegram/financial-notifications/*/claim").permitAll()
.requestMatchers(HttpMethod.GET, TELEGRAM_ENDPOINTS_PATTERN).permitAll()
.requestMatchers(HttpMethod.PATCH, TELEGRAM_ENDPOINTS_PATTERN).permitAll()
.requestMatchers(HttpMethod.DELETE, TELEGRAM_ENDPOINTS_PATTERN).permitAll()
diff --git a/src/main/java/com/financebot/user/domain/User.java b/src/main/java/com/financebot/user/domain/User.java
index 7b89c24..f3f95a4 100644
--- a/src/main/java/com/financebot/user/domain/User.java
+++ b/src/main/java/com/financebot/user/domain/User.java
@@ -60,6 +60,15 @@ public class User implements UserDetails {
@Column(name = "telegram_id", unique = true)
private Long telegramId;
+ @Column(name = "financial_alerts_enabled", nullable = false)
+ private boolean financialAlertsEnabled = true;
+
+ @Column(name = "weekly_summary_enabled", nullable = false)
+ private boolean weeklySummaryEnabled = true;
+
+ @Column(name = "monthly_summary_enabled", nullable = false)
+ private boolean monthlySummaryEnabled = true;
+
@Column(name = "telegram_link_code", length = 30)
private String telegramLinkCode;
diff --git a/src/main/java/com/financebot/user/repository/UserRepository.java b/src/main/java/com/financebot/user/repository/UserRepository.java
index f22f311..2b26f36 100644
--- a/src/main/java/com/financebot/user/repository/UserRepository.java
+++ b/src/main/java/com/financebot/user/repository/UserRepository.java
@@ -4,6 +4,12 @@
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import jakarta.persistence.LockModeType;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface UserRepository extends JpaRepository {
@@ -16,4 +22,10 @@ public interface UserRepository extends JpaRepository {
boolean existsByTelegramId(Long telegramId);
Optional findByTelegramId(Long telegramId);
-}
\ No newline at end of file
+
+ Slice findByTelegramIdIsNotNullOrderByIdAsc(Pageable pageable);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select u from User u where u.id = :id")
+ Optional lockById(@Param("id") Long id);
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 82b9807..3c7509b 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -64,3 +64,7 @@ telegram.internal-token=${TELEGRAM_INTERNAL_TOKEN:}
financebot.recurring.scheduler.cron=${FINANCEBOT_RECURRING_SCHEDULER_CRON:0 0 0 * * *}
financebot.reminders.publish-interval=${FINANCEBOT_REMINDERS_PUBLISH_INTERVAL:60000}
financebot.reminders.queue-message-ttl-ms=${FINANCEBOT_REMINDERS_QUEUE_MESSAGE_TTL_MS:86400000}
+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.enabled=${FINANCEBOT_ALERTS_ENABLED:true}
+financebot.alerts.publish-interval=${FINANCEBOT_ALERTS_PUBLISH_INTERVAL:60000}
diff --git a/src/main/resources/db/migration/V15__create_financial_notification_outbox.sql b/src/main/resources/db/migration/V15__create_financial_notification_outbox.sql
new file mode 100644
index 0000000..93859c8
--- /dev/null
+++ b/src/main/resources/db/migration/V15__create_financial_notification_outbox.sql
@@ -0,0 +1,21 @@
+ALTER TABLE users ADD COLUMN financial_alerts_enabled BOOLEAN NOT NULL DEFAULT TRUE;
+ALTER TABLE users ADD COLUMN weekly_summary_enabled BOOLEAN NOT NULL DEFAULT TRUE;
+ALTER TABLE users ADD COLUMN monthly_summary_enabled BOOLEAN NOT NULL DEFAULT TRUE;
+
+CREATE TABLE financial_notifications (
+ id VARCHAR(64) PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ telegram_id BIGINT NOT NULL,
+ kind VARCHAR(20) NOT NULL,
+ title VARCHAR(200) NOT NULL,
+ body TEXT NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ next_attempt_at TIMESTAMP NOT NULL,
+ claimed_at TIMESTAMP,
+ delivery_token VARCHAR(36),
+ attempts INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX idx_financial_notifications_pending ON financial_notifications(status, next_attempt_at);
+CREATE INDEX idx_financial_notifications_user ON financial_notifications(user_id);
diff --git a/src/test/java/com/financebot/alert/adapter/FinancialAlertSchedulerTest.java b/src/test/java/com/financebot/alert/adapter/FinancialAlertSchedulerTest.java
new file mode 100644
index 0000000..a157ca3
--- /dev/null
+++ b/src/test/java/com/financebot/alert/adapter/FinancialAlertSchedulerTest.java
@@ -0,0 +1,105 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.service.FinancialNotificationService;
+import com.financebot.alert.domain.*;
+import com.financebot.alert.service.*;
+import com.financebot.analysis.service.FinancialAnalysisService;
+import com.financebot.user.domain.User;
+import com.financebot.user.repository.UserRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.math.BigDecimal;
+import java.time.*;
+import java.util.List;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+class FinancialAlertSchedulerTest {
+ @Mock UserRepository users;
+ @Mock AtypicalExpenseDetector atypical;
+ @Mock ExcessiveInstallmentDetector installments;
+ @Mock TightBudgetDetector budget;
+ @Mock FinancialSummaryService summaries;
+ @Mock FinancialAnalysisService analysis;
+ @Mock FinancialNotificationService notifications;
+ private FinancialAlertScheduler scheduler;
+ private User user;
+
+ @BeforeEach
+ void setup() {
+ scheduler = new FinancialAlertScheduler(users, atypical, installments, budget, summaries,
+ analysis, notifications, Clock.fixed(Instant.parse("2026-09-15T09:00:00Z"), ZoneOffset.UTC));
+ ReflectionTestUtils.setField(scheduler, "alertsEnabled", true);
+ user = new User(); user.setId(1L); user.setTelegramId(123L);
+ }
+
+ @Test
+ void recoversLatestWeeklyAndMonthlySummariesOnTuesday() {
+ when(users.findByTelegramIdIsNotNullOrderByIdAsc(any())).thenReturn(new SliceImpl<>(List.of(user)));
+ when(summaries.previousCompletedWeek(user)).thenReturn(summary("WEEKLY", LocalDate.of(2026, 9, 13)));
+ when(summaries.previousCompletedMonth(user)).thenReturn(summary("MONTHLY", LocalDate.of(2026, 8, 31)));
+ scheduler.publishAlerts();
+ verify(notifications).enqueue(eq(user), eq(NotificationKind.WEEKLY), anyString(), anyString(),
+ eq("WEEKLY:2026-09-13"), eq(LocalDate.of(2026, 9, 27).atStartOfDay()));
+ verify(notifications).enqueue(eq(user), eq(NotificationKind.MONTHLY), anyString(), anyString(),
+ eq("MONTHLY:2026-08-31"), eq(LocalDate.of(2026, 10, 15).atStartOfDay()));
+ }
+
+ @Test
+ void failingRiskAnalysisDoesNotBlockSummaries() {
+ when(users.findByTelegramIdIsNotNullOrderByIdAsc(any())).thenReturn(new SliceImpl<>(List.of(user)));
+ when(analysis.getFinancialCommitment(user)).thenThrow(new IllegalStateException());
+ when(summaries.previousCompletedWeek(user)).thenReturn(summary("WEEKLY", LocalDate.of(2026, 9, 13)));
+ when(summaries.previousCompletedMonth(user)).thenReturn(summary("MONTHLY", LocalDate.of(2026, 8, 31)));
+ scheduler.publishAlerts();
+ verify(notifications, times(2)).enqueue(eq(user), any(), anyString(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void skipsAllGenerationWhenGloballyDisabled() {
+ ReflectionTestUtils.setField(scheduler, "alertsEnabled", false);
+ scheduler.publishAlerts();
+ verifyNoInteractions(users, atypical, summaries, notifications);
+ }
+
+ @Test
+ void optedOutUserDoesNotRunRules() {
+ user.setFinancialAlertsEnabled(false); user.setWeeklySummaryEnabled(false); user.setMonthlySummaryEnabled(false);
+ when(users.findByTelegramIdIsNotNullOrderByIdAsc(any())).thenReturn(new SliceImpl<>(List.of(user)));
+ scheduler.publishAlerts();
+ verifyNoInteractions(atypical, installments, budget, analysis, summaries, notifications);
+ }
+
+ @Test
+ void processesEligibleUsersInPages() {
+ when(users.findByTelegramIdIsNotNullOrderByIdAsc(PageRequest.of(0, 100)))
+ .thenReturn(new SliceImpl<>(List.of(), PageRequest.of(0, 100), true));
+ when(users.findByTelegramIdIsNotNullOrderByIdAsc(PageRequest.of(1, 100)))
+ .thenReturn(new SliceImpl<>(List.of()));
+ scheduler.publishAlerts();
+ verify(users).findByTelegramIdIsNotNullOrderByIdAsc(PageRequest.of(1, 100));
+ }
+
+ @Test
+ void doesNotSendSummaryForPeriodBeforeRegistration() {
+ user.setCreatedAt(LocalDateTime.of(2026, 9, 15, 8, 0));
+ user.setFinancialAlertsEnabled(false);
+ when(users.findByTelegramIdIsNotNullOrderByIdAsc(any())).thenReturn(new SliceImpl<>(List.of(user)));
+ when(summaries.previousCompletedWeek(user)).thenReturn(summary("WEEKLY", LocalDate.of(2026, 9, 13)));
+ when(summaries.previousCompletedMonth(user)).thenReturn(summary("MONTHLY", LocalDate.of(2026, 8, 31)));
+ scheduler.publishAlerts();
+ verifyNoInteractions(notifications);
+ }
+
+ private FinancialSummary summary(String kind, LocalDate end) {
+ return new FinancialSummary(kind, end.minusDays(6), end, BigDecimal.ZERO, BigDecimal.ZERO,
+ BigDecimal.ZERO, "Resumo");
+ }
+}
diff --git a/src/test/java/com/financebot/alert/adapter/FinancialNotificationServiceTest.java b/src/test/java/com/financebot/alert/adapter/FinancialNotificationServiceTest.java
new file mode 100644
index 0000000..3db48de
--- /dev/null
+++ b/src/test/java/com/financebot/alert/adapter/FinancialNotificationServiceTest.java
@@ -0,0 +1,239 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.application.*;
+import com.financebot.alert.service.FinancialNotificationService;
+import com.financebot.alert.domain.*;
+import com.financebot.security.config.DataEncryptionConfig;
+import com.financebot.user.domain.User;
+import com.financebot.user.repository.UserRepository;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import java.time.*;
+import java.util.ArrayList;
+import java.util.List;
+import static org.assertj.core.api.Assertions.*;
+
+@DataJpaTest
+@Import({DataEncryptionConfig.class, FinancialNotificationServiceTest.Config.class})
+class FinancialNotificationServiceTest {
+ private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 15, 9, 0);
+ @Autowired FinancialNotificationService service;
+ @Autowired FinancialNotificationRepository notifications;
+ @Autowired UserRepository users;
+ @Autowired FakePublisher publisher;
+ @Autowired jakarta.persistence.EntityManager entityManager;
+
+ @Test
+ void enqueueIsIdempotentAndStoresWorkBeforePublication() {
+ User user = user();
+ enqueue(user, NotificationKind.ALERT, "alert:2026-09");
+ enqueue(user, NotificationKind.ALERT, "alert:2026-09");
+ assertThat(notifications.count()).isEqualTo(1);
+ assertThat(notifications.findAll().getFirst().getStatus()).isEqualTo(NotificationStatus.PENDING);
+ }
+
+ @Test
+ void brokerFailurePreservesPendingWorkForRetry() {
+ enqueue(user(), NotificationKind.ALERT, "alert:2026-09");
+ publisher.fail = true;
+ try {
+ assertThat(service.publishDue()).isZero();
+ assertThat(notifications.findAll().getFirst().getStatus()).isEqualTo(NotificationStatus.PENDING);
+ } finally {
+ publisher.fail = false;
+ }
+ makeDue();
+ assertThat(service.publishDue()).isEqualTo(1);
+ }
+
+ @Test
+ void duplicateMessageCannotAcquireASecondDelivery() {
+ var notification = published();
+ var claim = service.claim(notification.getId());
+ assertThat(claim).isNotNull();
+ assertThat(service.claim(notification.getId())).isNull();
+ service.complete(notification.getId(), claim.token(), NotificationStatus.SENT);
+ service.complete(notification.getId(), claim.token(), NotificationStatus.SENT);
+ assertThat(service.claim(notification.getId())).isNull();
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT);
+ }
+
+ @Test
+ void expiredSendingLeaseIsUncertainRatherThanRetried() {
+ var notification = published();
+ var claim = service.claim(notification.getId());
+ makeDue();
+ service.publishDue();
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.UNKNOWN);
+ assertThat(service.claim(notification.getId())).isNull();
+ // Uma confirmação atrasada com o mesmo token pode resolver o estado incerto.
+ service.complete(notification.getId(), claim.token(), NotificationStatus.SENT);
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT);
+ }
+
+ @Test
+ void rejectedDeliveryBecomesPendingAndCanBeRepublished() {
+ var notification = published();
+ var claim = service.claim(notification.getId());
+ service.complete(notification.getId(), claim.token(), NotificationStatus.PENDING);
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.PENDING);
+ makeDue();
+ assertThat(service.publishDue()).isEqualTo(1);
+ assertThat(service.claim(notification.getId()).token()).isNotEqualTo(claim.token());
+ }
+
+ @Test
+ void fiveExplicitRejectionsStopAutomaticRetry() {
+ var notification = published();
+ for (int attempt = 0; attempt < 5; attempt++) {
+ var claim = service.claim(notification.getId());
+ service.complete(notification.getId(), claim.token(), NotificationStatus.PENDING);
+ makeDue();
+ service.publishDue();
+ }
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.CANCELLED);
+ }
+
+ @Test
+ void disconnectOrOptOutCancelsQueuedDelivery() {
+ var notification = published();
+ User user = users.findById(notification.getUserId()).orElseThrow();
+ service.updatePreferences(user.getTelegramId(), new AlertPreferences(false, true, true));
+ assertThat(service.claim(notification.getId())).isNull();
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.CANCELLED);
+ }
+
+ @Test
+ void changedTelegramLinkCannotReceiveOldNotification() {
+ var notification = published();
+ users.findById(notification.getUserId()).orElseThrow().setTelegramId(456L);
+ assertThat(service.claim(notification.getId())).isNull();
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.CANCELLED);
+ }
+
+ @Test
+ void weeklyAndMonthlyPreferencesAreIndependent() {
+ User user = user();
+ service.updatePreferences(123L, new AlertPreferences(false, true, false));
+ enqueue(user, NotificationKind.ALERT, "alert:2026-09");
+ enqueue(user, NotificationKind.WEEKLY, "weekly:2026-09-13");
+ enqueue(user, NotificationKind.MONTHLY, "monthly:2026-08");
+ assertThat(notifications.findAll()).singleElement()
+ .extracting(FinancialNotificationEntity::getKind).isEqualTo(NotificationKind.WEEKLY);
+ assertThat(service.preferences(123L)).isEqualTo(new AlertPreferences(false, true, false));
+ }
+
+ @Test
+ void expiredNotificationIsCancelledAndNotSent() {
+ var notification = published();
+ notification.setExpiresAt(NOW.minusSeconds(1));
+ assertThat(service.claim(notification.getId())).isNull();
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.CANCELLED);
+ }
+
+ @Test
+ void publishedNotificationIsRepublishedWhenNoConsumerClaimsIt() {
+ var notification = published();
+ makeDue();
+ assertThat(service.publishDue()).isEqualTo(1);
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.PUBLISHED);
+ }
+
+ @Test
+ void unrelatedTokenCannotAcknowledgeDelivery() {
+ var notification = published();
+ service.claim(notification.getId());
+ assertThatThrownBy(() -> service.complete(notification.getId(), "wrong", NotificationStatus.SENT))
+ .isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid delivery token");
+ }
+
+ @Test
+ void globalDisablePreventsQueuedDelivery() {
+ var notification = published();
+ var disabled = new FinancialNotificationService(notifications, users, publisher,
+ Clock.fixed(Instant.parse("2026-09-15T09:00:00Z"), ZoneOffset.UTC), false);
+ assertThat(disabled.claim(notification.getId())).isNull();
+ assertThat(notification.getStatus()).isEqualTo(NotificationStatus.CANCELLED);
+ }
+
+ @Test
+ void expiredCompletedContentIsPurged() {
+ var notification = published();
+ var claim = service.claim(notification.getId());
+ service.complete(notification.getId(), claim.token(), NotificationStatus.SENT);
+ notification.setExpiresAt(NOW.minusDays(8));
+ notifications.flush();
+ service.publishDue();
+ entityManager.clear();
+ assertThat(notifications.count()).isZero();
+ }
+
+ @Test
+ void expiredUncertainContentIsRedactedWhileMetadataRemains() {
+ var notification = published();
+ var claim = service.claim(notification.getId());
+ service.complete(notification.getId(), claim.token(), NotificationStatus.UNKNOWN);
+ notification.setExpiresAt(NOW.minusDays(8));
+ notifications.flush();
+ String id = notification.getId();
+ service.publishDue();
+ entityManager.clear();
+ var stored = notifications.findById(id).orElseThrow();
+ assertThat(stored.getStatus()).isEqualTo(NotificationStatus.UNKNOWN);
+ assertThat(stored.getBody()).isEqualTo("Conteúdo expirado");
+ }
+
+ @Test
+ void expiredPeriodCannotBeRecreated() {
+ User user = user();
+ service.enqueue(user, NotificationKind.ALERT, "Título", "Corpo", "expired", NOW.minusSeconds(1));
+ assertThat(notifications.count()).isZero();
+ }
+
+ private FinancialNotificationEntity published() {
+ enqueue(user(), NotificationKind.ALERT, "alert:2026-09");
+ service.publishDue();
+ return notifications.findAll().getFirst();
+ }
+
+ private void makeDue() {
+ notifications.findAll().forEach(n -> n.setNextAttemptAt(NOW.minusSeconds(1)));
+ notifications.flush();
+ }
+
+ private void enqueue(User user, NotificationKind kind, String key) {
+ service.enqueue(user, kind, "Título", "Mensagem financeira", key, NOW.plusDays(30));
+ }
+
+ private User user() {
+ User user = new User();
+ user.setName("Test");
+ user.setEmail("test@example.com");
+ user.setPassword("test-password");
+ user.setTelegramId(123L);
+ return users.saveAndFlush(user);
+ }
+
+ static class FakePublisher implements FinancialAlertNotificationPublisher {
+ boolean fail;
+ List events = new ArrayList<>();
+ public void publish(FinancialAlertNotificationEvent event) {
+ if (fail) throw new IllegalStateException("broker unavailable");
+ events.add(event);
+ }
+ }
+
+ @TestConfiguration
+ static class Config {
+ @Bean FakePublisher publisher() { return new FakePublisher(); }
+ @Bean Clock clock() { return Clock.fixed(Instant.parse("2026-09-15T09:00:00Z"), ZoneOffset.UTC); }
+ @Bean FinancialNotificationService service(FinancialNotificationRepository notifications,
+ UserRepository users, FakePublisher publisher, Clock clock) {
+ return new FinancialNotificationService(notifications, users, publisher, clock, true);
+ }
+ }
+}
diff --git a/src/test/java/com/financebot/alert/adapter/RabbitFinancialAlertNotificationPublisherTest.java b/src/test/java/com/financebot/alert/adapter/RabbitFinancialAlertNotificationPublisherTest.java
new file mode 100644
index 0000000..8d28219
--- /dev/null
+++ b/src/test/java/com/financebot/alert/adapter/RabbitFinancialAlertNotificationPublisherTest.java
@@ -0,0 +1,31 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.application.FinancialAlertNotificationEvent;
+import com.financebot.infra.messaging.QueueNames;
+import org.junit.jupiter.api.Test;
+import org.springframework.amqp.core.Message;
+import org.springframework.amqp.core.MessageProperties;
+import org.springframework.amqp.core.MessagePostProcessor;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.assertj.core.api.Assertions.*;
+
+class RabbitFinancialAlertNotificationPublisherTest {
+ @Test
+ void sendsOpaqueIdWithBoundedMessageTtl() {
+ RabbitTemplate rabbit = mock(RabbitTemplate.class);
+ doAnswer(invocation -> {
+ MessagePostProcessor processor = invocation.getArgument(3);
+ Message message = processor.postProcessMessage(new Message(new byte[0], new MessageProperties()));
+ assertThat(message.getMessageProperties().getExpiration()).isEqualTo("604800000");
+ return null;
+ }).when(rabbit).convertAndSend(eq(QueueNames.NOTIFICATION_EXCHANGE),
+ eq(QueueNames.FINANCIAL_ALERT_NOTIFICATION_ROUTING_KEY), any(Object.class), any(MessagePostProcessor.class));
+ new RabbitFinancialAlertNotificationPublisher(rabbit, 604800000)
+ .publish(new FinancialAlertNotificationEvent("opaque-id"));
+ verify(rabbit).convertAndSend(eq(QueueNames.NOTIFICATION_EXCHANGE),
+ eq(QueueNames.FINANCIAL_ALERT_NOTIFICATION_ROUTING_KEY),
+ eq(new FinancialAlertNotificationEvent("opaque-id")), any(MessagePostProcessor.class));
+ }
+}
diff --git a/src/test/java/com/financebot/alert/adapter/TelegramFinancialNotificationControllerTest.java b/src/test/java/com/financebot/alert/adapter/TelegramFinancialNotificationControllerTest.java
new file mode 100644
index 0000000..2e880f8
--- /dev/null
+++ b/src/test/java/com/financebot/alert/adapter/TelegramFinancialNotificationControllerTest.java
@@ -0,0 +1,57 @@
+package com.financebot.alert.adapter;
+
+import com.financebot.alert.service.FinancialNotificationService;
+import com.financebot.alert.application.NotificationDeliveryClaim;
+import com.financebot.alert.domain.NotificationStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+class TelegramFinancialNotificationControllerTest {
+ @Mock FinancialNotificationService service;
+ MockMvc mvc;
+
+ @BeforeEach void setup() {
+ mvc = MockMvcBuilders.standaloneSetup(new TelegramFinancialNotificationController(service)).build();
+ }
+
+ @Test void duplicateOrCancelledClaimReturnsNoContent() throws Exception {
+ mvc.perform(post("/telegram/financial-notifications/id/claim")).andExpect(status().isNoContent());
+ }
+
+ @Test void eligibleClaimReturnsAuthoritativeChatAndToken() throws Exception {
+ when(service.claim("id")).thenReturn(new NotificationDeliveryClaim("token", 123L, "Título", "Corpo"));
+ mvc.perform(post("/telegram/financial-notifications/id/claim"))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.token").value("token"))
+ .andExpect(jsonPath("$.telegramId").value(123));
+ }
+
+ @Test void missingPreferenceFieldsAreRejected() throws Exception {
+ mvc.perform(patch("/telegram/financial-notifications/preferences?telegramId=123")
+ .contentType("application/json").content("{\"alerts\":false}"))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(service);
+ }
+
+ @Test void missingTokenCannotAcknowledgeDelivery() throws Exception {
+ mvc.perform(patch("/telegram/financial-notifications/id/delivery").contentType("application/json")
+ .content("{\"outcome\":\"SENT\"}"))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(service);
+ }
+
+ @Test void deliveryResultKeepsReservationToken() throws Exception {
+ mvc.perform(patch("/telegram/financial-notifications/id/delivery").contentType("application/json")
+ .content("{\"token\":\"token\",\"outcome\":\"SENT\"}"))
+ .andExpect(status().isOk());
+ verify(service).complete("id", "token", NotificationStatus.SENT);
+ }
+}