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 nenhumExchangeRatefoi 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),
monthlyIncomeR$ 14.000 - And um Budget da casa com despesas
expectedsomando 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
ExchangeRateEUR→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
requiredMonthlyconvertido 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),
monthlyIncomeR$ 14.000 - And um Budget da casa com despesas
expectedsomando R$ 5.000 - And a meta “Reserva emergência” target R$ 36.000, startedOn 01/06/2026, deadline 01/06/2027 →
requiredMonthlyR$ 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
expectedsomando R$ 3.500 - And a meta “Amsterdam Setembro/2026” target €6.000, deadline 01/09/2026 →
requiredMonthly€2.000 - And uma
ExchangeRateEUR→BRL = 5,0 - When a gente avalia a viabilidade em 01/06/2026
- Then o
surplusé R$ 500 - And o
requiredMonthlyconvertido é 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
monthlyIncomeR$ 14.000 - And um Budget da casa em BRL com
expectedsomando 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
surplusainda vem calculado (R$ 9.000, na currency do household) - And o
requiredMonthlyvem 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
monthlyIncomeR$ 14.000 - And um Budget da casa com
expectedsomando R$ 5.000 - And a meta “Carro novo” target R$ 72.000, startedOn 01/06/2026, deadline 01/12/2027 →
requiredMonthlyR$ 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")
Modelo
Section titled “Modelo”- Context novo —
planning/(sem aggregate root; só Domain Services de leitura cross-context). - Domain Service —
FeasibilityCheck(stateless, todos os métodosstatic). Lê três aggregates (Goal,Household,Budget) sem mutar nenhum. - VO novo no shared-kernel —
ExchangeRate(from,to,rate,convert(Money) → Money). Direcional, sem inversa, sem cross-rate. Adapter externo entrega a taxa certa. - Resultado —
FeasibilityResult(record/interface):status,surplus,requiredMonthly,gap?.
ExchangeRate.of({ from: string, to: string, rate: number }): ExchangeRateexchangeRate.from: stringexchangeRate.to: stringexchangeRate.rate: numberexchangeRate.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.tsFeasibilityCheck.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:
feasible→surplus.amount >= requiredMonthlyBRL.amounttight→surplus.amount < requiredMonthlyBRL.amount&&surplus.amount >= 0.8 * requiredMonthlyBRL.amountinfeasible→surplus.amount < 0.8 * requiredMonthlyBRL.amountindeterminate→goal.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.
Decisões de design
Section titled “Decisões de design”- 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 entraapplication/planning/orquestrandoFeasibilityCheck. Por enquanto: Domain Service emcontexts/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 (from→to), fornecida pelo caller. Domínio nunca busca câmbio. Adapter externo (cenário futuro) chama API e entregaExchangeRate.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
FeasibilityThresholdenquanto 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ãoactual— 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”), viraRealisticFeasibilityCheckou recebe um modificador. Hoje: planning view pura.gapausente quando indeterminate,Money.zeroquando feasible — escolha consciente.undefinedquando o tipo da resposta não existe (não dá pra comparar moedas),Money.zeroquando 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 checaresult.status === "indeterminate"pra decidir mostrar nada vs mostrar “R$ 0”).requiredMonthlyno result fica na currency convertida (se rate provido) — facilita a UX, que renderizasurpluserequiredMonthlylado a lado. Quandoindeterminate, 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ícito —FeasibilityCheck.evaluaterecebetoday: Dateem vez de chamarnew Date()internamente. Mesma razão deGoal.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?”.
Fora de escopo
Section titled “Fora de escopo”- 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.
Próximo passo
Section titled “Próximo passo”Criar ExchangeRate no shared-kernel + context planning/ com FeasibilityCheck.evaluate até os 5 scenarios passarem. Sem mutação em Goal/Household/Budget — só leitura.