Skip to content

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 em planning/domain/services/) é puro: lê estado atual + monta DigestPlan POJO com message PT-BR pronto. Mesma régua de ReminderService.evaluate({today}) (019), BudgetAlerts.evaluate({today}) (015), FeasibilityCheck.evaluate({today}) (006) — today injetado, sem clock implícito, sem mutação.
  • DigestDispatcher (Application Service em agent/application/, capability-named — irmão do ReminderDispatcher do 019) orquestra I/O: itera householdRepository.list(), carrega Budget + Goal[] + Household de cada, roda DigestService.evaluate, pra cada DigestPlan × cada chatId linkado (DM + group) chama gateway.sendMessage. Idempotência por triple (householdId, kind, periodKey) num set in-memory.
  • Cadências paralelasstart() 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 via mockScheduler.tick({today, kind}) direto.
  • Scheduler (port já introduzido em 019) e HouseholdLookup.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) e chatId="group-ana-bruno" (group, via wa-003)
  • And um Budget da casa persistido com 2 RecurringExpenses: 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 registrou recordSpend(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 DigestDispatcher inicializado com start() registrando os dois jobs no MockScheduler
  • When mockScheduler.tick({today: new Date("2026-07-26"), kind: "weekly"}) aciona o callback semanal
  • Then gateway.sentMessages tem exatamente 2 entradas (uma DM, uma group) — broadcast pra todos os chatIdsFor(household.id)
  • And os dois chatId em sentMessages são distintos — "dm-ana-bruno" e "group-ana-bruno", cada um aparece exatamente uma vez
  • And cada sentMessages[i].text bate em /semana|esta\s*semana|últim/i (cadência semanal visível)
  • And cada sentMessages[i].text contém "R$" ou "BRL" (snapshot do orçamento aparece no texto)
  • And cada sentMessages[i].text menciona "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.sentMessages tem exatamente 2 entradas (broadcast DM + group)
  • And cada sentMessages[i].text referencia o mês — bate em /julho|07\/2026|2026-07|mês/i
  • And cada sentMessages[i].text carrega 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.variance reflete varianceReport(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 2 sentMessages
  • When mockScheduler.tick({today: new Date("2026-07-26"), kind: "weekly"}) roda uma segunda vez com o mesmo today e kind
  • Then gateway.sentMessages.length permanece 2 — idempotência por triple (household.id, "weekly", "2026-W30") curto-circuita antes de chamar gateway.sendMessage
  • And o dispatcher não chama gateway.sendMessage no 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 Budget da casa vazio (zero RecurringExpenses) persistido
  • And nenhuma Goal cadastrada
  • 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.sentMessages permanece vazio (dispatcher não envia quando plan.skip === true)
  • And DigestPlan.reason carrega 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 Budget normal 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-01requiredMonthly R$ 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.sentMessages tem exatamente 2 entradas (DM + group)
  • And cada sentMessages[i].text menciona "Casa" (nome da meta off-pace)
  • And cada sentMessages[i].text bate em /atrasad|abaixo|off.?pace|ritmo|fora/i (sinal de off-pace visível)
  • And DigestPlan.goalsProgress contém entrada com name = "Casa" e onTrack === false
  • Context existenteplanning/ (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 ServiceDigestService.evaluate({...}) stateless, todos os métodos static. 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 orchestratorDigestDispatcher em agent/application/. Não é Domain Service (orquestra I/O, depende de ports). Irmão do ReminderDispatcher (019).
  • Port Scheduler (de 019) — reusado intacto. Dispatcher registra dois jobs no start().
  • 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 DigestJournal port quando aparecer demanda de durabilidade (mesma postura do ReminderJournal TODO do 019).
src/contexts/planning/domain/value-objects/DigestPlan.ts
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.ts
DigestService.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.ts
DigestDispatcher.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.schedule
dispatcher.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}):

  1. periodKey derivado: weekly = ISO week ("2026-W30"), monthly = YYYY-MM ("2026-07").
  2. households = await householdRepository.list().
  3. 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 cada chatId chama await 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.

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.

  • DigestService em planning/domain/services/, não em budget/ — apesar de ler Budget como input principal, o serviço cross-contextGoal, Household e potencialmente outros aggregates no futuro (Account.invoiceDueDate, etc). Planning é a casa natural pra “olhar o estado consolidado e decidir como resumir”, mesma régua de FeasibilityCheck (006), BudgetAlerts (015) e ReminderService (019). Domain Service em <context>/domain/services/ é o slot canônico (gotcha “Port em application/, Domain Service em domain/services/”).
  • DigestDispatcher em agent/application/, não em planning/ — orquestra I/O (scheduler, repos, gateway). 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. Mesma régua do ReminderDispatcher (019).
  • Dois jobs no start() em vez de um job inteligentestart() chama scheduler.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 = dois kind explícitos no tick, sem branching de classificação dentro do dispatcher.
  • kind é parâmetro do tick, não do startstart() registra dois jobs e cada job invoca tick({today, kind}) com seu kind hard-coded. Spec dirige mockScheduler.tick({today, kind}) direto, pulando o cron. Isso mantém tick puramente parametrizado pra teste e adapter prod só amarra kind no callback registrado. Mirror do ReminderDispatcher.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).
  • periodKey ISO 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).
  • periodKey YYYY-MM pro monthly — formato Period existente no shared-kernel. Trivial de derivar do today (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.
  • message PT-BR direto, formatado per ADR 008 — gotcha “message humano 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 LLMDigestDispatcher chama gateway.sendMessage direto com plan.message. Não passa por AgentChat.ask, não consome token LLM, não toca AgentMemory. 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.
  • alerts no DigestPlan é subset reusado, não cópiaDigestService.evaluate pode chamar BudgetAlerts.evaluate({budget, period, today}) internamente e filtrar por relevância (ex: só severity warn+ pro weekly). Alert VO é 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/variance stringly-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 pra Money acontece na write tool quando virar mutação (não há mutação aqui).
  • Result flat com optional fieldsDigestPlan é uma forma só com skip?/budgetSnapshot?/goalsProgress?/alerts? opcionais. Não vira union WeeklyDigest | MonthlyDigest. Consumer diferencia por kind enum + 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: Date parâmetro explícito tanto no DigestService quanto no tick — sem new Date() interno em nenhuma camada. Mesma régua de 019/015/006. Spec constrói datas via new Date("2026-07-26") (UTC); cálculo ISO week interno usa getUTCFullYear/getUTCMonth/getUTCDate. Gotcha “Aritmética de data em UTC, sempre”.
  • Scheduler.schedule recebe cron + callback opaco — 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õe tick({today, kind}) síncrono.
  • MockScheduler.tick ganha overload com kind — 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 Map kind → callback. Quando o kind não importa (cenário 019), tick({today}) continua funcionando sem kind.
  • Goal repository opcional no DI — alguns Households não têm goals ainda (Carlos e Dani do scenario 4). goalRepository? é optional; se ausente, goals vira [] e goalsProgress fica undefined no plan. Mesma postura permissiva do household? no 015.
  • 019 (ReminderDispatcher) — sem touchup. 026 vive lado a lado, não substitui. Compartilham Scheduler + HouseholdLookup + WhatsAppGateway por referência (DI), nunca por código.
  • MockScheduler (test fake de 019) — ganha suporte a kind no tick (overload). Spec 019 continua chamando tick({today}) sem kind — back-compat.
  • Scheduler port (de 019) — sem touchup. Cron string + callback opaco já cobre 026.
  • HouseholdLookup port (wa-001/019) — sem touchup. chatIdsFor reusado.
  • WhatsAppGateway port (wa-002) — sem touchup. sendMessage reusado.
  • 015 (BudgetAlerts) — sem touchup. Alert VO consumido por referência via DigestPlan.alerts opcional.
  • 003 (Budget.varianceReport) — sem touchup. Reusado pelo DigestService no kind=monthly.
  • 002 (Goal.pace/onTrack) — sem touchup. Reusado pelo DigestService no goalsProgress.
  • DigestJournal port pra durabilidade do set de idempotência (mesmo trade-off do ReminderJournal TODO do 019). Promove quando aparecer caso real (deploy/reload entre ticks).
  • Adapter NodeScheduler real wrapping node-cron ou croner — compartilhado com 019. Spec colocada src/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.
  • 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.
  1. Criar POJO DigestPlan + tipo DigestKind em src/contexts/planning/domain/value-objects/DigestPlan.ts.
  2. Criar Domain Service DigestService em src/contexts/planning/domain/services/DigestService.ts com evaluate({budget, goals?, household?, today, kind}).
  3. Estender MockScheduler em src/contexts/agent/infrastructure/MockScheduler.ts pra suportar tick({today, kind?}) (overload).
  4. Criar DigestDispatcher em src/contexts/agent/application/DigestDispatcher.ts com start() registrando 2 jobs + tick({today, kind}).
  5. Atualizar barrels (planning/domain/index.ts, agent/application/index.ts) com os novos exports.
  6. Passar os 5 scenarios. Adapter real NodeScheduler + DigestJournal ficam deferidos.