015 — Alerts proativos do orçamento (nearing-threshold, overspend, goal-off-pace)
A gente abre o app e vê um painel: “Mercado 87% gasto (R$ 1.305 / R$ 1.500 esperado, faltam 12 dias do mês)”, “Energia projetada acima do esperado baseado em consumo até hoje”, “Meta Amsterdam: pace 60% do necessário, fora do ritmo”. Sem clicar em nada. Os alertas saem do estado atual do orçamento e da meta — recalculam toda vez que a gente consulta, sem fila, sem histórico, sem snapshot.
O domínio entrega uma lista de Alert[]. Quem renderiza (UX), quem dispara push/email (cenário futuro), e quem persiste “alert lido” (cenário futuro) ficam fora — esse cenário só decide quando algo merece virar alerta e como ele é descrito.
Domain Service BudgetAlerts.evaluate({ budget, period, today, goal?, household?, thresholds? }) mora em src/contexts/planning/domain/, mesmo padrão do FeasibilityCheck (capability-named, sem aggregate root, cross-context read-only).
Thresholds default são 80% warn e 100% critical — hard-coded como número mágico documentado (mesma postura do 0.8 do FeasibilityCheck). Override opcional via parâmetro pro caller que quiser mais conservador (“me avisa em 70%, não 80%”).
Scenario: Categoria passou o threshold de warn no meio do mês
Section titled “Scenario: Categoria passou o threshold de warn no meio do mês”- Given um orçamento da casa com Mercado (expected R$ 1.500), Aluguel (expected R$ 2.500) e Energia (expected R$ 200)
- And estamos em 18/06/2026 (12 dias pra acabar o mês)
- And o casal registrou
Mercado.recordSpend(junho/2026, R$ 1.305)— 87% do orçado - When a gente avalia
BudgetAlerts.evaluate({ budget, period: junho/2026, today: 18/06/2026 }) - Then retorna 1 Alert com
kind = "nearing-threshold",severity = "warn",expense = Mercado,amount = R$ 1.305,threshold = R$ 1.200(80% de R$ 1.500), emessagemencionando “87%” e “Mercado” - And Aluguel e Energia (sem record nesse mês) não geram alerta — sem fato, sem alerta (gotcha planning view do 003)
Scenario: Categoria estourou o orçado (overspend crítico)
Section titled “Scenario: Categoria estourou o orçado (overspend crítico)”- Given o mesmo orçamento da casa
- And estamos no fim de junho/2026 (30/06/2026)
- And o casal registrou
Mercado.recordSpend(junho/2026, R$ 1.820)— estourou R$ 320 do expected - When a gente avalia
BudgetAlerts.evaluate({ budget, period: junho/2026, today: 30/06/2026 }) - Then retorna 1 Alert com
kind = "overspend",severity = "critical",expense = Mercado,amount = R$ 1.820,threshold = R$ 1.500(o expected, 100%), emessagemencionando “estourou” e o overspend de R$ 320 - And o alert reaproveita a variance que o
Budget.varianceReport(junho/2026)já calcula — sem cache, sem campo armazenado (gotcha “métricas derivadas, não armazenadas”)
Scenario: Meta fora do pace (cross-context com Goal)
Section titled “Scenario: Meta fora do pace (cross-context com Goal)”- Given um orçamento da casa típico (sem nenhum overspend)
- And a meta “Amsterdam Setembro/2026” target €5.000 em 3 meses (
requiredMonthly = €1.666,67) - And estamos em 01/08/2026 (2 meses elapsed; faltam 1 mês)
- And Gabriel aportou €500 e esposa aportou €500 → pace = €500/mês, bem abaixo do requerido
- When a gente avalia
BudgetAlerts.evaluate({ budget, period: agosto/2026, today: 01/08/2026, goal }) - Then o resultado contém 1 Alert com
kind = "goal-off-pace",severity = "warn",expense = undefined(não é alerta de despesa),amount = €500(pace atual),threshold = €1.666,67(requiredMonthly), emessagemencionando “Amsterdam” e “fora do ritmo” - And alerta de goal é independente dos alertas de orçamento — não precisa ter overspend pra disparar
Scenario: Threshold customizado (override) dispara mais cedo
Section titled “Scenario: Threshold customizado (override) dispara mais cedo”- Given o mesmo orçamento da casa com Mercado (expected R$ 1.500)
- And estamos em 12/06/2026
- And o casal registrou
Mercado.recordSpend(junho/2026, R$ 1.080)— 72% do orçado (não dispara o default de 80%) - When a gente avalia
BudgetAlerts.evaluate({ budget, period: junho/2026, today: 12/06/2026, thresholds: { warn: 0.7, critical: 1.0 } }) - Then retorna 1 Alert
kind = "nearing-threshold",severity = "warn",threshold = R$ 1.050(70% de R$ 1.500) - And chamar de novo sem override (defaults 80% / 100%) não retorna alerta — mesma entrada, threshold diferente, decisão diferente
Modelo
Section titled “Modelo”- Context existente —
planning/(capability-named, sem aggregate root próprio, igual cenário 006). Não introduz aggregate root nem entity nova — só Domain Service + VO de resultado. - Domain Service —
BudgetAlerts.evaluate({ ... })stateless, todos os métodosstatic. LêBudget(obrigatório),Goal(opcional),Household(opcional, pra futuros alertas de renda). Sem mutação. - VO
Alert—kind,severity,expense?,amount,threshold,message. Imutável. - AlertKind / AlertSeverity — union types ou enums; sem comportamento, só nominal.
- Sem persistência — alerta é derivado. Cada chamada recomputa a partir do estado atual. Notificação (push, email, dedup) fica pra outro cenário; aqui só o domain service.
type AlertKind = | "overspend" // gasto real > expected | "nearing-threshold" // gasto real >= warn% do expected, mas < 100% | "projected-overspend" // (reservado; cenário futuro: extrapolação por dia do mês) | "goal-off-pace"; // goal.pace < goal.requiredMonthly
type AlertSeverity = "info" | "warn" | "critical";
interface Alert { kind: AlertKind; severity: AlertSeverity; expense?: RecurringExpense; // ausente quando kind = "goal-off-pace" amount: Money; // valor atual observado (gasto, pace, ...) threshold: Money; // valor de referência (expected*warn%, expected, requiredMonthly) message: string; // texto humano pra UX renderizar direto}
// src/contexts/planning/domain/BudgetAlerts.tsBudgetAlerts.evaluate({ budget: Budget, period: Period, today: Date, goal?: Goal, household?: Household, thresholds?: { warn: number; critical: number },}): Alert[]Regras de classificação (defaults warn = 0.8, critical = 1.0):
- Para cada
expenseembudget.expensescomexpense.actualFor(period)definido:ratio = actual / expectedratio >= critical→Alert { kind: "overspend", severity: "critical", threshold: expected }warn <= ratio < critical→Alert { kind: "nearing-threshold", severity: "warn", threshold: expected * warn }ratio < warn→ sem alerta
- Se
goalfoi passado:pace = goal.pace(today),required = goal.requiredMonthly(today)pace < required && !goal.isReached()→Alert { kind: "goal-off-pace", severity: "warn", amount: pace, threshold: required }- (multi-currency entre goal e budget fica fora deste cenário — caller que monitore só goals na mesma currency, ou aceite alerta sem comparação numérica entre eles)
Decisões de design (UX-driven)
Section titled “Decisões de design (UX-driven)”- Alertas são derivados, não armazenados (mesmo padrão de
pace/forecast/varianceReport). Cada chamada recomputa. Notificações persistidas (push lido / dismissed) viram outro cenário — provavelmente emnotifications/ouagent/, não emplanning/. - Threshold mágico (80% / 100%) documentado — gotcha do AGENTS.md aplica. Não vira VO
AlertThresholdenquanto não aparecer demanda real (“queremos warn em 70% pra Mercado, 90% pra Streaming”). Override opcional via parâmetro já cobre quem quer experimentar — promoção a VO/config por categoria só quando custar deixar fixo. expense?opcional, não dois subtipos —Alerté uma forma só.expenseausente significa “alerta global do casal” (goal, futuramente renda). Alternativa de dois VOs (ExpenseAlert+GoalAlert) foi rejeitada porque UX vai renderizar a lista flat em um painel único — diferenciar porkindno consumer é mais barato que dois loops.amount+thresholdambosMoney, não percentage — guarda os valores brutos pra UX formatar como quiser (“87%”, “R$ 1.305 / R$ 1.500”, “faltam R$ 195”). Percentage é derivada barata; truncar pra string no domínio reduz reuso.messageé PT humano, no domínio — incomum (i18n geralmente é UX), mas o casal é monolíngue PT e o agente conversacional (cenário 008) já vai consumir omessagepra falar no chat. Quando aparecer i18n real, vira chave + params.- Cross-context read-only via parâmetro opcional —
goalehouseholdchegam como argumento, não via repositório (planning não tem repo). Caller (futuroAlertsToolno agente, ouapp/api/alerts/route.ts) busca os aggregates e injeta. Mesmo padrão deFeasibilityCheck. today: Dateparâmetro explícito — semnew Date()interno. Mesma razão doGoal.pace(today)eFeasibilityCheck.evaluate({today}): domínio puro, testável, sem clock implícito.- Sem alerta em planning view — expense sem
actualFor(period)não dispara nada. Alerta significa “olha esse fato”, e na ausência de fato não há nada pra observar (gotcha do 003: planning view ≠ overspend zerado). - Reservado
projected-overspend—kindprevisto na união mas não implementado neste cenário. Futuro: extrapolaractual / dias_decorridos * dias_no_mes, comparar comexpected. Documentado pra UX já reservar o nome sem confundir comnearing-threshold(que olha só o presente). - Order do array não é contrato — domínio devolve
Alert[], UX que ordene se quiser (severity desc, recente primeiro, etc). Não promoverAlertListenquanto não aparecer regra de ordenação no domínio.
Fora de escopo
Section titled “Fora de escopo”- Notificação real (push, email, in-app toast) — adapter / cenário futuro.
BudgetAlertssó identifica; entrega é responsabilidade de outro context. - Persistência de “alert lido” / dismissed — estado de leitura é fact do usuário, não do orçamento. Outro cenário, provavelmente outro context.
- Dedup / rate-limit de alertas repetidos — mesma chamada duas vezes devolve a mesma lista (é puro). Quem manda push decide se vai re-disparar.
projected-overspend— reservado no enum, sem cenário ainda. Quando aparecer “projeta consumo de Energia baseado em dias decorridos”, abre cenário próprio.- Alerts cruzando household income (ex: “expenses > 80% da renda neste mês”) —
household?está no parâmetro mas nenhum scenario aqui exercita. Vira cenário próprio quando o casal pedir. - Multi-goal alerts — uma
goal?opcional só. Se aparecer “alerte qualquer das minhas 4 metas fora do pace”, viraevaluate({ goals: Goal[] })ou um service separado. - Categoria com threshold próprio (“Mercado em 70%, Streaming em 95%”) — override hoje é global. Per-category vira VO
AlertThresholdindexado porCategoryNamequando aparecer. - Histórico de alertas (“nas últimas 3 vezes você estourou Mercado em junho”) — analytics, fora.
Próximo passo
Section titled “Próximo passo”Criar src/contexts/planning/domain/value-objects/Alert.ts (tipos + factory simples) e src/contexts/planning/domain/BudgetAlerts.ts (service stateless) até os 4 scenarios passarem. Sem mutação em Budget/Goal/Household — só leitura.