Skip to content

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 com Household. Carrega o plan ("free" | "paid"), o usagePeriod (mês corrente do plano, baseado no Period do shared-kernel) e o usage (counters de mensagens, goals, faturas no mês). Métodos de mutação são counters explícitos (recordMessage, recordGoalCreated, recordFaturaImport), upgrade/downgrade pra trocar plano, e rolloverIfNeeded(today) pra resetar usage quando o mês vira.
  • Entitlement — Domain Service stateless em planning/domain/services/. Mesma régua de FeasibilityCheck (006) e BudgetAlerts (015): puro, sem I/O, recebe {subscription, action, today} e devolve EntitlementVerdict. 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 por householdId opaco)
  • 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 é o Period do mês corrente (não undefined) — 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, igual Budget.id do 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 Subscription free com usage.messagesSent = 99 (reconstruída via Subscription.rehydrate(...) pra simular o estado de meio de mês)
  • And today = 2026-07-15 (mesmo mês do usagePeriod)
  • When chamamos subscription.recordMessage()usage.messagesSent vira 100
  • And chamamos Entitlement.check({subscription, action: "send-message", today})
  • Then result.allowed === true (boundary inclusivo: messagesSent <= FREE_MESSAGES_PER_MONTH ainda 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 Subscription free com usage.messagesSent = 100 (já no teto)
  • And today = 2026-07-15
  • When chamamos subscription.recordMessage()usage.messagesSent vira 101 (ultrapassou)
  • And chamamos Entitlement.check({subscription, action: "send-message", today})
  • Then result.allowed === false
  • And result.reason bate em /limite|mensa|atingid/i (texto humano PT-BR pronto pra UX/agent ler)
  • And result.suggestion bate 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 Subscription free com usage.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.reason bate em /meta|goal|1/i (menciona o limite numérico ou a palavra meta)
  • And result.suggestion bate em /upgrade/i

Scenario: Paid plan — Entitlement sempre allowed

Section titled “Scenario: Paid plan — Entitlement sempre allowed”
  • Given uma Subscription com plan = "paid" e usage arbitrariamente alto (messagesSent = 5000, goalsCount = 10, faturasImported = 20)
  • And today = 2026-07-15
  • When chamamos Entitlement.check pras 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 Subscription free com usage.messagesSent = 100, usage.goalsCount = 0, usage.faturasImported = 1 e usagePeriod = 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ão messagesSent e faturasImported; goalsCount segue 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.messagesSent continua 0 (no-op — idempotente; usagePeriod já bate com mês corrente, não reseta de novo)
  • And o caller (router/agent) pode chamar rolloverIfNeeded em todo turn sem risco de zerar usage do mês corrente
  • Context novosubscriptions/ (plural porque cada casal tem sua subscription, e o contexto agrupa a coleção — mesma régua de accounts/ do 001 vs budget/ singular do 000). Aggregate root nomeia (Subscription).
  • Aggregate root Subscriptionid UUID estável (default + overload externo pra adapter), householdId opaco (string — não importa Household do context household/ pra evitar acoplamento desnecessário; caller mapeia), plan, usagePeriod: Period, usage: { messagesSent, goalsCount, faturasImported }.
  • Domain Service Entitlement — vive em planning/domain/services/ (igual FeasibilityCheck/BudgetAlerts). Capability-named, sem aggregate root. Stateless: Entitlement.check({subscription, action, today}): EntitlementVerdict.
  • EntitlementVerdict — POJO discriminado por allowed:
    type EntitlementVerdict =
    | { allowed: true }
    | { allowed: false; reason: string; suggestion?: string };
  • EntitlementAction"send-message" | "import-fatura" | "create-goal". String union, sem comportamento.
  • Limits hard-codedFREE_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 de Entitlement.check.
  • SubscriptionRepository — port em subscriptions/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 em subscriptions/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.
src/contexts/subscriptions/domain/Subscription.ts
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.ts
export 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.ts
export 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 : blocked
  • action === "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".

  • Subscription 1:1 com Household, key por householdId — escopo de monetização é o casal, não o usuário individual (cenário 005 cravou Household como unidade). Repository load(householdId), não load(subscriptionId), porque caller sempre conhece o casal e raramente o sub id. Subscription.id ainda existe (pattern 007/010) pra adapter ter chave primária, mas a fronteira pública prefere householdId. 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 Subscription em context novo, não em household/ — tentei encaixar em household/ (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 em Household. Trade-off: caller que precisa de “casal + plano” carrega dois aggregates — aceito (caso típico do router/AgentChat que já carrega vários).
  • Entitlement mora em planning/, não em subscriptions/ — é 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 de FeasibilityCheck/BudgetAlerts. Alternativa considerada: subscriptions/domain/services/Entitlement.ts — rejeitada porque colocar service em subscriptions/ 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 VO EntitlementLimits (gotcha 015 documentou esse caminho gradual).
  • Boundary <= em mensagens, < em goals/faturas — convenção explícitarecordMessage é chamado após o casal mandar a msg (incrementa primeiro, depois check decide se a próxima pode rolar). Então messagesSent === 100 ainda é “o 100º foi mandado, próximo seria 101 — bloqueia”. Já create-goal é checado antes da criação (goalsCount === 1 significa “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/recordFaturaImport são counters explícitos, não calculados de outros aggregates — alternativa: Entitlement.check faria goalRepo.list({householdId}).length pra contar goals. Rejeitado: cross-aggregate query síncrona acopla subscriptions/ aos repos de goals//accounts/, e cada check (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 de today: Date em FeasibilityCheck/BudgetAlerts/Goal.pace). Pattern: caller chama sub.rolloverIfNeeded(today) no início de cada turn (ou no schedule), antes de qualquer recordX ou check. Idempotente — chamar 100x no mesmo dia é seguro. Alternativa “rollover automático no primeiro check do mês novo” rejeitada porque esconde mutação dentro de método nomeado check (não esperaria side-effect).
  • upgrade preserva usage; downgrade preserva 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.
  • goalsCount reseta 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 com goalsCount = 1 porque recria contador via recordGoalCreated quando reabrir o app? — não, contador é volátil, é “metas criadas neste mês”); (b) tratar goalsCount como cumulativo + os outros como mensais aumenta complexidade do aggregate sem ganho prático. Documentado: goalsCount aqui 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.
  • EntitlementVerdict flat com optional reason/suggestion, não union discriminada com dois subtipos — segue gotcha “Result flat com optional fields, não union discriminada” do AGENTS.md. Consumer diferencia por verdict.allowed boolean, e quando falsereason/suggestion. Padrão alinhado com FeasibilityResult.gap? e Alert.expense?.
  • reason/suggestion PT-BR no domínio enquanto monolíngue — mesma gotcha do Alert.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 pra Entitlement.check rotear 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 ao ConversationGuard do 023) e write tools (gate antes de repo.save, novo flag gated?: true no ToolReceipt — 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.ask consulta Entitlement.check({action: "send-message"}) antes do LLM — paralelo ao ConversationGuard.evaluate do 023. Ordem proposta no ask:
    1. subscription.rolloverIfNeeded(today) (pelo caller, antes de ask).
    2. subscription.recordMessage() (já incrementa — fato registrado).
    3. Entitlement.check({subscription, action: "send-message", today}).
    4. Se allowed: false → retorna {reply: {role:"assistant", content: verdict.reason + " " + verdict.suggestion}, toolCalls: []} (zero token, sem invocar LLM).
    5. 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.check antes de repo.saveRegisterExpenseTool não tem gate (free permite expense register ilimitado). ContributeToGoalTool também livre (aporte em meta existente). Mas CreateGoalTool (futuro 017 promovendo RegisterGoalTool) e ImportInvoiceTool (009) consultam respectivamente create-goal e import-fatura. Se allowed: false, tool retorna ToolReceipt { operationId, preview: {...}, confirmed: false, persisted: false, gated: true, message: verdict.reason + " " + verdict.suggestion }. Novo flag gated?: true no 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 alvoBudget não chama subscription.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.
  • 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 no AgentChat.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; SubscriptionRepository segue mesma régua (save/load/list, id UUID default, rehydrate pareado com serialize*). Adapter SQLite fica em spec colocada subscriptions/infrastructure/SqliteSubscriptionRepository.spec.ts quando aparecer (ADR 003).
  • Billing / pagamento real — Stripe/Pluggy/iuGu/Pagar.me. Subscription.upgrade() não chama gateway; assume que algum impl externo (webhook handler) muta plan após confirmação. Cenário de billing entra próprio (provável context billing/).
  • Trial / grace period — “primeiros 7 dias paid grátis”, “1 mês grace pós-downgrade”. Não modelado. Vira usagePeriod com flag ou aggregate separado quando aparecer.
  • Tiered plans (basic/pro/family) — hoje só free | paid binary. Quando aparecer terceira opção, plan vira VO Plan com 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 afetar usage.
  • Webhooks de billing — Stripe/iuGu manda evento subscription.updated → impl externo chama sub.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 em subscriptions/infrastructure/SqliteSubscriptionRepository.spec.ts quando 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.
  1. Criar context src/contexts/subscriptions/ com domain/Subscription.ts (aggregate root) + barrel.
  2. Criar src/contexts/planning/domain/services/Entitlement.ts com constantes UPPER_SNAKE + service stateless.
  3. Criar src/contexts/subscriptions/application/SubscriptionRepository.ts (port) + src/contexts/subscriptions/infrastructure/InMemorySubscriptionRepository.ts (fake).
  4. Atualizar barrels subscriptions/domain/index.ts + planning/domain/index.ts exportando Entitlement, EntitlementVerdict, EntitlementAction e as três constantes FREE_*.
  5. Passar os 6 scenarios. Specs 008/013/015/023 continuam verdes sem touchup (integração com gate é impl-side, não no domain).