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); + } +}