Skip to content

006 — Viabilidade da meta (cross-context: Household + Budget + Goal)

A pergunta-cabeça do produto: “Conseguimos comprar Amsterdam em 3 meses?” O sistema cruza três coisas que já modelamos em contexts separados:

  • renda mensal do household (cenário 005)
  • despesas mensais orçadas (expected, cenários 000/003)
  • aporte mensal necessário pra fechar a meta no prazo (requiredMonthly, cenário 002)

Calcula o sobra (surplus = income - expenses) e compara com o necessário (requiredMonthly). Quando a meta está numa moeda diferente do household, recebe uma ExchangeRate pra converter — câmbio nunca é decidido pelo domínio, sempre injetado.

A resposta vem em quatro status:

  • feasible — sobra ≥ necessário. Cabe folgado, gap = zero.
  • tight — sobra < necessário, mas sobra ≥ 80% do necessário. Aperta, mas dá pra tentar. gap = o quanto falta.
  • infeasible — sobra < 80% do necessário. Não cabe sem mexer em renda ou despesas. gap = o quanto falta.
  • indeterminate — meta numa moeda diferente do household e nenhum ExchangeRate foi passado. Domínio não inventa câmbio; devolve indeterminate e a UX resolve (pede taxa do dia, sugere converter, etc).

Scenario: Meta tight — sobra dá quase pra meta, mas falta 10%

Section titled “Scenario: Meta tight — sobra dá quase pra meta, mas falta 10%”
  • Given o household “Casa” em BRL com Gabriel (R$ 8.000) e esposa (R$ 6.000), monthlyIncome R$ 14.000
  • And um Budget da casa com despesas expected somando R$ 5.000
  • And a meta “Amsterdam Setembro/2026” target €6.000, startedOn 01/06/2026, deadline 01/09/2026 → requiredMonthly €2.000
  • And uma ExchangeRate EUR→BRL = 5,0
  • When a gente avalia a viabilidade em 01/06/2026
  • Then o surplus é R$ 9.000 (income R$ 14.000 − expenses R$ 5.000)
  • And o requiredMonthly convertido pra BRL é R$ 10.000 (€2.000 × 5,0)
  • And o status é tight (R$ 9.000 < R$ 10.000, mas R$ 9.000 ≥ 0,8 × R$ 10.000)
  • And o gap é R$ 1.000

Scenario: Meta confortavelmente viável — mesma moeda, sobra >> necessário

Section titled “Scenario: Meta confortavelmente viável — mesma moeda, sobra >> necessário”
  • Given o household “Casa” em BRL com Gabriel (R$ 8.000) e esposa (R$ 6.000), monthlyIncome R$ 14.000
  • And um Budget da casa com despesas expected somando R$ 5.000
  • And a meta “Reserva emergência” target R$ 36.000, startedOn 01/06/2026, deadline 01/06/2027 → requiredMonthly R$ 3.000
  • When a gente avalia a viabilidade em 01/06/2026 (sem rate, mesma moeda)
  • Then o surplus é R$ 9.000
  • And o requiredMonthly é R$ 3.000 (sem conversão necessária)
  • And o status é feasible (R$ 9.000 ≥ R$ 3.000)
  • And o gap é Money.zero("BRL") (não falta nada)

Scenario: Meta inviável — sobra magrinha não cobre nem 80% do necessário

Section titled “Scenario: Meta inviável — sobra magrinha não cobre nem 80% do necessário”
  • Given um household “Casa Apertada” em BRL com renda total R$ 4.000
  • And um Budget da casa com despesas expected somando R$ 3.500
  • And a meta “Amsterdam Setembro/2026” target €6.000, deadline 01/09/2026 → requiredMonthly €2.000
  • And uma ExchangeRate EUR→BRL = 5,0
  • When a gente avalia a viabilidade em 01/06/2026
  • Then o surplus é R$ 500
  • And o requiredMonthly convertido é R$ 10.000
  • And o status é infeasible (R$ 500 < 0,8 × R$ 10.000 = R$ 8.000)
  • And o gap é R$ 9.500

Scenario: Currencies diferentes sem rate = indeterminate

Section titled “Scenario: Currencies diferentes sem rate = indeterminate”
  • Given o household “Casa” em BRL com monthlyIncome R$ 14.000
  • And um Budget da casa em BRL com expected somando R$ 5.000
  • And a meta “Amsterdam Setembro/2026” target em EUR (€6.000), deadline 01/09/2026
  • When a gente avalia a viabilidade em 01/06/2026 sem passar ExchangeRate
  • Then o status é indeterminate
  • And o surplus ainda vem calculado (R$ 9.000, na currency do household)
  • And o requiredMonthly vem na currency original da meta (€2.000) — não foi convertido
  • And o gap é undefined (não dá pra comparar moedas diferentes)

Scenario: Mesma moeda do household, rate desnecessário

Section titled “Scenario: Mesma moeda do household, rate desnecessário”
  • Given o household “Casa” em BRL com monthlyIncome R$ 14.000
  • And um Budget da casa com expected somando R$ 5.000
  • And a meta “Carro novo” target R$ 72.000, startedOn 01/06/2026, deadline 01/12/2027 → requiredMonthly R$ 4.000
  • When a gente avalia a viabilidade em 01/06/2026 sem passar rate (mesma moeda)
  • Then o surplus é R$ 9.000
  • And o requiredMonthly é R$ 4.000
  • And o status é feasible
  • And o gap é Money.zero("BRL")
  • Context novoplanning/ (sem aggregate root; só Domain Services de leitura cross-context).
  • Domain ServiceFeasibilityCheck (stateless, todos os métodos static). Lê três aggregates (Goal, Household, Budget) sem mutar nenhum.
  • VO novo no shared-kernelExchangeRate (from, to, rate, convert(Money) → Money). Direcional, sem inversa, sem cross-rate. Adapter externo entrega a taxa certa.
  • ResultadoFeasibilityResult (record/interface): status, surplus, requiredMonthly, gap?.
shared-kernel/ExchangeRate.ts
ExchangeRate.of({ from: string, to: string, rate: number }): ExchangeRate
exchangeRate.from: string
exchangeRate.to: string
exchangeRate.rate: number
exchangeRate.convert(money: Money): Money
// assume money.currency === this.from; retorna Money em this.to
// não inverte, não cross-rate — adapter entrega o rate certo
// contexts/planning/domain/FeasibilityCheck.ts
FeasibilityCheck.evaluate({
goal: Goal,
household: Household,
budget: Budget,
today: Date,
rate?: ExchangeRate,
}): FeasibilityResult
interface FeasibilityResult {
status: "feasible" | "tight" | "infeasible" | "indeterminate";
surplus: Money; // sempre em household.currency
requiredMonthly: Money; // convertido pra household.currency se rate provido; senão na currency do goal
gap?: Money; // requiredMonthly - surplus (positivo = falta); ausente quando indeterminate;
// Money.zero(currency) quando feasible
}

Regra de classificação:

  • feasiblesurplus.amount >= requiredMonthlyBRL.amount
  • tightsurplus.amount < requiredMonthlyBRL.amount && surplus.amount >= 0.8 * requiredMonthlyBRL.amount
  • infeasiblesurplus.amount < 0.8 * requiredMonthlyBRL.amount
  • indeterminategoal.target.currency !== household.currency && rate === undefined (curto-circuita antes de classificar)

expenses expected = soma de expense.expected de todos os RecurringExpense no Budget. Não usa actual — feasibility é planning (futuro), não retrospective.

  • Context planning/ sem aggregate root — quebra a convenção de “aggregate root dá nome ao contexto” (registrada em AGENTS.md). Mas planning é um conceito de negócio coeso (“planejamento financeiro do casal”), e os Domain Services aqui são read-only/cross-aggregate por natureza. Alternativa considerada: src/application/planning/. Rejeitada porque as regras (threshold de 80%, definição de surplus, classificação) são regras de domínio — moedas, ritmos, viabilidade vivem na linguagem do casal. Application layer é orquestração de I/O; aqui não tem I/O. Quando aparecer o primeiro use case real (UI chamando /api/feasibility), aí sim entra application/planning/ orquestrando FeasibilityCheck. Por enquanto: Domain Service em contexts/planning/domain/. Convenção atualizada: contextos podem ser nomeados por capability quando não tiverem aggregate root próprio.
  • FeasibilityCheck é stateless (Domain Service clássico) — não tem identidade, não muta nada. Recebe os três aggregates como input, devolve um result. Não fica “dentro” de nenhum aggregate porque (a) nenhum dos três deveria conhecer os outros dois, e (b) a regra cruza os três simétricamente — não é “comportamento do Goal” nem “do Household”.
  • ExchangeRate é VO no shared-kernel — imutável, direcional (fromto), fornecida pelo caller. Domínio nunca busca câmbio. Adapter externo (cenário futuro) chama API e entrega ExchangeRate.of({ from: "EUR", to: "BRL", rate: 5.0 }). Sem inversa, sem cross-rate: se precisar de BRL→EUR, o caller pede outro objeto pro adapter. Mantém o VO burro e o câmbio explícito.
  • Threshold de “tight” = 80% hard-coded — saiu do nada, não validado com usuário. Não vira VO FeasibilityThreshold enquanto não aparecer caso real (ex: “queremos status verde só se sobrar 10%, não 0%”). Quando aparecer, vira VO ou config no método. Por enquanto: número mágico documentado.
  • expected, não actual — feasibility é pergunta sobre o futuro. actual é pergunta sobre o passado (cenário 003). Se um dia aparecer “viabilidade realista baseada em histórico” (“você sempre estoura 10%, então o expected real é 10% maior”), vira RealisticFeasibilityCheck ou recebe um modificador. Hoje: planning view pura.
  • gap ausente quando indeterminate, Money.zero quando feasible — escolha consciente. undefined quando o tipo da resposta não existe (não dá pra comparar moedas), Money.zero quando a resposta existe e é “sem gap”. Evita misturar dois “vazios” diferentes. UX que renderiza o gap precisa de um único check (if (result.gap) ... cobre tanto feasible-com-zero quanto indeterminate-com-undefined? Não — zero é truthy. UX checa result.status === "indeterminate" pra decidir mostrar nada vs mostrar “R$ 0”).
  • requiredMonthly no result fica na currency convertida (se rate provido) — facilita a UX, que renderiza surplus e requiredMonthly lado a lado. Quando indeterminate, requiredMonthly volta na currency original da meta — sinaliza pra UX que conversão não rolou, ela vê as duas moedas e pede o câmbio.
  • surplus é sempre na currency do household — income e expenses já estão na mesma currency (single-currency household, cenário 005). Não precisa rate pra calcular surplus, só pra comparar com a meta.
  • today é parâmetro explícitoFeasibilityCheck.evaluate recebe today: Date em vez de chamar new Date() internamente. Mesma razão de Goal.pace(today): domínio puro, testável, sem clock implícito.
  • Avalia uma meta por vez — multi-goal (“qual meta cabe primeiro?”) é outro cenário. Hoje a pergunta é “essa meta cabe?”.
  • Multi-goal feasibility / priorização (“dada minha renda, em que ordem essas três metas cabem?”).
  • Sensitivity analysis (“e se a renda cair 10%?”, “e se o câmbio piorar?”).
  • Variance baseada em histórico (“você estoura R$ 200/mês no mercado, então o expected real é maior”).
  • Soma de goals em currencies diferentes (multi-currency feasibility consolidada).
  • Sugestões automáticas (“corte R$ 1.000 em comida e a meta vira feasible”).
  • Payment plan generation (“aporta R$ X em jun, R$ Y em jul…”).
  • Confidence intervals (“70% de chance de bater dado o ritmo histórico”).
  • Cache do resultado — recalcula a cada chamada, igual pace/forecast/total.
  • Adapter de câmbio — quem entrega a ExchangeRate (API, fixture, banco) vive na infraestrutura, fora deste cenário.

Criar ExchangeRate no shared-kernel + context planning/ com FeasibilityCheck.evaluate até os 5 scenarios passarem. Sem mutação em Goal/Household/Budget — só leitura.