025 — Freemium gate (Subscription + Entitlement)
CEO doc #9 cravou: freemium, não free puro. Free core funcional limitado, paid R$15–25/mês casal destranca ilimitado. Régua de produto: sem signup pre-paywall — o casal usa, e o gate só aparece quando ele atinge o limite. Quem nunca chega no limite nunca vê o paywall (viralidade preservada). Quem chega vê reason + suggestion empático, decide.
Esse cenário introduz dois conceitos:
Subscription— aggregate root novo, 1:1 comHousehold. Carrega oplan("free" | "paid"), ousagePeriod(mês corrente do plano, baseado noPerioddo shared-kernel) e ousage(counters de mensagens, goals, faturas no mês). Métodos de mutação são counters explícitos (recordMessage,recordGoalCreated,recordFaturaImport),upgrade/downgradepra trocar plano, erolloverIfNeeded(today)pra resetar usage quando o mês vira.Entitlement— Domain Service stateless emplanning/domain/services/. Mesma régua deFeasibilityCheck(006) eBudgetAlerts(015): puro, sem I/O, recebe{subscription, action, today}e devolveEntitlementVerdict. Caller (router/AgentChat/write tool) faz o pre-flight check antes de qualquer side-effect.
O gate mora no caller — não no aggregate. Esse cenário 025 cobre só o domain: Subscription state machine + Entitlement decision. Integração com AgentChat.ask (gate antes do LLM, paralelo ao ConversationGuard do 023) e com write tools (gate antes de repo.save, novo flag gated? no ToolReceipt) ficam descritos abaixo mas não exercitados no spec — entram nos cenários de impl-side quando o router começar a consultar Entitlement.
Limits free são hard-coded como constantes UPPER_SNAKE no módulo (FREE_MESSAGES_PER_MONTH = 100, FREE_GOALS_MAX = 1, FREE_FATURAS_PER_MONTH = 1). Mesma postura do 0.8 do FeasibilityCheck (006), 0.8/1.0 do BudgetAlerts (015), -5 do ReferralAttribution (016) e 200 do gasto rápido (020): número mágico documentado vence override prematuro até aparecer caso real (“casal X paga preset”, “campanha de Natal libera 200 msgs no free”). Quando aparecer, promove a parâmetro/VO — não antes.
Scenario: Subscription nasce free com usage zerado
Section titled “Scenario: Subscription nasce free com usage zerado”- Given um novo Household
hh-ana-bruno(id arbitrário, não tem que existir o aggregate — gate trabalha porhouseholdIdopaco) - When chamamos
Subscription.create({householdId: "hh-ana-bruno"}) - Then
result.plan === "free"(default) - And
result.usage.messagesSent === 0 - And
result.usage.goalsCount === 0 - And
result.usage.faturasImported === 0 - And
result.usagePeriodé oPerioddo mês corrente (nãoundefined) — o casal começa com janela mensal já aberta - And
result.idé uma string estável (UUID default — overload externo aceito pra reconstrução pelo adapter, igualBudget.iddo 007)
Scenario: recordMessage incrementa e Entitlement check passa no boundary do limite
Section titled “Scenario: recordMessage incrementa e Entitlement check passa no boundary do limite”- Given uma
Subscriptionfree comusage.messagesSent = 99(reconstruída viaSubscription.rehydrate(...)pra simular o estado de meio de mês) - And
today = 2026-07-15(mesmo mês dousagePeriod) - When chamamos
subscription.recordMessage()→usage.messagesSentvira100 - And chamamos
Entitlement.check({subscription, action: "send-message", today}) - Then
result.allowed === true(boundary inclusivo:messagesSent <= FREE_MESSAGES_PER_MONTHainda passa) - And o caller (router) pode seguir e invocar o LLM normalmente
Scenario: send-message bloqueado ao exceder o limite free
Section titled “Scenario: send-message bloqueado ao exceder o limite free”- Given uma
Subscriptionfree comusage.messagesSent = 100(já no teto) - And
today = 2026-07-15 - When chamamos
subscription.recordMessage()→usage.messagesSentvira101(ultrapassou) - And chamamos
Entitlement.check({subscription, action: "send-message", today}) - Then
result.allowed === false - And
result.reasonbate em/limite|mensa|atingid/i(texto humano PT-BR pronto pra UX/agent ler) - And
result.suggestionbate em/upgrade/i(oferece path concreto, alinha com ADR 008)
Scenario: Free user cria 2º Goal — bloqueado
Section titled “Scenario: Free user cria 2º Goal — bloqueado”- Given uma
Subscriptionfree comusage.goalsCount = 1(casal já tem a meta de Amsterdam ativa) - And
today = 2026-07-15 - When chamamos
Entitlement.check({subscription, action: "create-goal", today}) - Then
result.allowed === false(free permite só 1 meta ativa por casal) - And
result.reasonbate em/meta|goal|1/i(menciona o limite numérico ou a palavra meta) - And
result.suggestionbate em/upgrade/i
Scenario: Paid plan — Entitlement sempre allowed
Section titled “Scenario: Paid plan — Entitlement sempre allowed”- Given uma
Subscriptioncomplan = "paid"eusagearbitrariamente alto (messagesSent = 5000,goalsCount = 10,faturasImported = 20) - And
today = 2026-07-15 - When chamamos
Entitlement.checkpras três actions:{subscription, action: "send-message", today}{subscription, action: "create-goal", today}{subscription, action: "import-fatura", today}
- Then todos os três
result.allowed === true(paid não tem teto — counters seguem acumulando pra eventual telemetria, mas o gate sempre passa) - And nenhum
reason/suggestioné retornado (verdict é{ allowed: true }puro)
Scenario: Rollover de mês reseta usage idempotentemente
Section titled “Scenario: Rollover de mês reseta usage idempotentemente”- Given uma
Subscriptionfree comusage.messagesSent = 100,usage.goalsCount = 0,usage.faturasImported = 1eusagePeriod = Period.of(2026, 7) - And
today = 2026-08-01(primeiro dia do mês seguinte) - When chamamos
subscription.rolloverIfNeeded(today) - Then
usage.messagesSent === 0 - And
usage.faturasImported === 0 - And
usage.goalsCount === 0(counter cumulativo do casal continua zerado neste teste — decisão de impl: contadores que reseteam mensalmente sãomessagesSentefaturasImported;goalsCountsegue a regra de “metas ativas” e também zera no rollover pra simplificar, ver decisão abaixo) - And
usagePeriod.equals(Period.of(2026, 8))étrue(avançou um mês) - When chamamos
subscription.rolloverIfNeeded(today)de novo (mesmo dia, mesma sub) - Then
usage.messagesSentcontinua0(no-op — idempotente;usagePeriodjá bate com mês corrente, não reseta de novo) - And o caller (router/agent) pode chamar
rolloverIfNeededem todo turn sem risco de zerar usage do mês corrente
Modelo
Section titled “Modelo”- Context novo —
subscriptions/(plural porque cada casal tem sua subscription, e o contexto agrupa a coleção — mesma régua deaccounts/do 001 vsbudget/singular do 000). Aggregate root nomeia (Subscription). - Aggregate root
Subscription—idUUID estável (default + overload externo pra adapter),householdIdopaco (string— não importaHouseholddo contexthousehold/pra evitar acoplamento desnecessário; caller mapeia),plan,usagePeriod: Period,usage: { messagesSent, goalsCount, faturasImported }. - Domain Service
Entitlement— vive emplanning/domain/services/(igualFeasibilityCheck/BudgetAlerts). Capability-named, sem aggregate root. Stateless:Entitlement.check({subscription, action, today}): EntitlementVerdict. EntitlementVerdict— POJO discriminado porallowed:type EntitlementVerdict =| { allowed: true }| { allowed: false; reason: string; suggestion?: string };EntitlementAction—"send-message" | "import-fatura" | "create-goal". String union, sem comportamento.- Limits hard-coded —
FREE_MESSAGES_PER_MONTH = 100,FREE_GOALS_MAX = 1,FREE_FATURAS_PER_MONTH = 1. Exportados pro spec assertar boundary, mas regra de negócio (qual action consulta qual limit) mora dentro deEntitlement.check. SubscriptionRepository— port emsubscriptions/application/.save(subscription),load(householdId)(key por household, não por subscription id, porque caller raramente conhece o sub id — conhece o casal),list(). Pattern simétrico aos repos de 007/010, com a inversão da key documentada.InMemorySubscriptionRepository— fake emsubscriptions/infrastructure/.Map<householdId, Subscription>. Spec domain não exercita repo neste cenário (foco no aggregate + service), mas o fake fica disponível pra cenários de integração futuros.
type Plan = "free" | "paid";
interface Usage { messagesSent: number; goalsCount: number; faturasImported: number;}
class Subscription { static create(args: { householdId: string; plan?: Plan; today?: Date; id?: string }): Subscription; static rehydrate(args: { id: string; householdId: string; plan: Plan; usagePeriod: Period; usage: Usage; }): Subscription;
readonly id: string; readonly householdId: string; plan: Plan; // readonly por fora; upgrade/downgrade mutam usagePeriod: Period; usage: Usage;
recordMessage(): void; // incrementa usage.messagesSent (paid: no-op opcional ou contagem livre — impl decide) recordGoalCreated(): void; // incrementa usage.goalsCount recordFaturaImport(): void; // incrementa usage.faturasImported
rolloverIfNeeded(today: Date): void; // se Period(today) !== usagePeriod, reseta usage + atualiza usagePeriod; idempotente
upgrade(): void; // plan = "paid"; preserva usage histórico do mês (não reseta) downgrade(): void; // plan = "free"; usage continua — se exceder limit, próximas actions barradas até rollover
// serialização pareada com rehydrate (pattern 007/010) serializeUsage(): Usage;}
// src/contexts/planning/domain/services/Entitlement.tsexport type EntitlementAction = "send-message" | "import-fatura" | "create-goal";
export type EntitlementVerdict = | { allowed: true } | { allowed: false; reason: string; suggestion?: string };
export const FREE_MESSAGES_PER_MONTH = 100;export const FREE_GOALS_MAX = 1;export const FREE_FATURAS_PER_MONTH = 1;
export const Entitlement: { check(input: { subscription: Subscription; action: EntitlementAction; today: Date; }): EntitlementVerdict;};
// src/contexts/subscriptions/application/SubscriptionRepository.tsexport interface SubscriptionRepository { save(subscription: Subscription): Promise<void> | void; load(householdId: string): Promise<Subscription | undefined> | Subscription | undefined; list(): Promise<Subscription[]> | Subscription[];}Regras de classificação (paid sempre passa; free aplica counter ≤ limit):
plan === "paid"→{ allowed: true }(curto-circuito antes de olhar counters)action === "send-message"→usage.messagesSent <= FREE_MESSAGES_PER_MONTH ? allowed : blockedaction === "create-goal"→usage.goalsCount < FREE_GOALS_MAX ? allowed : blocked(estrito — 0 ok, 1 já é o limite ativo)action === "import-fatura"→usage.faturasImported < FREE_FATURAS_PER_MONTH ? allowed : blocked
Diferença sutil entre <= (messages) e < (goals/faturas): mensagens contam depois do envio (recordMessage então check), enquanto goals/faturas contam o estado atual (já tem 1 → não pode criar mais 1). Documentado pra impl não confundir.
reason/suggestion quando bloqueado seguem o tom mel (ADR 008): empático, oferece path concreto, hard-coded PT-BR no domínio (gotcha “message humano em PT no domínio enquanto monolíngue”). Exemplos: "limite mensal de mensagens atingido (100/mês)" / "/upgrade pra continuar este mês".
Decisões de design
Section titled “Decisões de design”- Subscription 1:1 com Household, key por
householdId— escopo de monetização é o casal, não o usuário individual (cenário 005 cravouHouseholdcomo unidade). Repositoryload(householdId), nãoload(subscriptionId), porque caller sempre conhece o casal e raramente o sub id.Subscription.idainda existe (pattern 007/010) pra adapter ter chave primária, mas a fronteira pública preferehouseholdId. Mesma régua de “fronteira LLM é EN, domínio interno PT/EN mix” (gotcha 008/013): a chave externa é a que o caller conhece. - Aggregate
Subscriptionem context novo, não emhousehold/— tentei encaixar emhousehold/(sub seria field do aggregate Household). Rejeitado: monetização tem ciclo de vida próprio (rollover mensal, upgrade/downgrade, eventual webhook de billing) que não pertence à unidade convivência. Separar context dá liberdade pra evoluir billing/usage sem mexer emHousehold. Trade-off: caller que precisa de “casal + plano” carrega dois aggregates — aceito (caso típico do router/AgentChat que já carrega vários). Entitlementmora emplanning/, não emsubscriptions/— é Domain Service cross-context (lêSubscription, eventualmente vai ler mais quando entitlement ficar rico — ex: “feature flag X só pra paid com 6mo de retenção”). Capability-named, mesmo lugar deFeasibilityCheck/BudgetAlerts. Alternativa considerada:subscriptions/domain/services/Entitlement.ts— rejeitada porque colocar service emsubscriptions/força esse context a importar coisas de outros quando entitlement ficar cross-aggregate.planning/é o lar correto pra decisões cross-context puras.- Limits UPPER_SNAKE hard-coded, não config externa — mesma régua de threshold mágico documentado dos 006/015/016/020. Free é o produto-marketing decidido pelo CEO doc #9; deve mudar via code review e deploy, não via config dinâmica per-casal. Quando aparecer caso real (“campanha de Natal libera 200 msgs”, “casal early-access tem teto custom”), promove a parâmetro override (passa pelo
Entitlement.check({limits?})) — primeiro passo antes de virar VOEntitlementLimits(gotcha 015 documentou esse caminho gradual). - Boundary
<=em mensagens,<em goals/faturas — convenção explícita —recordMessageé chamado após o casal mandar a msg (incrementa primeiro, depoischeckdecide se a próxima pode rolar). EntãomessagesSent === 100ainda é “o 100º foi mandado, próximo seria 101 — bloqueia”. Jácreate-goalé checado antes da criação (goalsCount === 1significa “já tem 1, criar mais seria 2 — bloqueia”). Pra evitar confusão de ordem, documento aqui qual counter conta o estado atual vs o “depois da ação”. Reforça o pattern de “tool chama gate, não aggregate chama gate” (gate fica no caller, aggregate só conta). recordMessage/recordGoalCreated/recordFaturaImportsão counters explícitos, não calculados de outros aggregates — alternativa:Entitlement.checkfariagoalRepo.list({householdId}).lengthpra contar goals. Rejeitado: cross-aggregate query síncrona acoplasubscriptions/aos repos degoals//accounts/, e cadacheck(dezenas por sessão) pagaria load completo. Trade-off aceito: duplicar counter no Subscription (caller incrementa explicitamente após criar goal). Inconsistência teórica (“dropei goal pelo SQL, Subscription não soube”) tratada por reset mensal — divergência tem TTL de 1 mês.rolloverIfNeeded(today)é eager, caller chama explicitamente — Subscription não tem clock interno; quem decide “agora é hora de rolar” é o router/AgentChat (mesmo padrão detoday: DateemFeasibilityCheck/BudgetAlerts/Goal.pace). Pattern: caller chamasub.rolloverIfNeeded(today)no início de cada turn (ou no schedule), antes de qualquerrecordXoucheck. Idempotente — chamar 100x no mesmo dia é seguro. Alternativa “rollover automático no primeirocheckdo mês novo” rejeitada porque esconde mutação dentro de método nomeadocheck(não esperaria side-effect).upgradepreserva usage;downgradepreserva também — semântica: contadores são “fatos do mês”, não “permissão do mês”. Upgrade no meio do mês não zera o que o casal já consumiu (poderia, mas seria comportamento estranho — “upgrade me deu 100 msgs novas??”). Downgrade também não — se o casal estava com 250 msgs no paid e fez downgrade, está em estado bloqueado até o próximo rollover. Reforça regra “rollover é o único reset”. Trade-off: downgrade no meio do mês pode soar punitivo. Aceito como gotcha — se virar dor real, vira cenário de “downgrade rolls over usage” próprio.goalsCountreseta no rollover junto com os outros counters — decisão pragmática de impl. Conceitualmente “metas ativas” é estado cumulativo (não-mensal), mas: (a) free permite só 1 meta ativa, e reset mensal não muda essa restrição na prática (casal continua comgoalsCount = 1porque recria contador viarecordGoalCreatedquando reabrir o app? — não, contador é volátil, é “metas criadas neste mês”); (b) tratargoalsCountcomo cumulativo + os outros como mensais aumenta complexidade do aggregate sem ganho prático. Documentado:goalsCountaqui significa “metas criadas neste período” — uso pra gate é boundary< FREE_GOALS_MAX = 1. Se aparecer caso “casal recria a mesma meta 3x no mês e a 4ª é bloqueada”, revisa.EntitlementVerdictflat com optionalreason/suggestion, não union discriminada com dois subtipos — segue gotcha “Result flat com optional fields, não union discriminada” do AGENTS.md. Consumer diferencia porverdict.allowedboolean, e quandofalselêreason/suggestion. Padrão alinhado comFeasibilityResult.gap?eAlert.expense?.reason/suggestionPT-BR no domínio enquanto monolíngue — mesma gotcha doAlert.message(015) e canonical responses do 023. Quando i18n entrar, vira{key, params}ou getter por locale.EntitlementActioné string union, não enum nem VO — fronteira é externa (router/tool passa string literal). Sem comportamento associado à action — só serve praEntitlement.checkrotear pro counter certo. Promove a VO quando aparecer caso de “action carrega metadata” (ex: peso, custo em tokens).- Spec domain testa só Subscription + Entitlement; integração com AgentChat/tools fica em cenários futuros — escopo 025 é o domain (state machine do aggregate + decisão do service). Integração com
AgentChat.ask(gate antes do LLM, paralelo aoConversationGuarddo 023) e write tools (gate antes derepo.save, novo flaggated?: truenoToolReceipt— anti-hallucination friendly) são descritas aqui pra fechar a história de produto mas não exercitadas no spec. Entram quando 026+ (ou impl-side do agent) precisar.
Integração com o resto do sistema (descritivo — não testado neste cenário)
Section titled “Integração com o resto do sistema (descritivo — não testado neste cenário)”AgentChat.askconsultaEntitlement.check({action: "send-message"})antes do LLM — paralelo aoConversationGuard.evaluatedo 023. Ordem proposta noask:subscription.rolloverIfNeeded(today)(pelo caller, antes deask).subscription.recordMessage()(já incrementa — fato registrado).Entitlement.check({subscription, action: "send-message", today}).- Se
allowed: false→ retorna{reply: {role:"assistant", content: verdict.reason + " " + verdict.suggestion}, toolCalls: []}(zero token, sem invocar LLM). - Senão → segue fluxo 008/021/023 (ConversationGuard primeiro, depois LLM). Entitlement check roda depois do ConversationGuard preempt: se casal frustrado bate na cota, ainda recebe a resposta empática do guard (UX > monetização).
- Write tools (013) consultam
Entitlement.checkantes derepo.save—RegisterExpenseToolnão tem gate (free permite expense register ilimitado).ContributeToGoalTooltambém livre (aporte em meta existente). MasCreateGoalTool(futuro 017 promovendoRegisterGoalTool) eImportInvoiceTool(009) consultam respectivamentecreate-goaleimport-fatura. Seallowed: false, tool retornaToolReceipt { operationId, preview: {...}, confirmed: false, persisted: false, gated: true, message: verdict.reason + " " + verdict.suggestion }. Novo flaggated?: trueno receipt — caller (AgentChat) renderiza pro casal direto, anti-hallucination friendly (LLM vê o receipt e sabe que não rolou — não inventa “criei a meta”). - Counters incrementados pelo caller pós-action, não pelo aggregate alvo —
Budgetnão chamasubscription.recordGoalCreated()quando uma meta é criada. Caller (write tool) faz:entitlement.check→ se allowed,goal.create + goalRepo.save + subscription.recordGoalCreated + subscriptionRepo.save. Acopla a write tool a dois repos, mas mantém aggregates desacoplados entre si.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenários 008, 013, 017, 009 — sem touchup nos specs existentes. Integração com gate é nova layer no impl-side; specs antigos rodam com caller que não consulta
Entitlement(default: comporta como paid plan free-of-gate). - Cenário 023 (ConversationGuard) — coexiste com
Entitlement. Ordem noAgentChat.ask: (1) ConversationGuard preempt, (2) Entitlement check, (3) LLM. Razão: emoção do casal vence cota — frustrado/em loop/pedindo humano vê resposta empática mesmo no teto. - Cenário 016 (referral attribution) — sinergia futura: referral bem-sucedido pode dar bônus de cota free (“indicou 1 casal → ganha 50 msgs no mês”). Fora do escopo do 025; entra como override em
Entitlement.check({limits?})quando aparecer. - Cenário 007 (persistir Budget) — pattern do repo replicado;
SubscriptionRepositorysegue mesma régua (save/load/list,idUUID default,rehydratepareado comserialize*). Adapter SQLite fica em spec colocadasubscriptions/infrastructure/SqliteSubscriptionRepository.spec.tsquando aparecer (ADR 003).
Fora de escopo
Section titled “Fora de escopo”- Billing / pagamento real — Stripe/Pluggy/iuGu/Pagar.me.
Subscription.upgrade()não chama gateway; assume que algum impl externo (webhook handler) mutaplanapós confirmação. Cenário de billing entra próprio (provável contextbilling/). - Trial / grace period — “primeiros 7 dias paid grátis”, “1 mês grace pós-downgrade”. Não modelado. Vira
usagePeriodcom flag ou aggregate separado quando aparecer. - Tiered plans (basic/pro/family) — hoje só
free | paidbinary. Quando aparecer terceira opção,planvira VOPlancom métodos.allows(action). - Counters cumulativos vs mensais — todos os 3 counters reseteam no rollover. Se aparecer “goalsCount cumulativo” (ex: “total de metas já criadas pelo casal, vida toda”), vira field separado (
lifetimeGoalsCreated) sem afetarusage. - Webhooks de billing — Stripe/iuGu manda evento
subscription.updated→ impl externo chamasub.upgrade()/sub.downgrade(). Mecânica do webhook não é deste cenário. - Audit log de gate misses — “quantas vezes o casal X bateu no teto este mês”. Telemetria, fora do domain. Logger fica no router/adapter que chama
Entitlement.check. - Quota burst — “deixa passar +10% do limit pra evitar UX ruim em boundary”. Hoje boundary estrito. Quando aparecer, vira parâmetro override.
- Spec de integração com AgentChat/write tools — gate no router/tool é cenário próprio (provável extensão do 013 ou cenário 026+). 025 cobre só o domain.
- Adapter SQLite do
SubscriptionRepository— spec colocada emsubscriptions/infrastructure/SqliteSubscriptionRepository.spec.tsquando primeiro consumer prod precisar (ADR 003 — mecânica adapter sem doc Gherkin). - Concorrência (race condition no
recordX) — single-user single-process por enquanto. Quando virar multi-device com sync, entra cenário de versioning.
Próximo passo
Section titled “Próximo passo”- Criar context
src/contexts/subscriptions/comdomain/Subscription.ts(aggregate root) + barrel. - Criar
src/contexts/planning/domain/services/Entitlement.tscom constantes UPPER_SNAKE + service stateless. - Criar
src/contexts/subscriptions/application/SubscriptionRepository.ts(port) +src/contexts/subscriptions/infrastructure/InMemorySubscriptionRepository.ts(fake). - Atualizar barrels
subscriptions/domain/index.ts+planning/domain/index.tsexportandoEntitlement,EntitlementVerdict,EntitlementActione as três constantesFREE_*. - Passar os 6 scenarios. Specs 008/013/015/023 continuam verdes sem touchup (integração com gate é impl-side, não no domain).