Skip to content

wa-004 — Bring partner via DM solo (2º member entra no mesmo Household via DM próprio)

Pós-wa-001, Ana já criou o Household (1 member: ela). Ana quer trazer Bruno pra dentro do mesmo Household — sem signup, sem onboarding novo, sem group chat. Caminho deste cenário:

  1. Ana pede ao bot um link de convite pro parceiro (UX: comando natural no chat, ou botão na landing/dashboard read-only futura; detalhe de UX fora do escopo desta spec — aqui só o Household.issuePartnerInvite() é exercitado).
  2. Bot retorna deep link wa.me/<bot>?text=partner-<token> pra Ana copiar/compartilhar.
  3. Ana manda o link pro Bruno pelo WhatsApp pessoal dela.
  4. Bruno clica → abre conversa DM SOLO com o bot (não group, não grupo). Primeira mensagem dele já carrega o prefix.
  5. Bot detecta o prefix partner-<token> no text, NÃO cria Household novo: resolve o token → encontra o Household da Ana → adiciona Bruno como Member (Household.addMember) → linka o chatId DM do Bruno ao mesmo Household.id.
  6. A partir daí: ambos os DMs (Ana e Bruno) apontam pro mesmo Household. resourceId compartilhado, threads isoladas por chatId (mesmo mapping de wa-003 entre group e DM — ADR 004 + cenário 012).

Caminho alternativo ao wa-003 (group): casal que não quer abrir group dedicado de 3, ou casal cujo parceiro nunca esteve no DM do bot ainda. wa-003 e wa-004 são ortogonais — em prod ambos coexistem; user escolhe.

Distinção crítica vs cenário 016 (ref-<token>)

Section titled “Distinção crítica vs cenário 016 (ref-<token>)”

Ambos os prefixes carregam token opaco, ambos parseados no router. Semântica é inversa:

PrefixSemânticaAggregate impactoPort chamado
ref-<shareToken>Novo casal entra via convite de outro casalCria Household novo + Referrals.attribute(...)HouseholdLookup.bootstrapFromChat(...)
partner-<token>Mesmo casal, 2º member entra no Household existenteNÃO cria Household — chama Household.addMember(...)HouseholdLookup.bringPartnerFromInvite(...)

Misturar os dois cabe num parser estruturado (gotcha “prefix mágico documentado é OK até aparecer 2º prefix com semântica diferente — aí vira parser estruturado”). Este é exatamente o 2º prefix. Discutido em Decisões de design abaixo.

PartnerInviteToken.expiresAt = issuedAt + 7 dias. Threshold mágico documentado (gotcha “threshold mágico documentado é OK até aparecer caso”). Quando aparecer caso “casais com vida corrida pedem 30 dias” ou “queremos rotacionar a cada 24h”, promove a parâmetro do issuePartnerInvite({ttlDays?}). Hoje: simples, suficiente.

Token marcado consumedAt quando consumePartnerInvite retorna {ok: true}. Segunda tentativa com mesmo token → {ok: false, reason: "already-consumed"}. Mirror da decisão de 016 sobre ShareToken single-use opcional, mas aqui é default porque convite de parceiro é pessoal (vs share viral que é multi-use por design).

Scenario: Ana gera convite parceiro — token emitido + persistido

Section titled “Scenario: Ana gera convite parceiro — token emitido + persistido”
  • Given um Household “Ana” criado em BRL com Member Ana (estado pós-wa-001 — Ana é o único member)
  • And today = 2026-07-15T00:00:00Z (clock injetado pelo caller; domínio não consulta Date.now())
  • And household.partnerInvites().length === 0
  • When chamamos household.issuePartnerInvite({ today })
  • Then o result.token é uma string non-empty (token opaco, gerado pelo aggregate — domain controla unicidade interna)
  • And o result.issuedAt é today (passado pelo caller, não Date.now())
  • And o result.expiresAt > today (TTL hard-coded 7 dias — expiresAt = issuedAt + 7d)
  • And o result.consumedAt é undefined (token recém-emitido)
  • And household.partnerInvites().length === 1 (append-only — pattern do IncomeEntry em 014)
Section titled “Scenario: Bruno abre link partner-<token> no DM próprio — vira 2º member”
  • Given Household “Ana” existe com Member Ana (id = hh-ana) e está persistido em HouseholdRepository
  • And o chatId="dm-5511999999999" (DM da Ana) está linkado a hh-ana via householdLookup.linkChat(...) (estado pós-wa-001)
  • And Ana gerou um PartnerInviteToken com token="partner_invite_xyz" via household.issuePartnerInvite({today}) (ver scenario anterior); household.partnerInvites().length === 1
  • And today = 2026-07-15 (dentro do TTL de 7d)
  • And o chatId="dm-5599888888888" (DM solo do Bruno) ainda não está linkado a nenhum Household — householdLookup.findByChatId("dm-5599888888888") retorna undefined
  • When MockWhatsAppGateway.emit({ chatId: "dm-5599888888888", senderId: "5599888888888", text: "partner-partner_invite_xyz oi", isGroup: false, mentions: [] }) dispara o evento (primeira mensagem do Bruno no DM)
  • And o router parseia o prefix via /^partner-(\S+)/i → extrai partnerToken = "partner_invite_xyz"
  • And o router chama householdLookup.bringPartnerFromInvite({ chatId: "dm-5599888888888", senderId: "5599888888888", partnerToken: "partner_invite_xyz" })
  • Then o result é o mesmo aggregate Household “Ana” (mesmo id = hh-ana); NENHUM Household novo foi criadohouseholdRepo.list().length continua 1
  • And household.members().length === 2 (Ana + novo Member derivado do senderId do Bruno — caller decide nome, ex: placeholder "Parceiro" ou waid-derived)
  • And o novo Member tem id UUID estável (não reusa nenhum id existente)
  • And householdLookup.findByChatId("dm-5599888888888") agora retorna o mesmo Household “Ana” (chatId DM do Bruno linkado)
  • And householdLookup.findByChatId("dm-5511999999999") continua retornando o mesmo Household “Ana” (link da Ana intacto — many-to-one chatIds → Household)
  • And o token foi marcado consumed: household.consumePartnerInvite("partner_invite_xyz", today).ok === true (segunda chamada — idempotente do ponto de vista do verdict, mas o estado interno só transiciona uma vez na primeira call efetiva do lookup)
  • And o gateway.sentMessages contém uma saudação enviada ao chatId do Bruno cumprindo regex /junto|casal|Ana|bem-vindo/i (wording personalizado mencionando o parceiro existente; LLM real renderiza, spec valida via regex permissivo)

Scenario: Token expirado — fallback gracioso, Member NÃO adicionado

Section titled “Scenario: Token expirado — fallback gracioso, Member NÃO adicionado”
  • Given Household “Ana” existe com partnerInvites: [{ token: "old_token", issuedAt: 2026-07-01, expiresAt: 2026-07-08, consumedAt: undefined }] (gerado 14 dias atrás, expirou há 7 dias)
  • And today = 2026-07-15 (depois de expiresAt)
  • And household.members().length === 1 (só Ana)
  • When householdLookup.bringPartnerFromInvite({ chatId: "dm-5577777777777", senderId: "5577777777777", partnerToken: "old_token" }) é chamado
  • Then o result === undefined (lookup falha silenciosa — não throw, não loga)
  • And household.members().length permanece 1 (Bruno NÃO foi adicionado)
  • And householdLookup.findByChatId("dm-5577777777777") continua retornando undefined (chatId não foi linkado)
  • And o verdict explícito do aggregate é household.consumePartnerInvite("old_token", today){ ok: false, reason: "expired" }
  • And o router NÃO dispara bootstrapFromChat automaticamente neste caso — diferentemente do scenario 5 (token desconhecido), token expirado não vira onboarding novo por design (parceiro com link velho provavelmente quer o link novo, não virar Household separado). Router responde via gateway.sendMessage com texto regex /expirou|expirado|outro link|peça um novo/i. (Wording deferido pra router — spec aqui valida o verdict do aggregate + ausência de side-effect; o teste do router específico fica no spec do wa-004 que dirige o fluxo completo.)

Scenario: Token já usado — bloqueia segunda tentativa

Section titled “Scenario: Token já usado — bloqueia segunda tentativa”
  • Given Household “Ana” com partnerInvites: [{ token: "partner_invite_xyz", issuedAt: 2026-07-15, expiresAt: 2026-07-22, consumedAt: 2026-07-16 }] (Bruno já entrou via scenario 2 — token marcado consumed)
  • And today = 2026-07-17 (dentro do TTL original — token não expirou, só foi consumido)
  • And household.members().length === 2 (Ana + Bruno, já no Household)
  • When alguém (ex: um amigo curioso que pegou o link) tenta householdLookup.bringPartnerFromInvite({ chatId: "dm-5500000000001", senderId: "5500000000001", partnerToken: "partner_invite_xyz" })
  • Then o result === undefined
  • And household.members().length permanece 2 (não cresce — protege Household de leakage pra terceiro)
  • And householdLookup.findByChatId("dm-5500000000001") continua retornando undefined
  • And o verdict explícito é household.consumePartnerInvite("partner_invite_xyz", today){ ok: false, reason: "already-consumed" }
  • And o router responde com texto regex /já foi usado|outro link|peça um novo|expirou/i (wording deferido)

Scenario: Token inválido (desconhecido) — silent fallback pra onboarding normal

Section titled “Scenario: Token inválido (desconhecido) — silent fallback pra onboarding normal”
  • Given nenhum Household no HouseholdRepository tem partnerInvites contendo o token "tok_nope"
  • And o chatId="dm-5500000000002" ainda não está linkado a nenhum Household
  • When householdLookup.bringPartnerFromInvite({ chatId: "dm-5500000000002", senderId: "5500000000002", partnerToken: "tok_nope" }) é chamado
  • Then o result === undefined (lookup não encontrou Household que owna o token)
  • And householdRepo.list().length não muda dentro da chamada do bringPartnerFromInvite (o lookup não cria nada — caller decide o fallback)
  • And o caller (router wa-001 estendido) deve detectar result === undefined + token desconhecido (não expirado/consumed em nenhum Household) e fazer fallback pra householdLookup.bootstrapFromChat({ chatId: "dm-5500000000002", senderId: "5500000000002", referralToken: undefined }) — cria Household novo (parceiro com link lixo vira casal novo sem attribution, mesma régua silent do wa-001 ref-inválido)
  • And o user nunca vê erro — saudação é a de onboarding normal (regex /oi|mel|casal|começar/i, mesma do wa-001 cenário 1)

Nota de teste: spec deste cenário valida apenas o retorno undefined do lookup (responsabilidade do HouseholdLookup). O fluxo de fallback (router chama bootstrapFromChat quando bringPartnerFromInvite retorna undefined) é responsabilidade do router e fica documentado aqui no doc Gherkin pra orientar implementação, mas a asserção fica fora do spec wa-004 (pertence ao spec do router estendido — provavelmente um wa-001 refactored ou spec colocada). Mantém o spec wa-004 focado no contract do aggregate + lookup.

  • Sem context novohousehold/ ganha um VO (PartnerInviteToken) e dois métodos no aggregate (issuePartnerInvite, consumePartnerInvite). agent/application/HouseholdLookup ganha um método (bringPartnerFromInvite).
  • Sem aggregate novo — convite de parceiro é fato append-only dentro do Household (pattern do IncomeEntry em 014). Promove a Entity própria (PartnerInvite com id/revoke) se aparecer caso “Ana quer revogar link antes de expirar”.
  • Sem Domain Service novo — verbos issuePartnerInvite / consumePartnerInvite moram no aggregate (estado interno: lista append-only de tokens). bringPartnerFromInvite mora no port (cruza I/O: load Household via repository + addMember + linkChat).
  • Token gerado pelo aggregate, não passado de fora — mesmo princípio do ShareToken em 016 (“shareToken gerado pelo aggregate”, “domain controla unicidade interna”). Caller recebe pronto.
// src/contexts/household/domain/value-objects/PartnerInviteToken.ts (planned)
export interface PartnerInviteToken {
readonly token: string; // opaco, gerado pelo Household (UUID-like)
readonly issuedAt: Date;
readonly expiresAt: Date; // = issuedAt + 7d (TTL hard-coded)
readonly consumedAt?: Date; // single-use; undefined enquanto não consumido
}
// src/contexts/household/domain/Household.ts (estende — já existe)
class Household {
// ...métodos existentes (addMember, assignIncome, monthlyIncome, ...)
issuePartnerInvite(input: { today: Date }): PartnerInviteToken;
// Gera token opaco non-empty, expiresAt = today + 7d.
// Append-only em this._partnerInvites; retorna o token recém-emitido.
partnerInvites(): PartnerInviteToken[];
// Snapshot da lista append-only (mesmo padrão de members()).
consumePartnerInvite(
token: string,
today: Date,
): { ok: true } | { ok: false; reason: "expired" | "already-consumed" | "unknown" };
// Verdict + side-effect: ao retornar { ok: true }, marca consumedAt internamente.
// - "unknown": token não está em this._partnerInvites
// - "expired": today > tokenRecord.expiresAt
// - "already-consumed": tokenRecord.consumedAt !== undefined
// Idempotência: segunda chamada de consumePartnerInvite com mesmo token
// (após primeira sucesso) retorna { ok: false, reason: "already-consumed" }.
}
// src/contexts/agent/application/HouseholdLookup.ts (estende — já existe)
interface HouseholdLookup {
findByChatId(chatId: string): Promise<Household | undefined>;
findBySenderId(senderId: string): Promise<Household | undefined>; // wa-003
linkChat(input: { chatId: string; householdId: string }): Promise<void>;
bootstrapFromChat(input: { // wa-001
chatId: string;
senderId: string;
referralToken?: string;
}): Promise<Household>;
chatIdsFor(householdId: string): Promise<string[]>; // 019
// wa-004 (novo):
bringPartnerFromInvite(input: {
chatId: string; // DM solo do parceiro chegando
senderId: string; // waid do parceiro (vira Member derivado)
partnerToken: string; // opaco; lookup resolve via Household.consumePartnerInvite
today?: Date; // opcional; default new Date() (caller injeta nos specs)
}): Promise<Household | undefined>;
// Semântica:
// 1. Itera households conhecidos (via HouseholdRepository.list ou index interno).
// 2. Tenta consumePartnerInvite(token, today) em cada um.
// 3. Se algum retornar { ok: true }: chama household.addMember(Member.create({name})),
// household.save (via HouseholdRepository), linkChat({chatId, householdId}),
// retorna o Household.
// 4. Se nenhum aceita (todos retornam { ok: false }): retorna undefined.
// Caller decide fallback (wa-001 → bootstrapFromChat).
}
  • Segundo prefix mágico — gatilho pra promover a parser estruturado, mas ainda dentro do limite — gotcha existente: “prefix mágico documentado é OK até aparecer 2º prefix com semântica diferente — aí vira parser estruturado”. Este é exatamente o 2º prefix. Decisão consciente: manter regex isolado por prefix por enquanto (/^partner-(\S+)/i separado do /^ref-(\S+)/i), porque os dois são mutuamente exclusivos (mensagem começa com um ou outro, nunca ambos) e o router só precisa de um if/else if/else. Quando aparecer 3º prefix (reminder-, goal-, ?) — aí vira parser estruturado tipo {kind: "partner"|"ref"|..., token: string}. Não promover por antecipação. Cenário 022 (eval harness) é o lugar natural pra testar combinações de prefix se virar regra real.
  • partner-<token> vs ref-<shareToken> — semântica inversa, ports diferentesref- cria Household NOVO via bootstrapFromChat (cenário 016 + wa-001). partner- atrai 2º member pra Household EXISTENTE via bringPartnerFromInvite. Dois ports separados em vez de overload mantém intent explícito + facilita evolução (TTL/single-use de partner- ≠ multi-use de ref- por design).
  • Token mora no aggregate Household, não em ReferralsReferrals é cross-household (rastreia attribution entre casais distintos); PartnerInviteToken é intra-household (binding pro parceiro do mesmo casal). Mistura semantica diferente → mistura cleanup, audit, persistência. Mantém Referrals enxuto e ancora ciclo de vida do convite no aggregate dono do convite.
  • TTL 7d hard-coded — threshold mágico documentado (gotcha existente, repetido aqui). Promove a parâmetro (issuePartnerInvite({ttlDays?})) quando aparecer caso. Hoje: 7d cobre 99% dos casos reais (“mando hoje, parceiro abre no fim de semana”).
  • Single-use por default — convite pessoal entre os dois do casal. Multi-use só faria sentido pra “Ana quer convidar vários parceiros” — semanticamente inconsistente com couple-first. Decisão diferente de ShareToken (16), que é multi-use por design (viral). Promove a flag se aparecer “polirelacionamento de 3 members” (esperar paciência razoável).
  • Member derivado do senderId, sem nome real — caller (bringPartnerFromInvite) cria Member.create({name}) com placeholder. Doc deixa em aberto: pode ser "Parceiro" literal, waid-derived ("Parceiro 5599888888888"), ou string que LLM extrai depois via conversa natural. Spec não trava o wording, só checa members().length === 2 + novo Member ≠ Ana. UX/system prompt do agent prod refina nome depois (cenário 018 AddMemberTool cobre a edição).
  • bringPartnerFromInvite retorna Household | undefined, não throw — UX silent fallback. Lookup itera Households tentando consumir token; se ninguém aceita, retorna undefined e caller decide. Mirror do attribute em 016 (gotcha “attribute silent on invalid token”) e do findByChatId (retorna undefined em vez de throw).
  • Verdict de consumePartnerInvite é discriminated union, não throw{ok: true} | {ok: false, reason}. Caller (HouseholdLookup.bringPartnerFromInvite + router) precisa distinguir os 3 motivos de falha pra UX:
    • expired: peça novo link (mas não cria Household novo — preserva relação).
    • already-consumed: link já foi usado, possivelmente por outro (intencionalmente?). Peça novo link.
    • unknown: nenhum Household tem esse token → caller faz fallback pra bootstrapFromChat (vira novo casal). Régua: só unknown vira onboarding novo; expired/already-consumed ficam no fluxo “peça novo link”.
  • Append-only em partnerInvites: PartnerInviteToken[], não Map — VO sem id próprio (mesmo padrão de IncomeEntry em 014). Promove a Entity (com revoke(), regenerate()) se aparecer verbo “Ana quer revogar link antes de Bruno usar”. Hoje: append-only + consumed flag basta.
  • today injetado, sem Date.now() no domínioissuePartnerInvite({today}) e consumePartnerInvite(token, today) recebem clock externo. Mesma fronteira de FeasibilityCheck.evaluate({today}) + ReminderService.evaluate({today}). Spec usa datas UTC fixas; gotcha “Aritmética de data em UTC, sempre” se aplica ao cálculo expiresAt = issuedAt + 7d.
  • Many-to-one chatId → Household.id — depois do bringPartnerFromInvite, o HouseholdLookup tem dois chatId linkados ao mesmo Household.id (DM Ana + DM Bruno). wa-003 já documentou esse caso parcialmente (DM + group). wa-004 confirma: lookup armazena Map<chatId, householdId> simples — many chatId apontam pra um householdId. Cenário 019 (chatIdsFor(householdId)) consome essa estrutura.
  • wa-004 é alternativo a wa-003, não substituto — UX prod oferece ambos. wa-003 é zero-friction quando casal já tem group; wa-004 é zero-friction quando não tem. Promove a primary path se um dos dois ganhar (telemetria futura). Hoje: ambos viáveis, decisão do casal.
  • wa-001 (onboarding via WhatsApp) — router precisa estender o parser pra reconhecer segundo prefix. Pseudo-código do router refactored:
    if (text.startsWith("partner-")) {
    const partnerToken = parse(/^partner-(\S+)/i)
    const result = await lookup.bringPartnerFromInvite({chatId, senderId, partnerToken, today})
    if (result) → saudação personalizada ("bem-vindo, casal Ana e ...")
    else → fallback pra bootstrapFromChat (silent onboarding normal)
    } else if (text.startsWith("ref-")) {
    ...wa-001 referral path
    } else {
    bootstrap normal
    }
    Touchup no spec wa-001 quando este cenário implementar; alternativamente, o spec do router fica em wa-004 e wa-001 segue cobrindo só ref/no-prefix.
  • wa-003 (group com parceiro) — caminho ortogonal, não muda. wa-003 cobre group bind via findBySenderId (Ana já tá no DM, vira group bind). wa-004 cobre DM solo do Bruno via convite. Em prod ambos viáveis; sem touchup em wa-003.
  • Cenário 005 (Household) — sem mudança de contract. addMember reusado. Aggregate ganha issuePartnerInvite/consumePartnerInvite/partnerInvites como verbos novos (extensão, não breaking).
  • Cenário 010 (HouseholdRepository) — adapter SQLite precisa serializar partnerInvites: PartnerInviteToken[] (tabela própria ou JSON column). Mecânica fica em SqliteHouseholdRepository.spec.ts colocada (ADR 003) — adicionar pattern serializePartnerInvites() + rehydrate({...partnerInvites}) quando primeira sessão prod precisar persistir.
  • Cenário 018 (AddMemberTool)AddMemberTool continua válido pra edição de nome do Member já adicionado via wa-004 (placeholder "Parceiro""Bruno" via conversa). wa-004 + 018 se compõem; sem conflito.
  • Cenário 016 (Referrals) — sem touchup. Distinção semantica documentada explicitamente neste doc. Quando aparecer 3º prefix, refatorar parser estruturado em ambos.
  • Revogação manual de token antes de consumir — “Ana errou link, quer cancelar antes do Bruno abrir”. Promove PartnerInviteToken a Entity com revoke() quando aparecer. Hoje: token expira em 7d e Ana pede novo.
  • Múltiplos partner invites simultâneos — Ana gera 2 tokens, manda 1 pro Bruno, manda outro pra terceiro. Domain aguenta (lista append-only), mas UX casal+parceiro não justifica testar. Cenário futuro se aparecer caso “casal poliamoroso, 3 members”.
  • TTL configurável per-Household — hoje hard-coded 7d. Override por parâmetro fica pra cenário com demanda real.
  • Rotação de waid (parceiro trocou de número após bind)findByChatId continua resolvendo via chatId antigo; chatId novo precisa de re-bind. Cenário futuro (mesmo TODO do wa-003).
  • Notificação ao inviter quando invitee abre o link — “Ana, o Bruno entrou!” é efeito colateral. Fora do aggregate. Worker/agent decide.
  • Persistência real do PartnerInviteToken em SQLite — adapter SqliteHouseholdRepository precisa estender schema. Mecânica em spec colocada futura (ADR 003).
  • Persistência do mapping chatId → Household.id pós-bind — adapter real (InMemoryHouseholdLookup no fake; tabela whatsapp_links no SQLite real). Mecânica em spec colocada.
  • Wording exato da saudação — spec valida via regex permissivo. LLM em prod renderiza.
  • Rate limiting de bringPartnerFromInvite — terceiro spamando tokens aleatórios. Mitigação fica no adapter Baileys / layer de orquestração.
  • Convite por email/SMS além de WhatsApp link — wa-004 cobre só o canal whats. Outros canais entram em cenário próprio.
  • Rollback se addMember sucede mas linkChat falha — cross-aggregate transaction. Aceito como trade-off (gotcha 013 “transactions cross-aggregate fora do escopo”). Recovery futura via comando “removi Bruno sem querer”.
  1. Criar VO PartnerInviteToken em src/contexts/household/domain/value-objects/.
  2. Estender Household (src/contexts/household/domain/Household.ts) com issuePartnerInvite({today}) + partnerInvites() + consumePartnerInvite(token, today) + estado interno privado _partnerInvites: PartnerInviteToken[].
  3. Estender port HouseholdLookup (src/contexts/agent/application/HouseholdLookup.ts) com bringPartnerFromInvite({chatId, senderId, partnerToken, today?}).
  4. Estender fake InMemoryHouseholdLookup (src/contexts/agent/infrastructure/InMemoryHouseholdLookup.ts) com a impl: itera HouseholdRepository.list() chamando consumePartnerInvite; ao primeiro {ok: true}, chama addMember + linkChat, persiste via repo.save, retorna Household.
  5. Atualizar barrel agent/application/index.ts + household/domain/index.ts + household/domain/value-objects/index.ts (ou equivalentes) com novos exports.
  6. Estender WhatsAppMessageRouter (src/contexts/agent/application/whatsapp/WhatsAppMessageRouter.ts) com parse do segundo prefix /^partner-(\S+)/i + branch bringPartnerFromInvite → fallback bootstrapFromChat quando undefined. Saudação distinta pro caso success.
  7. Adapter real (SqliteHouseholdRepository) ganha persistência de partnerInvites em sessão futura — spec colocada sem doc Gherkin (ADR 003).