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
fixedtotal é R$ 14.000 - And o
variabletotal é 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
forecastIncomerecalcula 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")
Modelo
Section titled “Modelo”- Contexto existente —
household/(sem novo aggregate). Variabilidade vive dentro doHousehold, mesma identidade — coleção internaMap<periodKey, IncomeEntry[]>(similar aoMap<periodKey, Money>deRecurringExpense.actualsno cenário 003). - VO novo —
IncomeEntryemhousehold/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 praIncomeSourceVO/enum. Hoje: free text. - Métricas derivadas —
monthlyIncome(period?),incomeBreakdown(period),forecastIncome(period, lookback?)recalculam a cada chamada. Storage só do fato (a entradaIncomeEntry), igualGoal.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.8doFeasibilityCheckno cenário 006).
API do aggregate / VO
Section titled “API do aggregate / VO”IncomeEntry.of({ member: Member, amount: Money, date: Date, source?: string, // default "" — free text, sem enum por enquanto}): IncomeEntryincomeEntry.member: MemberincomeEntry.amount: MoneyincomeEntry.date: DateincomeEntry.source: stringincomeEntry.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.Decisões de design
Section titled “Decisões de design”- 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 (criarIncomeBookaggregate) introduziria sincronização entre dois roots por nenhum ganho concreto. Igual aRecurringExpense.actualsno 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 comrecordExtraIncome(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 comidpróprio (mesmo trajeto deMemberno cenário 005). Hoje: append-only, sem identidade.sourceé string livre — sem enum, semIncomeSourceVO. 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: semrecordExtraIncome, a média é zero, e o retorno é só o fixo.extraIncomeByretornaMoney.zero, nãoundefined— contraste consciente comincomeBy(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)usadate.getUTCFullYear()+date.getUTCMonth()pra comparar, nuncagetMonth()(TZ-local).forecastIncomeitera os últimos N meses construindo cadaPeriodvia aritmética UTC pura. - Lookback default = 3 meses (hard-coded) — mesmo trajeto do threshold
0.8noFeasibilityCheck: 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 pulam —
forecastIncomedivide 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 chamada —
monthlyIncome,incomeBreakdown,forecastIncomepercorrem a coleção. Consistente comGoal.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 emassignIncome+recordExtraIncome+ queries (extraIncomeBy,incomeBreakdown.byMember.get). Gotcha do cenário 005: duas chamadasMember.create(...)geram ids diferentes e não são iguais.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 005 continua passando sem touchup.
monthlyIncome()sem argumento continua válido; semrecordExtraIncome, 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 demonthlyIncome()— útil pra metas longe no futuro onde o histórico de variável muda o veredito (infeasible→tight, por exemplo). Esse override não é deste cenário — fica documentado aqui como follow-up natural. Hoje 006 continua chamandomonthlyIncome()sem argumento, que agora já entrega fixo + média se houver histórico.
Fora de escopo
Section titled “Fora de escopo”- Edição/remoção de
IncomeEntry— append-only por agora. Quando aparecer “registrei errado, apaga essa entry”, promoveIncomeEntrya Entity comid. - Categorização de
source— string livre por enquanto. EnumIncomeSourceou VO entra quando UX/agente filtrar por categoria. - Frequência ≠ pontual — recorrência (freelance mensal fixo de R$ 1.000) cai no
assignIncomeclá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) —
assignIncomecontinua 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.
Próximo passo
Section titled “Próximo passo”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.