Skip to content

014 — Receita variável do casal (freelance, bonus, vendas) sobre o income fixo do Household

O salário fixo do casal já mora no Household via assignIncome (cenário 005). Mas a vida real tem renda extra: Gabriel faz freelance UI design de vez em quando, a esposa vende crochê no Instagram. Essas receitas não são mensais previsíveis, são fatos pontuais com data, valor, member e fonte.

A gente quer:

  • registrar essas receitas conforme caem (“R$ 2.500 de freelance em 15/09”);
  • ver, pra um mês específico, quanto foi fixo + quanto foi variável, com breakdown por member;
  • pra metas no futuro (cenário 006), projetar o income usando a média de variável dos últimos N meses, em vez de fingir que mês que vem é só salário.

O monthlyIncome() original (cenário 005) continua válido — só ganha um overload com period opcional. Sem argumento, retorna fixo + média de variável (lookback N meses, default 3). Com period, retorna fixo + variável real daquele mês.

Scenario: Registrar freelances de 3 meses e consultar income por período

Section titled “Scenario: Registrar freelances de 3 meses e consultar income por período”
  • Given o household “Casa” em BRL com Gabriel (fixo R$ 8.000) e esposa (fixo R$ 6.000) — monthlyIncome() sem argumento seria R$ 14.000 antes de qualquer variável
  • When a gente registra:
    • Gabriel: R$ 2.500 em 15/07/2026, source “freelance UI design”
    • Gabriel: R$ 1.500 em 10/08/2026, source “freelance UI design”
    • esposa: R$ 800 em 22/08/2026, source “venda crochê”
    • Gabriel: R$ 3.000 em 20/09/2026, source “bonus consultoria”
  • Then monthlyIncome(julho/2026) é R$ 16.500 (R$ 14.000 fixo + R$ 2.500 variável do mês)
  • And monthlyIncome(agosto/2026) é R$ 16.300 (R$ 14.000 fixo + R$ 2.300 variável: R$ 1.500 Gabriel + R$ 800 esposa)
  • And monthlyIncome(setembro/2026) é R$ 17.000 (R$ 14.000 fixo + R$ 3.000 variável)
  • And extraIncomeBy(gabriel, agosto/2026) é R$ 1.500
  • And extraIncomeBy(esposa, agosto/2026) é R$ 800
  • And extraIncomeBy(esposa, setembro/2026) é R$ 0 (esposa não vendeu nada em setembro)

Scenario: Breakdown fixo vs variável por member num período

Section titled “Scenario: Breakdown fixo vs variável por member num período”
  • Given o household “Casa” em BRL com Gabriel (fixo R$ 8.000) e esposa (fixo R$ 6.000)
  • And os mesmos lançamentos de variável do cenário acima
  • When a gente chama incomeBreakdown(agosto/2026)
  • Then o fixed total é R$ 14.000
  • And o variable total é R$ 2.300 (R$ 1.500 + R$ 800)
  • And byMember.get(gabriel) é { fixed: R$ 8.000, variable: R$ 1.500 }
  • And byMember.get(esposa) é { fixed: R$ 6.000, variable: R$ 800 }

Scenario: Forecast com média móvel dos últimos N meses

Section titled “Scenario: Forecast com média móvel dos últimos N meses”
  • Given o household “Casa” em BRL com Gabriel (fixo R$ 8.000) e esposa (fixo R$ 6.000)
  • And Gabriel registrou R$ 2.500 (julho), R$ 1.500 (agosto), R$ 3.000 (setembro) de freelance/bonus
  • And esposa registrou R$ 800 (agosto) de venda
  • When a gente chama forecastIncome(outubro/2026) com lookback default (3 meses: julho, agosto, setembro)
  • Then o resultado é R$ 16.600 (R$ 14.000 fixo + média variável dos últimos 3 meses = (R$ 2.500 + R$ 2.300 + R$ 3.000) / 3 = R$ 2.600)
  • And forecastIncome(outubro/2026, 1) (lookback 1 mês = só setembro) é R$ 17.000 (R$ 14.000 + R$ 3.000)
  • And monthlyIncome() (sem period — usa lookback default a partir do mês corrente) inclui a média variável, não fica fixo zero
  • And o forecastIncome recalcula a cada chamada (métrica derivada, não armazenada)

Scenario: Período sem nenhuma variável volta a ser só o fixo

Section titled “Scenario: Período sem nenhuma variável volta a ser só o fixo”
  • Given o household “Casa” em BRL com Gabriel (fixo R$ 8.000) e esposa (fixo R$ 6.000), nenhum lançamento de variável registrado
  • When a gente consulta monthlyIncome(novembro/2026)
  • Then o resultado é R$ 14.000 (só o fixo — sem variável no mês)
  • And incomeBreakdown(novembro/2026).variable é Money.zero("BRL") (vazio explícito, não undefined)
  • And forecastIncome(dezembro/2026) também é R$ 14.000 (média de 3 meses zerados = zero)
  • And extraIncomeBy(gabriel, novembro/2026) é Money.zero("BRL")
  • Contexto existentehousehold/ (sem novo aggregate). Variabilidade vive dentro do Household, mesma identidade — coleção interna Map<periodKey, IncomeEntry[]> (similar ao Map<periodKey, Money> de RecurringExpense.actuals no cenário 003).
  • VO novoIncomeEntry em household/domain/value-objects/. Shape { member: Member, amount: Money, date: Date, source: string }. source é string livre por enquanto — quando aparecer necessidade de categorização (“é freelance ou bonus?”), promove pra IncomeSource VO/enum. Hoje: free text.
  • Métricas derivadasmonthlyIncome(period?), incomeBreakdown(period), forecastIncome(period, lookback?) recalculam a cada chamada. Storage só do fato (a entrada IncomeEntry), igual Goal.contributions, RecurringExpense.actuals, Invoice.transactions.
  • Lookback default = 3 meses — número escolhido por feeling (“trimestre é o horizonte natural pra freelancer”). Hard-coded até aparecer caso pra promover a VO/parâmetro de config (mesmo padrão do threshold 0.8 do FeasibilityCheck no cenário 006).
src/contexts/household/domain/value-objects/IncomeEntry.ts
IncomeEntry.of({
member: Member,
amount: Money,
date: Date,
source?: string, // default "" — free text, sem enum por enquanto
}): IncomeEntry
incomeEntry.member: Member
incomeEntry.amount: Money
incomeEntry.date: Date
incomeEntry.source: string
incomeEntry.fallsIn(period: Period): boolean
// true se entry.date está no period (year+month). Aritmética UTC (gotcha AGENTS.md).
// src/contexts/household/domain/Household.ts (estende cenário 005)
household.recordExtraIncome(args: {
member: Member;
amount: Money;
date: Date;
source?: string;
}): void
// adiciona uma IncomeEntry à coleção interna; addMember automático se ainda não estiver.
household.extraIncomeBy(member: Member, period: Period): Money
// soma de IncomeEntry.amount onde entry.member.equals(member) && entry.fallsIn(period).
// retorna Money.zero(currency) se nada encontrado — nunca undefined (diferente de incomeBy
// fixo, que distingue "sem atribuição" de "zero atribuído").
household.monthlyIncome(period?: Period): Money
// overload:
// - sem period: fixo + média de variável dos últimos N meses (lookback default = 3)
// contados a partir do mês corrente em UTC
// - com period: fixo + extraIncomeBy(*, period) somado entre todos os members
// continua compatível com cenário 005 (chamada sem argumento ganha média se houver
// variável; sem variável, retorna só o fixo como antes).
household.incomeBreakdown(period: Period): {
fixed: Money;
variable: Money;
byMember: Map<Member, { fixed: Money; variable: Money }>;
}
// breakdown estruturado pra UX renderizar lado a lado.
household.forecastIncome(period: Period, lookbackMonths?: number): Money
// fixo + (soma de extraIncome dos últimos N meses anteriores ao period / N).
// default lookbackMonths = 3. Recalcula a cada chamada — não cachea.
// Períodos sem variável contam como zero na média (não pulam — divisor é fixo em N).
household.extraIncomes(): IncomeEntry[]
// lista plana (read-only) das entries registradas — útil pra spec inspecionar.
  • Sem aggregate novo — receita variável é continuação do Household, não um agregado separado. Mesma identidade (“a Casa”), mesma currency, mesmo conjunto de members. Inverter (criar IncomeBook aggregate) introduziria sincronização entre dois roots por nenhum ganho concreto. Igual a RecurringExpense.actuals no cenário 003: o fato variável mora dentro do dono natural.
  • IncomeEntry é VO, não Entity — receita registrada não muda. Quem registrou errado, registra de novo com recordExtraIncome (sobrescreve por chave natural? por agora não — registra-se como entrada nova, e a leitura soma tudo). Se aparecer caso de “editar/cancelar uma entrada específica”, aí promove pra Entity com id próprio (mesmo trajeto de Member no cenário 005). Hoje: append-only, sem identidade.
  • source é string livre — sem enum, sem IncomeSource VO. O catálogo real (“freelance”, “venda”, “bonus”, “dividendos”, “reembolso”) só aparece quando UX/agente precisar agrupar por categoria. Hoje cada lançamento é texto livre — promove pra VO no primeiro cenário que filtrar por source.
  • monthlyIncome(period?) é overload, mantém back-compat 005 — chamada sem argumento continua válida; só passa a incluir média de variável quando houver entries. Cenário 005 ainda passa: sem recordExtraIncome, a média é zero, e o retorno é só o fixo.
  • extraIncomeBy retorna Money.zero, não undefined — contraste consciente com incomeBy (fixo), que distingue “não atribuído” (undefined) de “atribuído como zero” (Money.zero). Aqui não existe esse estado intermediário: ou tem entries, ou não tem. Money.zero é a resposta natural pra “ele não recebeu variável esse mês”.
  • Aritmética de mês em UTC — gotcha do AGENTS.md aplica. IncomeEntry.fallsIn(period) usa date.getUTCFullYear() + date.getUTCMonth() pra comparar, nunca getMonth() (TZ-local). forecastIncome itera os últimos N meses construindo cada Period via aritmética UTC pura.
  • Lookback default = 3 meses (hard-coded) — mesmo trajeto do threshold 0.8 no FeasibilityCheck: número “razoável” documentado, promove a VO/parâmetro quando aparecer demanda real (“quero forecast só do último mês”, “quero média anual”). Já dá pra customizar via segundo argumento (lookbackMonths), então a flexibilidade está exposta sem precisar de tipo novo.
  • Períodos vazios contam como zero na média, não pulamforecastIncome divide por N fixo. Se nos últimos 3 meses só houve variável em 1, o forecast é (0 + 0 + R$ 3.000) / 3 = R$ 1.000. Trade-off consciente: divisor variável (“média só dos meses com variável”) inflaria forecast em casal que freelanca esporadicamente. Conservador > otimista pra planejamento.
  • Métricas derivadas, recalculam a cada chamadamonthlyIncome, incomeBreakdown, forecastIncome percorrem a coleção. Consistente com Goal.pace, Invoice.total, Budget.actualTotal, RecurringExpense.actualFor. Storage é só do fato (a entry), nunca da agregação.
  • Reuso de Member entity por id — caller cria const gabriel = Member.create({name: "Gabriel"}) uma vez e reusa a mesma instância em assignIncome + recordExtraIncome + queries (extraIncomeBy, incomeBreakdown.byMember.get). Gotcha do cenário 005: duas chamadas Member.create(...) geram ids diferentes e não são iguais.
  • Cenário 005 continua passando sem touchup. monthlyIncome() sem argumento continua válido; sem recordExtraIncome, a média é zero, retorno é o fixo de antes.
  • Cenário 006 (FeasibilityCheck) pode passar a aceitar override pra usar forecastIncome(period, lookback) em vez de monthlyIncome() — útil pra metas longe no futuro onde o histórico de variável muda o veredito (infeasibletight, por exemplo). Esse override não é deste cenário — fica documentado aqui como follow-up natural. Hoje 006 continua chamando monthlyIncome() sem argumento, que agora já entrega fixo + média se houver histórico.
  • Edição/remoção de IncomeEntry — append-only por agora. Quando aparecer “registrei errado, apaga essa entry”, promove IncomeEntry a Entity com id.
  • Categorização de source — string livre por enquanto. Enum IncomeSource ou VO entra quando UX/agente filtrar por categoria.
  • Frequência ≠ pontual — recorrência (freelance mensal fixo de R$ 1.000) cai no assignIncome clássico. Variável aqui é fato pontual com data.
  • Multi-currency em entry — entries herdam a currency do Household (single-currency, ADR 005). Receita em currency diferente entra com cenário próprio quando aparecer o caso (mesma postura de cenário 002 — domínio não converte).
  • Taxes / income líquido vs bruto — todos os valores são líquidos por convenção do casal. IR sobre freelance entra com cenário próprio se virar dor.
  • Forecast com modelos mais sofisticados — média móvel simples é o ponto de partida. Mediana, weighted decay, trend extrapolation só aparecem com demanda concreta.
  • Histórico de income fixo (reajustes)assignIncome continua sobrescrevendo (cenário 005). Cenário próprio se aparecer “quanto a gente ganhou ao longo do tempo”.
  • Override do FeasibilityCheck pra usar forecastIncome — esboçado em “Impacto em outros cenários”, mas não exercitado aqui. Fica pra cenário futuro de planning ou touchup do 006.

Criar IncomeEntry em household/domain/value-objects/, estender Household com recordExtraIncome + extraIncomeBy + incomeBreakdown + forecastIncome + overload de monthlyIncome(period?) até os 4 scenarios passarem. Garantir aritmética UTC em IncomeEntry.fallsIn e no loop de forecastIncome.