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 emplanning/domain/services/) é puro: lêBudget, devolveReminderPlan[]POJO já commessageem PT-BR pronto. DefaultlookAheadDays = 3. Mesma régua deFeasibilityCheck.evaluate({today})(006) eBudgetAlerts.evaluate({today})(015) —todayinjetado, sem clock implícito, sem mutação.ReminderDispatcher(Application Service emagent/application/, capability-named — mesma classe de coisa queWhatsAppMessageRouterdo wa-002/wa-003) orquestra I/O: iterahouseholdRepository.list(), carregaBudgetde cada, rodaReminderService.evaluate, pra cadaReminderPlan× cadachatIdlinkado (DM + group) chamagateway.sendMessage. Idempotência por triple(householdId, expenseId, dueDate)num set in-memory dentro do dispatcher — basta pro MVP, vira portReminderJournalquando aparecer demanda de durabilidade.Scheduler(port emagent/application/) é cron-like opaco.schedule({cron, callback}): SchedulerHandleregistra um job que disparacallback({today})quando o cron bater. Adapter prod (NodeSchedulerwrappingnode-cronou similar) fica deferido — spec usaMockSchedulercomtick({today})síncrono pra dirigir tempo manualmente.HouseholdLookup(port já introduzido em wa-001) ganha método novochatIdsFor(householdId): Promise<string[]>— retorna todos os chats linkados àquele Household. Reverse dofindByChatId. FakeInMemoryHouseholdLookupimplementa lendo oMap<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) echatId="group-abc-xyz"(group dos dois + bot, via wa-003) - And um
Budgetda casa persistido comRecurringExpense“Aluguel”expected = R$ 3.000vencimento = BillingDay.of(5) - And
today = 2026-06-02(3 dias antes do dia 5 → dentro do defaultlookAheadDays = 3) - And
ReminderDispatcherinicializado comstart()registrando um job noMockScheduler - When
mockScheduler.tick({today: new Date("2026-06-02")})aciona o callback - Then
ReminderService.evaluate({budget, today: 2026-06-02})retorna 1ReminderPlanpro Aluguel comdaysUntilDue = 3,dueDate = 2026-06-05,amount = R$ 3.000 - And
gateway.sentMessagestem exatamente 2 entradas (uma DM, uma group) — broadcast pra todos oschatIdsFor(household.id) - And cada
sentMessages[i].textcontém"Aluguel"e bate em/vence|3 dias/i - And os dois
chatIdemsentMessagessã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 2sentMessages - When
mockScheduler.tick({today: new Date("2026-06-02")})roda uma segunda vez com o mesmotoday - Then
gateway.sentMessages.lengthpermanece 2 — idempotência por triple(household.id, expense.id, dueDate)curto-circuita antes de chamargateway.sendMessage - And o dispatcher não chama
gateway.sendMessageno 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
Householdlinkado a 1 chat +Budgetcom Aluguelvencimento = BillingDay.of(5) - And
today = 2026-06-01(4 dias antes do dia 5 → fora do defaultlookAheadDays = 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
Householdlinkado a DM + group,Budgetcom Aluguelvencimento = 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].textbate em/amanhã|1 dia/i(urgência humana visível no texto) - And o
daysUntilDuenoReminderPlancorrespondente é1
Scenario: Vence hoje — disparo “vence hoje”
Section titled “Scenario: Vence hoje — disparo “vence hoje””- Given
Householdlinkado a DM + group,Budgetcom Aluguelvencimento = 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].textbate em/hoje/i - And o
daysUntilDuenoReminderPlancorrespondente é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
Budgetcom Aluguelvencimento = BillingDay.of(5)e está linkada a"dm-A"+"group-A" - And “Casa B” tem
Budgetcom Internetvencimento = 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 defaultlookAheadDays = 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)
Modelo
Section titled “Modelo”- Context existente —
planning/(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 Service —
ReminderService.evaluate({budget, today, lookAheadDays?})stateless, todos os métodosstatic. 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 orchestrator —
ReminderDispatcheremagent/application/. Não é Domain Service (orquestra I/O, depende de ports). Mesma classe de coisa queWhatsAppMessageRouter(wa-002/003). - Port
Scheduler—agent/application/. Cron-like, opaco. Adapter prod fica deferido. - Port
HouseholdLookupganhachatIdsFor(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;ReminderJournalport fica como TODO quando aparecer durabilidade necessária (ex: bot reinicia entre ticks e perde o set).
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.tsReminderService.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.tsexport 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.tsReminderDispatcher.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.scheduledispatcher.tick({today}): Promise<void> // método público pra spec disparar direto; // scheduler.callback === dispatcher.tick.bind(dispatcher)Lógica do tick:
households = await householdRepository.list().- Pra cada
household:budget = await budgetRepository.load(household.id)(assumebudget.id === household.idpor 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 cadachatIdchamaawait 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.
Decisões de design
Section titled “Decisões de design”ReminderServiceemplanning/domain/services/, não embudget/— apesar de lerBudget, o serviço é capability cross-context: pode futuramente lerGoal.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 deFeasibilityCheck(006) eBudgetAlerts(015). Domain Service em<context>/domain/services/é o slot canônico (gotcha “Port emapplication/, Domain Service emdomain/services/”).ReminderDispatcheremagent/application/, não emplanning/— orquestra I/O (scheduler, repos, gateway). Gotcha do AGENTS.md: “se a coisa é pura, mora emdomain/services/; se é infra-facing, emapplication/”. Dispatcher não é Domain Service. Mora emagent/(não emplanning/) porque depende deWhatsAppGateway+HouseholdLookupque vivem emagent/application/. Planning não conhece canal; agent é quem fala com o mundo.Schedulerport genérico, cron string opaco —cron: "0 9 * * *"é convenção UNIX, vai ser consumida pelo adapter prod (provavelmentenode-cron). Domain não interpreta a string. Spec usaMockSchedulerque ignora o cron (só guarda o callback e expõetick({today})pra spec invocar manualmente). Forma escolhida vsevery({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 fazawait mockScheduler.tick({today: ...})pra garantir que dispatcher terminou de processar antes das asserções. Mirror doMockWhatsAppGateway.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 temdueDatediferente). Mesma filosofia de “Idempotência poroperationId, não hash de args” (013). - Set in-memory dentro do dispatcher, sem port
ReminderJournalainda — 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
chatIdsForou 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 comfindByChatId/findBySenderId/linkChat/bindSender. 019 adiciona uma leitura complementar, não outro port. FakeInMemoryHouseholdLookupitera oMap<chatId, householdId>e filtra. Adapter prod (SQLite) ganha índice secundário emhousehold_id(decisão de schema deferida).today: Dateparâmetro explícito tanto noReminderServicequanto noMockScheduler.tick— semnew Date()interno em nenhuma camada. Mesma régua deFeasibilityCheck.evaluate({today})(006) eBudgetAlerts.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);ReminderServicefaz cálculo dedaysUntilDueviaDate.UTC(...)+getUTCFullYear/getUTCMonth/getUTCDate. Se domain usarDate#getMonth()(local), BRT (-3) joga01/06 00:00 UTCpra31/05 21:00 localemonthsBetweenquebra silenciosamente. dueDateé o próximo dia X >= today — sevencimento = BillingDay.of(5)etoday = 2026-06-02,dueDate = 2026-06-05. Setoday = 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.messagePT-BR direto, sem i18n — gotcha “messagehumano 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 LLM —
ReminderDispatcherchamagateway.sendMessagedireto complan.message. Não passa porAgentChat.ask, não consome token LLM, não tocaAgentMemory. 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/FeasibilityResult—ReminderPlané uma forma só. Não vira union discriminadaOverdueReminder | UpcomingReminder | DueTodayReminder—daysUntilDue: number(com0/1/>=2) cobre os três casos com diferenciação nomessage. Mesma postura de “Result flat com optional fields, não union discriminada” (013/014/015). - Spec não testa
scheduler.scheduleregistration — confia quedispatcher.start()registra; spec disparamockScheduler.tick({today})que invoca o callback registrado. Mirror do pattern de wa-001/002/003 (“Spec não testagateway.onMessageregistration”).
Impacto em outros cenários
Section titled “Impacto em outros cenários”- wa-001 (HouseholdLookup port) — ganha
chatIdsFor(householdId). Spec wa-001 não precisa touchup (não usa o método novo). FakeInMemoryHouseholdLookupganha 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
ReminderDispatcherou criandoAlertDispatcher. - ADR 001 (stack) —
node-cron(ou equivalente) entra na lista adopt quando primeiro consumer prod implementar o adapterNodeScheduler. Por ora: port + fake só.
ReminderJournalport 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 commarkSent(triple)+hasSent(triple), adapter SQLite com tabelareminder_journal. Promove quando aparecer caso real (deploy/reload do processo Baileys).NodeScheduleradapter real wrappingnode-cronoucroner. Spec colocadasrc/contexts/agent/infrastructure/NodeScheduler.spec.tssem 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.askcom prompt template. Cenário próprio. - Push de alertas de overspend (cenário 015) via mesma infraestrutura. Provavelmente
AlertDispatcherespelhandoReminderDispatcher. - 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.
Fora de escopo
Section titled “Fora de escopo”- Adapter real
NodeScheduler— gated por env, spec colocada futura (ADR 003). - Persistência do journal de reminders enviados — set in-memory por ora;
ReminderJournalport + adapter SQLite ficam pra quando aparecer demanda real. - Reminder LLM-driven — texto vem do
ReminderServicemecânico. Agente personaliza fica pra cenário próprio. - Reminder de fatura de cartão (cenário 001) —
Invoice.dueDatepoderia disparar reminder via mesma engine. Hoje 019 cobre sóRecurringExpense.vencimento. Estender pra Invoice é variação direta — adiciona “for eachAccountinaccountRepository, for each unpaidInvoice, 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.
Próximo passo
Section titled “Próximo passo”- Criar VO
ReminderPlanemsrc/contexts/planning/domain/value-objects/. - Criar Domain Service
ReminderServiceemsrc/contexts/planning/domain/services/comevaluate({budget, today, lookAheadDays?}). - Criar port
Scheduler+SchedulerHandleemsrc/contexts/agent/application/. - Estender port
HouseholdLookupcomchatIdsFor(householdId). - Criar
ReminderDispatcheremsrc/contexts/agent/application/comstart()+tick({today}). - Criar test fake
MockScheduleremsrc/contexts/agent/infrastructure/expondotick({today})síncrono. - Atualizar fake
InMemoryHouseholdLookupemsrc/contexts/agent/infrastructure/comchatIdsFor. - Estender barrels (
planning/domain/index.ts,agent/application/index.ts,agent/infrastructure/) com os novos exports. - Passar os 6 scenarios. Adapter real
NodeScheduler+ReminderJournalficam deferidos.