026 — Digest periódico (semanal + mensal)
019 cobre o reminder pontual — uma fatura vencendo dispara mensagem no whats. 026 é o irmão periódico: bot manda resumo semanal todo domingo 9h e resumo mensal no último dia do mês 9h, por conta própria. Sem nudge humano. Sem fatura específica vencendo. Snapshot do estado financeiro do casal numa mensagem só, no whats que eles abrem 87x/dia.
Pesquisa NotebookLM (decisão CEO #11) confirmou: notificações proativas + UX writing empático = retenção via hábito. Reminder (019) ancora “bot lembra de fatura” — digest (026) ancora “bot acompanha junto”. São camadas complementares, mesma régua técnica, cadências diferentes.
A decomposição segue exatamente a régua do 019 — decisão pura separada de orquestração I/O:
DigestService.evaluate({budget, goals?, household?, today, kind})(Domain Service emplanning/domain/services/) é puro: lê estado atual + montaDigestPlanPOJO commessagePT-BR pronto. Mesma régua deReminderService.evaluate({today})(019),BudgetAlerts.evaluate({today})(015),FeasibilityCheck.evaluate({today})(006) —todayinjetado, sem clock implícito, sem mutação.DigestDispatcher(Application Service emagent/application/, capability-named — irmão doReminderDispatcherdo 019) orquestra I/O: iterahouseholdRepository.list(), carregaBudget+Goal[]+Householdde cada, rodaDigestService.evaluate, pra cadaDigestPlan× cadachatIdlinkado (DM + group) chamagateway.sendMessage. Idempotência por triple(householdId, kind, periodKey)num set in-memory.- Cadências paralelas —
start()registra dois jobs no scheduler:"0 9 * * 0"(todo domingo 9h, kind=weekly) e"0 9 28-31 * *"filtrado pra last-day-of-month (kind=monthly). Adapter prod (NodeScheduler) decide impl exata; spec dirige viamockScheduler.tick({today, kind})direto. Scheduler(port já introduzido em 019) eHouseholdLookup.chatIdsFor(extensão de 019) reusados intactos — 026 não adiciona método novo, só novo consumer.WhatsAppGateway(port já introduzido em wa-002) reusado intacto — broadcast pra DM + group, mesma régua do reminder.
Digest bypassa LLM. Texto vem do DigestService.message direto, formatado per ADR 008 (PT-BR, sem juridiquês, frases curtas, emoji funcional quando alerta crítico). Sem AgentChat, sem tool-call, sem token gasto. Mesma filosofia de “evento derivado puro” do 019/015.
Idempotência por triple (householdId, kind, periodKey). Dispatcher mantém Set<string> interno. periodKey formato: weekly = "2026-W30" (ISO week), monthly = "2026-07" (YYYY-MM). Domingo da semana W30 dispara uma vez; rodar o tick de novo no mesmo domingo é no-op. Semana seguinte (W31) é triple novo — dispara natural.
Scenario: Domingo 9h dispara digest semanal — DM + group
Section titled “Scenario: Domingo 9h dispara digest semanal — DM + group”- Given um
Household“Ana e Bruno” persistido (householdRepository.save(...)) - And
householdLookup.linkChat(...)registrou dois chats pra esse Household:chatId="dm-ana-bruno"(DM, via wa-001) echatId="group-ana-bruno"(group, via wa-003) - And um
Budgetda casa persistido com 2RecurringExpenses: Aluguel (expected = R$ 3.000,vencimento = BillingDay.of(5)) e Mercado (expected = R$ 800,vencimento = BillingDay.of(10)) - And Aluguel registrou
recordSpend(julho/2026, R$ 3.000)e Mercado registrourecordSpend(julho/2026, R$ 540) - And uma
Goal“Lua de mel”target = €6.000,startedOn = 2026-06-01,deadline = 2028-06-01 - And
today = 2026-07-26(domingo, semana ISO W30 de 2026) - And
DigestDispatcherinicializado comstart()registrando os dois jobs noMockScheduler - When
mockScheduler.tick({today: new Date("2026-07-26"), kind: "weekly"})aciona o callback semanal - Then
gateway.sentMessagestem exatamente 2 entradas (uma DM, uma group) — broadcast pra todos oschatIdsFor(household.id) - And os dois
chatIdemsentMessagessão distintos —"dm-ana-bruno"e"group-ana-bruno", cada um aparece exatamente uma vez - And cada
sentMessages[i].textbate em/semana|esta\s*semana|últim/i(cadência semanal visível) - And cada
sentMessages[i].textcontém"R$"ou"BRL"(snapshot do orçamento aparece no texto) - And cada
sentMessages[i].textmenciona"Lua de mel"ou"meta"(progresso da meta aparece)
Scenario: Último dia do mês dispara digest mensal com variance
Section titled “Scenario: Último dia do mês dispara digest mensal com variance”- Given mesmo setup do scenario anterior (Household + Budget com Aluguel/Mercado + Goal Lua de mel)
- And
today = 2026-07-31(último dia de julho) - When
mockScheduler.tick({today: new Date("2026-07-31"), kind: "monthly"})aciona o callback mensal - Then
gateway.sentMessagestem exatamente 2 entradas (broadcast DM + group) - And cada
sentMessages[i].textreferencia o mês — bate em/julho|07\/2026|2026-07|mês/i - And cada
sentMessages[i].textcarrega variância de Mercado (expected R$ 800, actual R$ 540, underspend R$ 260) — bate em/260|variân|diferen|sobr/i - And o
DigestPlan.budgetSnapshot.variancerefletevarianceReport(julho/2026)do Budget (reuso do 003, não recalcula por fora)
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 scenario 1, com
tick({today: 2026-07-26, kind: "weekly"})já tendo rodado uma vez e produzido 2sentMessages - When
mockScheduler.tick({today: new Date("2026-07-26"), kind: "weekly"})roda uma segunda vez com o mesmotodayekind - Then
gateway.sentMessages.lengthpermanece 2 — idempotência por triple(household.id, "weekly", "2026-W30")curto-circuita antes de chamargateway.sendMessage - And o dispatcher não chama
gateway.sendMessageno segundo tick (side-effect observável zerado, não só count final)
Scenario: Household sem dados — digest é skipped, nenhum disparo
Section titled “Scenario: Household sem dados — digest é skipped, nenhum disparo”- Given um
Household“Carlos e Dani” persistido e linkado a"dm-carlos-dani"+"group-carlos-dani" - And um
Budgetda casa vazio (zeroRecurringExpenses) persistido - And nenhuma
Goalcadastrada - And
today = 2026-07-26(domingo) - When
mockScheduler.tick({today: new Date("2026-07-26"), kind: "weekly"})aciona o callback - Then
DigestService.evaluate({budget, goals: [], household, today, kind: "weekly"}).skip === true(domínio puro retorna skip sem dados) - And
gateway.sentMessagespermanece vazio (dispatcher não envia quandoplan.skip === true) - And
DigestPlan.reasoncarrega motivo legível (ex:"sem dados ainda") pra debug/telemetria futura
Scenario: Goal off-pace aparece como destaque do digest semanal
Section titled “Scenario: Goal off-pace aparece como destaque do digest semanal”- Given o
Household“Ana e Bruno” linkado a DM + group - And um
Budgetnormal com Aluguel (expected = R$ 3.000,recordSpend(julho/2026, R$ 3.000)) - And uma
Goal“Casa”target = R$ 120.000,startedOn = 2026-06-01,deadline = 2026-12-01→requiredMonthlyR$ 20.000 - And o casal aportou apenas R$ 2.000 totais até
2026-07-26(pace R$ 1.000/mês, muito abaixo do required) —Goal.onTrack(today) === false - And
today = 2026-07-26(domingo) - When
mockScheduler.tick({today: new Date("2026-07-26"), kind: "weekly"})aciona o callback - Then
gateway.sentMessagestem exatamente 2 entradas (DM + group) - And cada
sentMessages[i].textmenciona"Casa"(nome da meta off-pace) - And cada
sentMessages[i].textbate em/atrasad|abaixo|off.?pace|ritmo|fora/i(sinal de off-pace visível) - And
DigestPlan.goalsProgresscontém entrada comname = "Casa"eonTrack === false
Modelo
Section titled “Modelo”- Context existente —
planning/(capability-named, sem aggregate root próprio, igual 006/015/019). Não introduz aggregate nem entity nova. Só Domain Service + POJO de resultado. - Domain Service —
DigestService.evaluate({...})stateless, todos os métodosstatic. LêBudget(obrigatório),goals?: Goal[](opcional),household?: Household(opcional). Sem mutação, sem I/O. - POJO
DigestPlan—{ kind, periodKey, skip?, reason?, budgetSnapshot?, goalsProgress?, alerts?, message }. Result flat com optional fields (mesma postura de 013/014/015/019), não union discriminada por kind. - Application orchestrator —
DigestDispatcheremagent/application/. Não é Domain Service (orquestra I/O, depende de ports). Irmão doReminderDispatcher(019). - Port
Scheduler(de 019) — reusado intacto. Dispatcher registra dois jobs nostart(). - Port
HouseholdLookup(de 019/wa-001) — reusado intacto.chatIdsFor(householdId)é tudo que 026 precisa. - Port
WhatsAppGateway(de wa-002) — reusado intacto. - Sem persistência de digest — alerta é derivado. Idempotência via set in-memory; promove a
DigestJournalport quando aparecer demanda de durabilidade (mesma postura doReminderJournalTODO do 019).
export type DigestKind = "weekly" | "monthly";
export interface BudgetSnapshot { expected: string; // "BRL 8000" — stringly-typed pra serialização e fronteira agente actualSoFar: string; // "BRL 5240" variance: string; // "BRL +200" ou "BRL -150" topExpenses: Array<{ name: string; amount: string }>; // top 3 por actual desc}
export interface GoalProgressEntry { name: string; pace: string; // "BRL 1000" — pace na currency do target onTrack: boolean;}
export interface DigestPlan { kind: DigestKind; periodKey: string; // weekly = "2026-W30" (ISO week), monthly = "2026-07" (YYYY-MM) skip?: boolean; // true = sem dados, dispatcher não envia reason?: string; // legível, complementa skip ("sem dados ainda") budgetSnapshot?: BudgetSnapshot; goalsProgress?: GoalProgressEntry[]; alerts?: Alert[]; // subset relevante do BudgetAlerts.evaluate (015), opcional message: string; // PT-BR pronto pro casal ler, formatado per ADR 008}
// src/contexts/planning/domain/services/DigestService.tsDigestService.evaluate({ budget: Budget, goals?: Goal[], household?: Household, today: Date, kind: DigestKind,}): DigestPlan // kind="weekly": // periodKey = ISO week ("YYYY-Www") // budgetSnapshot opcional: lê do mês corrente (Period.of(today.year, today.month)) // se budget.list().length === 0 && (!goals || goals.length === 0) → { skip: true, reason: "sem dados ainda" } // kind="monthly": // periodKey = YYYY-MM // budgetSnapshot reflete budget.varianceReport(period) (003) // goalsProgress reflete goal.pace/requiredMonthly/onTrack (002) // message é montado por kind, PT-BR, ADR 008
// src/contexts/agent/application/DigestDispatcher.tsDigestDispatcher.create({ scheduler: Scheduler; gateway: WhatsAppGateway; householdLookup: HouseholdLookup; householdRepository: HouseholdRepository; budgetRepository: BudgetRepository; goalRepository?: GoalRepository; weeklyCron?: string; // default "0 9 * * 0" (domingo 9h) monthlyCron?: string; // default "0 9 28-31 * *" + filtro last-day-of-month no callback}): DigestDispatcher
dispatcher.start(): void // registra os dois jobs via scheduler.scheduledispatcher.tick({today, kind}): Promise<void> // método público pra spec disparar direto; // scheduler.callback ≈ dispatcher.tick.bind(dispatcher, {kind})Lógica do tick({today, kind}):
periodKeyderivado: weekly = ISO week ("2026-W30"), monthly = YYYY-MM ("2026-07").households = await householdRepository.list().- Pra cada
household:budget = await budgetRepository.load(household.id)(mesma convenção do 019:budget.id === household.id).goals = await goalRepository?.list?.() ?? [](filtragem por household quando o repo suportar — hoje toda goal pertence à única casa do MVP, ok ler todos).plan = DigestService.evaluate({budget, goals, household, today, kind}).- Se
plan.skip === true→ continue (sem envio). triple = ${household.id}|${kind}|${periodKey}— checa set in-memory.- Se já visto: continue.
- Senão: marca no set,
chatIds = await householdLookup.chatIdsFor(household.id), pra cadachatIdchamaawait gateway.sendMessage({chatId, text: plan.message}).
Formato do message (PT-BR, ADR 008 — frases curtas, sem juridiquês, ≤ 1 emoji):
- Weekly snapshot (~3 frases):
- linha 1: cadência (“esta semana no orçamento”) + total realizado vs expected.
- linha 2 opcional: top 1 expense ou alerta crítico (⚠️ se warn+/critical).
- linha 3 opcional: progresso da meta principal (
"meta X no ritmo"ou"meta X fora do ritmo").
- Monthly snapshot (~3 frases):
- linha 1: mês (“fechamento de julho”) + variance total (
"sobrou R$ 260"/"estourou R$ 320"). - linha 2 opcional: top expenses do mês (até 3).
- linha 3 opcional: status das metas no mês.
- linha 1: mês (“fechamento de julho”) + variance total (
Specs validam via regex tolerante (/semana|últim/i, /julho|mês/i, /Lua de mel/, /atrasad|fora/i) — wording exato fica livre pra UX iterar.
Decisões de design
Section titled “Decisões de design”DigestServiceemplanning/domain/services/, não embudget/— apesar de lerBudgetcomo input principal, o serviço cross-context lêGoal,Householde potencialmente outros aggregates no futuro (Account.invoiceDueDate, etc). Planning é a casa natural pra “olhar o estado consolidado e decidir como resumir”, mesma régua deFeasibilityCheck(006),BudgetAlerts(015) eReminderService(019). Domain Service em<context>/domain/services/é o slot canônico (gotcha “Port emapplication/, Domain Service emdomain/services/”).DigestDispatcheremagent/application/, não emplanning/— orquestra I/O (scheduler, repos, gateway). 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. Mesma régua doReminderDispatcher(019).- Dois jobs no
start()em vez de um job inteligente —start()chamascheduler.schedule(...)duas vezes: um pra weekly ("0 9 * * 0"), outro pra monthly ("0 9 28-31 * *"+ filtro last-day no callback). Alternativa rejeitada: um job único"0 9 * * *"que decide internamente “é domingo? é último dia?”. Rejeitada porque (a) cron é convenção UNIX que adapter prod (NodeScheduler) já interpreta — push pro adapter o que o adapter faz bem, (b) dois callbacks distintos = doiskindexplícitos notick, sem branching de classificação dentro do dispatcher. kindé parâmetro dotick, não dostart—start()registra dois jobs e cada job invocatick({today, kind})com seu kind hard-coded. Spec dirigemockScheduler.tick({today, kind})direto, pulando o cron. Isso mantémtickpuramente parametrizado pra teste e adapter prod só amarrakindno callback registrado. Mirror doReminderDispatcher.tick({today})do 019.- Idempotência por triple
(householdId, kind, periodKey)— não por hash de args, não por content-hash do message. Triple é o que define “mesma casa, mesma cadência, mesmo período”. Domingo 9h W30 dispara uma vez; rodar de novo no mesmo domingo é no-op. Semana seguinte (W31) é triple novo — dispara natural. Mesma régua do 019 ((householdId, expenseId, dueDate)) e do 013 (operationId). periodKeyISO week pro weekly —"2026-W30"formato familiar (YYYY-Www). Domain calcula sem libs externas (cálculo ISO week é determinístico via UTC date arithmetic, gotcha aplicada). Quando aparecer caso “semanas começam na segunda no Brasil mas no domingo nos EUA”, vira parâmetro. Hoje: ISO 8601 (semana começa segunda, semana 1 contém a primeira quinta-feira do ano).periodKeyYYYY-MM pro monthly — formato Period existente no shared-kernel. Trivial de derivar dotoday(UTC).skip: trueé resposta válida, não erro — Household sem Budget/Goal não é caso de exceção. Domain retorna{skip: true, reason}pra dispatcher saber “não envia” sem branch de try/catch. Mesma filosofia de “Sem record = planning view, não erro” do 003. Caller (dispatcher) tem regra:if (plan.skip) continue.messagePT-BR direto, formatado per ADR 008 — gotcha “messagehumano em PT no domínio enquanto monolíngue” (015/019) reaplicada. Tom mel: frases curtas, sem juridiquês, emoji funcional só. Spec assert via regex tolerante, não wording exato — protege evolução do texto.- Digest bypassa LLM —
DigestDispatcherchamagateway.sendMessagedireto complan.message. Não passa porAgentChat.ask, não consome token LLM, não tocaAgentMemory. Reminder e digest são mensagens outbound mecânicas. Quando aparecer “digest personalizado pelo agente” (LLM-driven com prompt), aí sim passa pelo agente — cenário próprio. alertsno DigestPlan é subset reusado, não cópia —DigestService.evaluatepode chamarBudgetAlerts.evaluate({budget, period, today})internamente e filtrar por relevância (ex: só severity warn+ pro weekly).AlertVO é reusado intacto, sem clonar. Quando aparecer “digest tem alerta próprio diferente do BudgetAlerts” abre cenário.topExpensesé capped em 3, ordenado por actual desc — heurística simples pra “destaques do mês”. UX renderiza mais se quiser via outra tool. Hard-coded number é OK até aparecer demanda de configuração (“queremos top 5”).budgetSnapshot.expected/actualSoFar/variancestringly-typed — formato"BRL 8000"(currency + amount). Mesma postura de “Stringly-typed na fronteira LLM, mesmo dentro de read model” (012/working memory). Digest é input pro casal ler (texto) e potencialmente pro LLM consumir como context, não objeto pra domain code. Conversão praMoneyacontece na write tool quando virar mutação (não há mutação aqui).- Result flat com optional fields —
DigestPlané uma forma só comskip?/budgetSnapshot?/goalsProgress?/alerts?opcionais. Não vira unionWeeklyDigest | MonthlyDigest. Consumer diferencia porkindenum + checa optional. Mesma postura de “Result flat com optional fields, não union discriminada” (013/014/015/019). - Broadcast pra todos os chatIds linkados, idêntico ao 019 — DM e group recebem. Filtro DM-only/group-only fica TODO até o casal pedir.
today: Dateparâmetro explícito tanto noDigestServicequanto notick— semnew Date()interno em nenhuma camada. Mesma régua de 019/015/006. Spec constrói datas vianew Date("2026-07-26")(UTC); cálculo ISO week interno usagetUTCFullYear/getUTCMonth/getUTCDate. Gotcha “Aritmética de data em UTC, sempre”.Scheduler.schedulerecebecron + callbackopaco — 026 reusa o port de 019. Adapter prod (NodeScheduler) interpreta"0 9 28-31 * *"+ filtro last-day-of-month no próprio callback (ou via croner). MockScheduler ignora o cron e expõetick({today, kind})síncrono.MockScheduler.tickganha overload comkind— para suportar dois jobs paralelos, o mock precisa rotear o tick pro callback certo. Forma sugerida:tick({today, kind})que invoca o callback registrado com aquele kind. Implementação interna do mock guarda Mapkind → callback. Quando okindnão importa (cenário 019),tick({today})continua funcionando semkind.- Goal repository opcional no DI — alguns Households não têm goals ainda (Carlos e Dani do scenario 4).
goalRepository?é optional; se ausente,goalsvira[]egoalsProgressficaundefinedno plan. Mesma postura permissiva dohousehold?no 015.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- 019 (ReminderDispatcher) — sem touchup. 026 vive lado a lado, não substitui. Compartilham
Scheduler+HouseholdLookup+WhatsAppGatewaypor referência (DI), nunca por código. MockScheduler(test fake de 019) — ganha suporte akindnotick(overload). Spec 019 continua chamandotick({today})semkind— back-compat.Schedulerport (de 019) — sem touchup. Cron string + callback opaco já cobre 026.HouseholdLookupport (wa-001/019) — sem touchup.chatIdsForreusado.WhatsAppGatewayport (wa-002) — sem touchup.sendMessagereusado.- 015 (BudgetAlerts) — sem touchup.
AlertVO consumido por referência viaDigestPlan.alertsopcional. - 003 (Budget.varianceReport) — sem touchup. Reusado pelo
DigestServiceno kind=monthly. - 002 (Goal.pace/onTrack) — sem touchup. Reusado pelo
DigestServiceno goalsProgress.
DigestJournalport pra durabilidade do set de idempotência (mesmo trade-off doReminderJournalTODO do 019). Promove quando aparecer caso real (deploy/reload entre ticks).- Adapter
NodeSchedulerreal wrappingnode-cronoucroner— compartilhado com 019. Spec colocadasrc/contexts/agent/infrastructure/NodeScheduler.spec.ts(ADR 003). - Filtro DM-only vs group-only no broadcast — flag no link (mesmo TODO do 019).
- Digest customizado pelo agente (LLM-driven, com prompt template) — passa por
AgentChat.ask. Cenário próprio. - Cadência configurável por casal (alguns querem digest só mensal, outros só semanal) — flag por Household.
- Bundling de digest + reminder no mesmo whats quando bater no mesmo dia/hora — anti-spam, cenário próprio.
- Filtro de goals por household no
GoalRepository.list({householdId?})— hoje todas goals vão pra todas casas; resolve quando segunda casa real entrar.
Fora de escopo
Section titled “Fora de escopo”- Adapter real
NodeScheduler— gated por env, spec colocada futura (ADR 003). - Persistência do journal de digests enviados — set in-memory por ora.
- Digest LLM-driven com personalização contextual — fica pra cenário próprio.
- Cadência customizada por casal — defaults globais por ora.
- Bundling reminder + digest no mesmo whats — cenário próprio.
- Snooze / silenciar digest específico — UX futura.
- Time-of-day configurável — cron fixo 9h por ora.
- Digest via DM-only ou group-only — broadcast sempre.
- Digest trimestral / anual — só weekly + monthly por ora; trimestrais viram cenário quando o casal pedir.
- Comparativo “este mês vs anterior” — analytics, fora.
- Recommendations dentro do digest (“corta R$ 200 em Streaming”) — sugestão automática, fica pra cenário próprio com regra explícita.
Próximo passo
Section titled “Próximo passo”- Criar POJO
DigestPlan+ tipoDigestKindemsrc/contexts/planning/domain/value-objects/DigestPlan.ts. - Criar Domain Service
DigestServiceemsrc/contexts/planning/domain/services/DigestService.tscomevaluate({budget, goals?, household?, today, kind}). - Estender
MockScheduleremsrc/contexts/agent/infrastructure/MockScheduler.tspra suportartick({today, kind?})(overload). - Criar
DigestDispatcheremsrc/contexts/agent/application/DigestDispatcher.tscomstart()registrando 2 jobs +tick({today, kind}). - Atualizar barrels (
planning/domain/index.ts,agent/application/index.ts) com os novos exports. - Passar os 5 scenarios. Adapter real
NodeScheduler+DigestJournalficam deferidos.