Skip to content

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), e message mencionando “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%), e message mencionando “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), e message mencionando “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
  • Context existenteplanning/ (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 ServiceBudgetAlerts.evaluate({ ... }) stateless, todos os métodos static. Lê Budget (obrigatório), Goal (opcional), Household (opcional, pra futuros alertas de renda). Sem mutação.
  • VO Alertkind, 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.
src/contexts/planning/domain/value-objects/Alert.ts
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.ts
BudgetAlerts.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 expense em budget.expenses com expense.actualFor(period) definido:
    • ratio = actual / expected
    • ratio >= criticalAlert { kind: "overspend", severity: "critical", threshold: expected }
    • warn <= ratio < criticalAlert { kind: "nearing-threshold", severity: "warn", threshold: expected * warn }
    • ratio < warn → sem alerta
  • Se goal foi 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)
  • 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 em notifications/ ou agent/, não em planning/.
  • Threshold mágico (80% / 100%) documentado — gotcha do AGENTS.md aplica. Não vira VO AlertThreshold enquanto 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 subtiposAlert é uma forma só. expense ausente 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 por kind no consumer é mais barato que dois loops.
  • amount + threshold ambos Money, 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 o message pra falar no chat. Quando aparecer i18n real, vira chave + params.
  • Cross-context read-only via parâmetro opcionalgoal e household chegam como argumento, não via repositório (planning não tem repo). Caller (futuro AlertsTool no agente, ou app/api/alerts/route.ts) busca os aggregates e injeta. Mesmo padrão de FeasibilityCheck.
  • today: Date parâmetro explícito — sem new Date() interno. Mesma razão do Goal.pace(today) e FeasibilityCheck.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-overspendkind previsto na união mas não implementado neste cenário. Futuro: extrapolar actual / dias_decorridos * dias_no_mes, comparar com expected. Documentado pra UX já reservar o nome sem confundir com nearing-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 promover AlertList enquanto não aparecer regra de ordenação no domínio.
  • Notificação real (push, email, in-app toast) — adapter / cenário futuro. BudgetAlerts só 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”, vira evaluate({ goals: Goal[] }) ou um service separado.
  • Categoria com threshold próprio (“Mercado em 70%, Streaming em 95%”) — override hoje é global. Per-category vira VO AlertThreshold indexado por CategoryName quando aparecer.
  • Histórico de alertas (“nas últimas 3 vezes você estourou Mercado em junho”) — analytics, fora.

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.