Skip to content

019 — Reminder proativo (fatura vencendo via WhatsApp)

Até aqui o agente é reativo — só fala quando o casal pergunta (cenários 008/009/012/013/wa-001/wa-002/wa-003). Esse cenário inverte: o agente toma a iniciativa de mandar mensagem no whats lembrando da fatura ou conta recorrente prestes a vencer. Sem nudge humano, sem comando, sem mention. Cron interno dispara, scheduler chama o dispatcher, dispatcher decide quem avisar do quê e onde, gateway envia.

Engagement K-factor (CEO doc decisão #9) depende de habit diário no canal. Casal abrir o whats e ver “Aluguel vence amanhã (R$ 3.000)” do bot é o tipo de toque que ancora o produto como assistente real — não só ferramenta consultada. UX é broadcast: a mensagem cai no DM e no group (caso ambos estejam linkados ao mesmo Household via wa-001/wa-003), pra que os dois members vejam ao mesmo tempo, sem coordenação manual de “você avisou ele?”.

A decomposição segue a régua já estabelecida — separar decisão de orquestração:

  • ReminderService.evaluate({budget, today, lookAheadDays?}) (Domain Service em planning/domain/services/) é puro: lê Budget, devolve ReminderPlan[] POJO já com message em PT-BR pronto. Default lookAheadDays = 3. Mesma régua de FeasibilityCheck.evaluate({today}) (006) e BudgetAlerts.evaluate({today}) (015) — today injetado, sem clock implícito, sem mutação.
  • ReminderDispatcher (Application Service em agent/application/, capability-named — mesma classe de coisa que WhatsAppMessageRouter do wa-002/wa-003) orquestra I/O: itera householdRepository.list(), carrega Budget de cada, roda ReminderService.evaluate, pra cada ReminderPlan × cada chatId linkado (DM + group) chama gateway.sendMessage. Idempotência por triple (householdId, expenseId, dueDate) num set in-memory dentro do dispatcher — basta pro MVP, vira port ReminderJournal quando aparecer demanda de durabilidade.
  • Scheduler (port em agent/application/) é cron-like opaco. schedule({cron, callback}): SchedulerHandle registra um job que dispara callback({today}) quando o cron bater. Adapter prod (NodeScheduler wrapping node-cron ou similar) fica deferido — spec usa MockScheduler com tick({today}) síncrono pra dirigir tempo manualmente.
  • HouseholdLookup (port já introduzido em wa-001) ganha método novo chatIdsFor(householdId): Promise<string[]> — retorna todos os chats linkados àquele Household. Reverse do findByChatId. Fake InMemoryHouseholdLookup implementa lendo o Map<chatId, householdId> ao contrário.

Reminder bypassa LLM. Texto vem do ReminderService.message direto. Gotcha “message humano em PT no domínio enquanto monolíngue” (introduzida em 015) aplica — quando i18n aparecer, vira {key, params}. Sem AgentChat, sem tool-call, sem token gasto. Reminder é evento derivado puro: olhou o estado do Budget + today, decidiu o que disparar.

Idempotência por triple (householdId, expenseId, dueDate). Dispatcher mantém um Set<string> interno (key concatenada) e curto-circuita antes de chamar gateway.sendMessage quando o triple já apareceu. Disparo recorrente mensal funciona porque a dueDate muda de mês pra mês — junho disparou (h-1, e-1, 2026-06-05), julho dispara (h-1, e-1, 2026-07-05) que é triple diferente.

Scenario: Fatura vence em 3 dias — dispara reminder em DM + group

Section titled “Scenario: Fatura vence em 3 dias — dispara reminder em DM + group”
  • Given um Household “Ana e Bruno” persistido (householdRepository.save(...))
  • And householdLookup.linkChat(...) registrou dois chats pra esse Household: chatId="dm-5511999999999" (DM da Ana, via wa-001) e chatId="group-abc-xyz" (group dos dois + bot, via wa-003)
  • And um Budget da casa persistido com RecurringExpense “Aluguel” expected = R$ 3.000 vencimento = BillingDay.of(5)
  • And today = 2026-06-02 (3 dias antes do dia 5 → dentro do default lookAheadDays = 3)
  • And ReminderDispatcher inicializado com start() registrando um job no MockScheduler
  • When mockScheduler.tick({today: new Date("2026-06-02")}) aciona o callback
  • Then ReminderService.evaluate({budget, today: 2026-06-02}) retorna 1 ReminderPlan pro Aluguel com daysUntilDue = 3, dueDate = 2026-06-05, amount = R$ 3.000
  • And gateway.sentMessages tem exatamente 2 entradas (uma DM, uma group) — broadcast pra todos os chatIdsFor(household.id)
  • And cada sentMessages[i].text contém "Aluguel" e bate em /vence|3 dias/i
  • And os dois chatId em sentMessages são distintos — "dm-5511999999999" e "group-abc-xyz", cada um aparece exatamente uma vez

Scenario: Mesmo tick rodado de novo — idempotência (não duplica)

Section titled “Scenario: Mesmo tick rodado de novo — idempotência (não duplica)”
  • Given o setup do cenário anterior, com o tick({today: 2026-06-02}) já tendo rodado uma vez e produzido 2 sentMessages
  • When mockScheduler.tick({today: new Date("2026-06-02")}) roda uma segunda vez com o mesmo today
  • Then gateway.sentMessages.length permanece 2 — idempotência por triple (household.id, expense.id, dueDate) curto-circuita antes de chamar gateway.sendMessage
  • And o dispatcher não chama gateway.sendMessage no segundo tick (asserção de side-effect, não só do count final)

Scenario: Sem expenses no look-ahead window — nenhum disparo

Section titled “Scenario: Sem expenses no look-ahead window — nenhum disparo”
  • Given um Household linkado a 1 chat + Budget com Aluguel vencimento = BillingDay.of(5)
  • And today = 2026-06-01 (4 dias antes do dia 5 → fora do default lookAheadDays = 3)
  • When mockScheduler.tick({today: new Date("2026-06-01")}) aciona o callback
  • Then ReminderService.evaluate(...) retorna [] (zero ReminderPlans)
  • And gateway.sentMessages.length === 0 (nenhum disparo)

Scenario: Vence amanhã (1 dia) — disparo com urgência

Section titled “Scenario: Vence amanhã (1 dia) — disparo com urgência”
  • Given Household linkado a DM + group, Budget com Aluguel vencimento = BillingDay.of(5)
  • And today = 2026-06-04
  • When mockScheduler.tick({today: new Date("2026-06-04")}) aciona o callback
  • Then gateway.sentMessages.length === 2 (broadcast DM + group)
  • And cada sentMessages[i].text bate em /amanhã|1 dia/i (urgência humana visível no texto)
  • And o daysUntilDue no ReminderPlan correspondente é 1

Scenario: Vence hoje — disparo “vence hoje”

Section titled “Scenario: Vence hoje — disparo “vence hoje””
  • Given Household linkado a DM + group, Budget com Aluguel vencimento = BillingDay.of(5)
  • And today = 2026-06-05 (mesmo dia)
  • When mockScheduler.tick({today: new Date("2026-06-05")}) aciona o callback
  • Then gateway.sentMessages.length === 2
  • And cada sentMessages[i].text bate em /hoje/i
  • And o daysUntilDue no ReminderPlan correspondente é 0

Scenario: Múltiplos Households — broadcast independente

Section titled “Scenario: Múltiplos Households — broadcast independente”
  • Given dois Households persistidos: “Casa A” e “Casa B”
  • And “Casa A” tem Budget com Aluguel vencimento = BillingDay.of(5) e está linkada a "dm-A" + "group-A"
  • And “Casa B” tem Budget com Internet vencimento = BillingDay.of(7) e está linkada a "dm-B" + "group-B"
  • And today = 2026-06-05 (Casa A vence hoje, Casa B vence em 2 dias — ambos dentro do default lookAheadDays = 3)
  • When mockScheduler.tick({today: new Date("2026-06-05")}) aciona o callback
  • Then gateway.sentMessages.length === 4 (2 households × 2 chats cada)
  • And as mensagens enviadas pra "dm-A" e "group-A" mencionam “Aluguel” (e não “Internet”)
  • And as mensagens enviadas pra "dm-B" e "group-B" mencionam “Internet” (e não “Aluguel”)
  • And nenhum chat de Casa A recebeu reminder de Casa B nem vice-versa (state per-couple não vaza no broadcast)
  • Context existenteplanning/ (capability-named, sem aggregate root próprio, igual 006/015). Não introduz aggregate nem entity nova. Só Domain Service + VO/POJO de resultado.
  • Domain ServiceReminderService.evaluate({budget, today, lookAheadDays?}) stateless, todos os métodos static. Lê Budget (obrigatório). Sem mutação, sem I/O.
  • POJO/VO ReminderPlan{ expenseId: string, expenseName: string, amount: Money, dueDate: Date, daysUntilDue: number, message: string }. Imutável, serializável.
  • Application orchestratorReminderDispatcher em agent/application/. Não é Domain Service (orquestra I/O, depende de ports). Mesma classe de coisa que WhatsAppMessageRouter (wa-002/003).
  • Port Scheduleragent/application/. Cron-like, opaco. Adapter prod fica deferido.
  • Port HouseholdLookup ganha chatIdsFor(householdId) — método novo. Fake atualiza junto.
  • Sem persistência de reminder — alerta é derivado, mesma filosofia de 015 (Alert) e 006 (FeasibilityResult). Idempotência via set in-memory; ReminderJournal port fica como TODO quando aparecer durabilidade necessária (ex: bot reinicia entre ticks e perde o set).
src/contexts/planning/domain/value-objects/ReminderPlan.ts
export interface ReminderPlan {
expenseId: string;
expenseName: string;
amount: Money;
dueDate: Date; // calculada do BillingDay + month(today) + year(today)
daysUntilDue: number; // 0 = hoje, 1 = amanhã, ...
message: string; // PT-BR pronto pro casal ler
}
// src/contexts/planning/domain/services/ReminderService.ts
ReminderService.evaluate({
budget: Budget,
today: Date,
lookAheadDays?: number, // default 3
}): ReminderPlan[]
// pra cada expense em budget.list():
// dueDate = próximo "dia X" >= today, no mês corrente ou no próximo
// daysUntilDue = (dueDate - today) em dias UTC
// se daysUntilDue ∈ [0, lookAheadDays] → push ReminderPlan
// senão → skip
// src/contexts/agent/application/Scheduler.ts
export interface SchedulerHandle {
readonly id: string;
}
export interface Scheduler {
schedule(input: {
cron: string;
callback: (ctx: { today: Date }) => Promise<void> | void;
}): SchedulerHandle;
cancel(handle: SchedulerHandle): void;
}
// src/contexts/agent/application/HouseholdLookup.ts (extensão)
export interface HouseholdLookup {
// ... métodos existentes (findByChatId/linkChat/findBySenderId/bindSender)
chatIdsFor(householdId: string): Promise<string[]>; // novo (019)
}
// src/contexts/agent/application/ReminderDispatcher.ts
ReminderDispatcher.create({
scheduler: Scheduler;
gateway: WhatsAppGateway;
householdLookup: HouseholdLookup;
householdRepository: HouseholdRepository;
budgetRepository: BudgetRepository;
cron?: string; // default "0 9 * * *" (9am daily)
lookAheadDays?: number; // default 3, passado adiante pra ReminderService
}): ReminderDispatcher
dispatcher.start(): void // registra job via scheduler.schedule
dispatcher.tick({today}): Promise<void> // método público pra spec disparar direto;
// scheduler.callback === dispatcher.tick.bind(dispatcher)

Lógica do tick:

  1. households = await householdRepository.list().
  2. Pra cada household:
    • budget = await budgetRepository.load(household.id) (assume budget.id === household.id por convenção, ou injeção de mapping fica num cenário futuro; aqui spec garante a correspondência).
    • plans = ReminderService.evaluate({budget, today, lookAheadDays}).
    • Pra cada plan:
      • triple = ${household.id}|${plan.expenseId}|${plan.dueDate.toISOString()} — checa set in-memory.
      • Se já visto: skip.
      • Senão: marca no set, chatIds = await householdLookup.chatIdsFor(household.id), pra cada chatId chama await gateway.sendMessage({chatId, text: plan.message}).

Formato do message (PT-BR, regra):

  • daysUntilDue === 0"Lembrete: {expenseName} vence hoje ({amount formatado}) — não esquece."
  • daysUntilDue === 1"Lembrete: {expenseName} vence amanhã ({amount formatado})."
  • daysUntilDue >= 2"Lembrete: {expenseName} vence em {daysUntilDue} dias ({amount formatado}) — dia {dueDay} do mês."

Formato de amount no texto fica a critério do ReminderService (ex: R$ 3.000,00 em pt-BR). Specs validam via regex tolerante (/3\.?000/) pra não amarrar formatting.

  • ReminderService em planning/domain/services/, não em budget/ — apesar de ler Budget, o serviço é capability cross-context: pode futuramente ler Goal.deadline (próximo deadline de meta), Account.invoiceDueDate (próxima fatura do cartão), etc. Planning é a casa natural pra “olhar o futuro e decidir o que avisar”, mesma régua de FeasibilityCheck (006) e BudgetAlerts (015). Domain Service em <context>/domain/services/ é o slot canônico (gotcha “Port em application/, Domain Service em domain/services/”).
  • ReminderDispatcher em agent/application/, não em planning/ — orquestra I/O (scheduler, repos, gateway). Gotcha do AGENTS.md: “se a coisa é pura, mora em domain/services/; se é infra-facing, em application/”. Dispatcher não é Domain Service. Mora em agent/ (não em planning/) porque depende de WhatsAppGateway + HouseholdLookup que vivem em agent/application/. Planning não conhece canal; agent é quem fala com o mundo.
  • Scheduler port genérico, cron string opacocron: "0 9 * * *" é convenção UNIX, vai ser consumida pelo adapter prod (provavelmente node-cron). Domain não interpreta a string. Spec usa MockScheduler que ignora o cron (só guarda o callback e expõe tick({today}) pra spec invocar manualmente). Forma escolhida vs every({hours: 24}) por (a) familiar pra ops, (b) trivial pra adapter prod implementar, (c) zero custo pro spec.
  • MockScheduler.tick({today}) é síncrono no contract mas async no callback — spec faz await mockScheduler.tick({today: ...}) pra garantir que dispatcher terminou de processar antes das asserções. Mirror do MockWhatsAppGateway.emit(event) (wa-001/002/003).
  • Idempotência por triple (householdId, expenseId, dueDate) — não por hash de args, não por content-hash do message. Triple é o que define “mesma fatura, mesmo mês, mesma casa”. dueDate.toISOString() na key porque é único por (mês, dia) — disparo recorrente mensal funciona (mes seguinte tem dueDate diferente). Mesma filosofia de “Idempotência por operationId, não hash de args” (013).
  • Set in-memory dentro do dispatcher, sem port ReminderJournal ainda — basta pro MVP. Quando aparecer (a) reinício do bot perdendo o set, (b) histórico auditável de “o que mandei pra quem”, (c) regra “no max 1 reminder por triple, mesmo se rodar 100 ticks”, aí promove a port. Mesma postura de “Threshold mágico documentado é OK até aparecer caso” (006/015). Documentado como TODO.
  • Broadcast pra todos os chatIds linkados, não filtra por DM vs group — casal definiu que quer o bot no DM e no group via wa-001/003. Ambos são canais legítimos. Reminder vai pros dois — escolha consciente pra reforçar habit nos dois lugares e reduzir risco de “ah, mandou só pro DM, eu não vi”. Quando aparecer caso “só quero reminder no group, DM é privado”, vira filtro por flag no chatIdsFor ou no link (ex: linkChat({..., kind: "dm" | "group", remindersEnabled: bool})). Hoje: broadcast indiscriminado.
  • Auto-link silencioso vem com confirmação humana visível, mas reminder NÃO é silencioso — gotcha “Auto-link silencioso vem com confirmação humana visível” (wa-003) trata bind. Reminder é diferente: cada disparo já é a mensagem visível pro casal. Não precisa de “primeira vez aviso, depois fico quieto” porque a mensagem é a coisa em si. Cada sendMessage é a ação observável.
  • HouseholdLookup.chatIdsFor é reverse-lookup, não novo port — wa-001/wa-003 já criaram o port com findByChatId/findBySenderId/linkChat/bindSender. 019 adiciona uma leitura complementar, não outro port. Fake InMemoryHouseholdLookup itera o Map<chatId, householdId> e filtra. Adapter prod (SQLite) ganha índice secundário em household_id (decisão de schema deferida).
  • today: Date parâmetro explícito tanto no ReminderService quanto no MockScheduler.tick — sem new Date() interno em nenhuma camada. Mesma régua de FeasibilityCheck.evaluate({today}) (006) e BudgetAlerts.evaluate({today}) (015). Domínio testável, determinístico.
  • Aritmética de data em UTC, sempre — gotcha do AGENTS.md aplicado. Spec constrói datas via new Date("2026-06-XX") (parseado UTC); ReminderService faz cálculo de daysUntilDue via Date.UTC(...) + getUTCFullYear/getUTCMonth/getUTCDate. Se domain usar Date#getMonth() (local), BRT (-3) joga 01/06 00:00 UTC pra 31/05 21:00 local e monthsBetween quebra silenciosamente.
  • dueDate é o próximo dia X >= today — se vencimento = BillingDay.of(5) e today = 2026-06-02, dueDate = 2026-06-05. Se today = 2026-06-06 (passou), dueDate = 2026-07-05. Lookahead window aplicada sobre essa próxima ocorrência. Disparo recorrente mensal emerge naturalmente sem campo “next due” armazenado.
  • message PT-BR direto, sem i18n — gotcha “message humano em PT no domínio enquanto monolíngue” (015) reaplicada. Quando aparecer i18n real, vira {key, params} em VO. Spec assert via regex tolerante (/vence|hoje|amanhã|N dias/i), não wording exato — protege evolução do texto.
  • Reminder bypassa LLMReminderDispatcher chama gateway.sendMessage direto com plan.message. Não passa por AgentChat.ask, não consome token LLM, não toca AgentMemory. Reminder é mensagem outbound mecânica, não conversa. Quando aparecer “reminder personalizado pelo agente” (“vi que vocês passaram do orçamento mês passado, esse mês é melhor pagar adiantado”), aí sim passa pelo agente. Hoje: mecânico, barato, deterministico.
  • Result flat com optional fields, igual Alert/FeasibilityResultReminderPlan é uma forma só. Não vira union discriminada OverdueReminder | UpcomingReminder | DueTodayReminderdaysUntilDue: number (com 0/1/>=2) cobre os três casos com diferenciação no message. Mesma postura de “Result flat com optional fields, não union discriminada” (013/014/015).
  • Spec não testa scheduler.schedule registration — confia que dispatcher.start() registra; spec dispara mockScheduler.tick({today}) que invoca o callback registrado. Mirror do pattern de wa-001/002/003 (“Spec não testa gateway.onMessage registration”).
  • wa-001 (HouseholdLookup port) — ganha chatIdsFor(householdId). Spec wa-001 não precisa touchup (não usa o método novo). Fake InMemoryHouseholdLookup ganha a implementação.
  • wa-003 (HouseholdLookup com DM + group bind) — sem touchup. 019 consome o estado pós-wa-003 (mesmo Household com 2 chats linkados) como caso 1 do scenario.
  • wa-002 (WhatsAppGateway.sendMessage) — reusado intacto. Reminder e fatura-import dividem a mesma fronteira de saída.
  • Cenário 015 (BudgetAlerts) — sibling. Ambos são “evento derivado do estado do Budget que vira mensagem PT-BR”. Diferença: 015 é pull (UX/agente pergunta), 019 é push (cron dispara). Compartilham filosofia, não código. Quando aparecer “alerta de overspend também vai pro whats automaticamente” (push via 015), abre cenário próprio reusando ReminderDispatcher ou criando AlertDispatcher.
  • ADR 001 (stack)node-cron (ou equivalente) entra na lista adopt quando primeiro consumer prod implementar o adapter NodeScheduler. Por ora: port + fake só.
  • ReminderJournal port pra durabilidade do set de idempotência — quando bot reiniciar entre ticks e perder o set in-memory, vamos re-disparar reminders. Solução: port com markSent(triple) + hasSent(triple), adapter SQLite com tabela reminder_journal. Promove quando aparecer caso real (deploy/reload do processo Baileys).
  • NodeScheduler adapter real wrapping node-cron ou croner. Spec colocada src/contexts/agent/infrastructure/NodeScheduler.spec.ts sem doc Gherkin (ADR 003), gated por env porque precisa de timers reais.
  • Filtro DM vs group no chatIdsFor — quando casal pedir “reminder só no group, DM é privado”. Vira flag no link.
  • Reminder customizado pelo agente (LLM-driven, com contexto de overspend / metas) — passa por AgentChat.ask com prompt template. Cenário próprio.
  • Push de alertas de overspend (cenário 015) via mesma infraestrutura. Provavelmente AlertDispatcher espelhando ReminderDispatcher.
  • Snooze / silenciar reminder específico (UX: casal responde “não me lembra mais desse”). Vira tool de escrita + flag por expense.
  • Time-of-day configurável por casal (alguns casais querem 8h, outros 18h). Hoje cron fixo (default 9am). Vira override por Household ou config global.
  • Adapter real NodeScheduler — gated por env, spec colocada futura (ADR 003).
  • Persistência do journal de reminders enviados — set in-memory por ora; ReminderJournal port + adapter SQLite ficam pra quando aparecer demanda real.
  • Reminder LLM-driven — texto vem do ReminderService mecânico. Agente personaliza fica pra cenário próprio.
  • Reminder de fatura de cartão (cenário 001)Invoice.dueDate poderia disparar reminder via mesma engine. Hoje 019 cobre só RecurringExpense.vencimento. Estender pra Invoice é variação direta — adiciona “for each Account in accountRepository, for each unpaid Invoice, check dueDate” no dispatcher. Cenário próximo (provavelmente 020).
  • Reminder de meta off-pace (cenário 002/015)BudgetAlerts.evaluate(..., goal) já detecta. Push proativo via mesma infraestrutura é cenário próprio (ver TODO).
  • Snooze / dismiss — UX futura. Hoje reminder dispara incondicional dentro da window.
  • Push em horário customizável por Household — cron global por ora.
  • Filtro DM-only ou group-only — broadcast sempre.
  • Recovery de duplicate envio quando bot reinicia — set in-memory perde estado; cenário de durabilidade fica com ReminderJournal.
  • Rate limiting (ex: max 1 reminder por household por dia, mesmo se múltiplas faturas vencerem) — hoje uma mensagem por expense, sem agrupamento. Bundling vira cenário próprio quando aparecer reclamação real.
  • Reminder via DM da Ana sem o group, ou só pro Bruno — granularidade per-member. Hoje per-chat (broadcast pra todos os chats do Household).
  • Confirmação de pagamento via reminder (“já paguei!” → marca expense como pago) — interaction. Reminder é one-way push hoje.
  1. Criar VO ReminderPlan em src/contexts/planning/domain/value-objects/.
  2. Criar Domain Service ReminderService em src/contexts/planning/domain/services/ com evaluate({budget, today, lookAheadDays?}).
  3. Criar port Scheduler + SchedulerHandle em src/contexts/agent/application/.
  4. Estender port HouseholdLookup com chatIdsFor(householdId).
  5. Criar ReminderDispatcher em src/contexts/agent/application/ com start() + tick({today}).
  6. Criar test fake MockScheduler em src/contexts/agent/infrastructure/ expondo tick({today}) síncrono.
  7. Atualizar fake InMemoryHouseholdLookup em src/contexts/agent/infrastructure/ com chatIdsFor.
  8. Estender barrels (planning/domain/index.ts, agent/application/index.ts, agent/infrastructure/) com os novos exports.
  9. Passar os 6 scenarios. Adapter real NodeScheduler + ReminderJournal ficam deferidos.