Skip to content

016 — Referral attribution (sem fila) — share link, attribuição append-only, round-trip persistência

CEO matou a waitlist em 2026-06-03: “bobeira ter waitlist, vamos fazer landing final direta whats”. A versão anterior do 016 modelava Waitlist/WaitlistEntry/position/register — toda essa mecânica de fila foi removida integralmente. Casais agora entram direto via whats (wa.me/<bot>?text=..., ADR 007), sem fila intermediária, sem position, sem email obrigatório (ADR 006).

Mas attribution de referrals continua importando, por três motivos:

  1. K-factor analytics (CEO doc) — quem trouxe quantos casais, viralidade real medível.
  2. Contexto de onboarding amigável — quando o casal novo chega no chat, o agente sabe “te convidou Ana e Bruno” e pode personalizar a primeira interação.
  3. Audit log futuro — rastreabilidade de qual casal indicou qual, sem PII além do Household.id.

Este cenário modela o domain mínimo que sustenta esses três usos. O aggregate Referrals orquestra ReferralLinks emitidos por Households existentes e ReferralAttributions registradas quando um novo Household nasce via referral. Sem position, sem fila, sem email, sem coupleName. Sem register ou linked. Apenas:

  • emitir link (issue) — idempotente por ownerHouseholdId (mesmo casal sempre tem o mesmo token).
  • registrar attribuição (attribute) — append-only fact: “novo Household X veio via shareToken Y”.
  • consultarfindByShareToken, findAttributionsForInviter, list, size.

Persistência via ReferralsRepository segue o molde do 007 / 010 (port em application/, fake em infrastructure/).

Migration note (supersedes versão anterior do 016)

Section titled “Migration note (supersedes versão anterior do 016)”

A versão anterior deste cenário modelava Waitlist (fila) com register({email, coupleName, ref?}), position mutável, delta -5 quando referrer indicava alguém, idempotência por email, e lookup por email. Tudo removido integralmente:

  • Waitlist aggregate → renomeado Referrals (mesmo pattern singular orquestrando coleção).
  • WaitlistEntry entity → renomeado ReferralLink (entity simples, não carrega position nem email).
  • register({email, coupleName, ref?}) → split em issue({ownerHouseholdId}) + attribute({inviteeHouseholdId, shareToken}).
  • position field → removido. Não há fila.
  • findByEmailremovido. Email não vive aqui (anonymous-first, ADR 006).
  • linked / referrer no result → removidos. attribute retorna ReferralAttribution direto ou undefined.
  • Delta hard-coded -5removido. Sem reordenação porque não há ordem.
  • Idempotência por email → substituída por idempotência por ownerHouseholdId (chave estável do Household).
  • ShareToken VO → preservado (mesma regra: non-empty, sem espaço).

Sem aliases, sem back-compat. Old waitlist mechanic está morta.

Section titled “Scenario: Casal pede share link, recebe ShareToken”
  • Given um Household “Ana e Bruno” existe com id = "hh-ana-bruno" (criado fora do contexto referrals — via chat-as-onboarding, ADR 006)
  • And referrals.size() === 0
  • When chamamos referrals.issue({ ownerHouseholdId: "hh-ana-bruno" })
  • Then result.link.shareToken é um ShareToken non-empty (string sem espaço)
  • And result.link.ownerHouseholdId === "hh-ana-bruno"
  • And result.link.id é um UUID estável (sobrevive a round-trip)
  • And result.link.createdAt é uma Date (now-ish)
  • And referrals.size() === 1
Section titled “Scenario: Idempotência por ownerHouseholdId — mesmo casal retorna mesmo link”
  • Given referrals.issue({ ownerHouseholdId: "hh-ana-bruno" }) já gerou um link com shareToken = tok_abc123
  • When chamamos referrals.issue({ ownerHouseholdId: "hh-ana-bruno" }) uma segunda vez
  • Then result.link.shareToken.equals(tok_abc123) (mesmo token)
  • And result.link.id é o mesmo id do link anterior (mesma entity, não duplicada)
  • And referrals.size() === 1 (não cresceu)

Scenario: Attribuição de referral quando inviter existe

Section titled “Scenario: Attribuição de referral quando inviter existe”
  • Given Household “Ana e Bruno” (hh-ana-bruno) tem um ReferralLink com shareToken = tok_abc123
  • And Household “Carlos e Dani” (hh-carlos-dani) acabou de nascer via whats (composition root passa o shareToken do deep link pro contexto referrals)
  • When chamamos referrals.attribute({ inviteeHouseholdId: "hh-carlos-dani", shareToken: ShareToken.of("tok_abc123") })
  • Then result é um ReferralAttribution definido (não undefined)
  • And result.inviter === "hh-ana-bruno"
  • And result.invitee === "hh-carlos-dani"
  • And result.attributedAt é uma Date (now-ish)
  • And referrals.findAttributionsForInviter("hh-ana-bruno").length === 1
  • And referrals.findAttributionsForInviter("hh-ana-bruno")[0] é o result

Scenario: Token inválido — attribute retorna undefined sem throw

Section titled “Scenario: Token inválido — attribute retorna undefined sem throw”
  • Given nenhum ReferralLink com shareToken = tok_nope existe
  • When chamamos referrals.attribute({ inviteeHouseholdId: "hh-x", shareToken: ShareToken.of("tok_nope") })
  • Then result === undefined (silent, sem throw — UX: link expirado/corrompido não quebra onboarding)
  • And referrals.findAttributionsForInviter("hh-x").length === 0 (nada foi gravado)
Section titled “Scenario: Round-trip persistência preserva links e attributions”
  • Given uma instância de Referrals com 2 ReferralLinks (Ana e Bruno + Carlos e Dani) e 1 ReferralAttribution (Ana indicou Eve)
  • And um InMemoryReferralsRepository
  • When chamamos repo.save(referrals)
  • And depois repo.load(referrals.id)
  • Then loaded.size() === 2
  • And loaded.findByShareToken(anaToken) retorna o ReferralLink da Ana (mesmo id, mesmo ownerHouseholdId, mesmo createdAt)
  • And loaded.findAttributionsForInviter("hh-ana-bruno").length === 1
  • And essa attribution preserva inviter, invitee e attributedAt
  • And repo.list() retorna [referrals] com 1 elemento
src/contexts/referrals/domain/value-objects/ShareToken.ts
class ShareToken {
static of(value: string): ShareToken; // non-empty, sem espaço
toString(): string;
equals(other: ShareToken): boolean;
}
// src/contexts/referrals/domain/value-objects/ReferralAttribution.ts
interface ReferralAttribution {
readonly inviter: string; // ownerHouseholdId do referrer
readonly invitee: string; // householdId do casal novo
readonly attributedAt: Date;
}
// src/contexts/referrals/domain/ReferralLink.ts
interface ReferralLink {
readonly id: string; // UUID estável
readonly shareToken: ShareToken;
readonly ownerHouseholdId: string;
readonly createdAt: Date;
}
// src/contexts/referrals/domain/Referrals.ts
class Referrals {
readonly id: string; // UUID estável (overload externo pra reconstrução)
static create(input?: { id?: string }): Referrals;
issue(input: { ownerHouseholdId: string }): { link: ReferralLink };
attribute(input: {
inviteeHouseholdId: string;
shareToken: ShareToken;
}): ReferralAttribution | undefined;
findByShareToken(token: ShareToken): ReferralLink | undefined;
findAttributionsForInviter(ownerHouseholdId: string): ReferralAttribution[];
list(): ReferralLink[];
size(): number;
// pattern 007: serialize/rehydrate pro adapter
serializeLinks(): Array<{ ... }>;
serializeAttributions(): Array<{ ... }>;
static rehydrate(input: {
id: string;
links: Array<{...}>;
attributions: Array<{...}>;
}): Referrals;
}
// src/contexts/referrals/application/ReferralsRepository.ts
interface ReferralsRepository {
save(referrals: Referrals): Promise<void> | void;
load(id: string): Promise<Referrals | undefined> | Referrals | undefined;
list(): Promise<Referrals[]> | Referrals[];
}
  • Aggregate root Referrals — singular orquestrando coleção, mesmo padrão de Budget (singular orquestrando RecurringExpense) e da Waitlist anterior. Uma instância em produção (singleton-ish), mas repo.list() mantido por consistência com 007/010 + tenancy futura.
  • Entity ReferralLink — id UUID estável, imutável após criação. Não tem mais position (não há fila); não tem email/coupleName (anonymous-first). Verbos do cenário (“emitir” + “consultar”) justificam Entity (id estável necessário pra round-trip e dedupe por ownerHouseholdId).
  • VO ReferralAttribution — fato append-only. POJO simples com inviter, invitee, attributedAt. Sem id próprio (chave natural = par inviter+invitee+attributedAt, sem necessidade de revogação até cenário pedir). Mesmo trajeto do IncomeEntry no 014 (gotcha “Append-only VO até verbo ‘editar’ aparecer”).
  • VO ShareToken — preservado da versão anterior. Wrapper fino sobre string, regra non-empty + sem espaço, .toString() como fronteira pública de serialização. Atravessa boundary cross-context (wa-001 consome via deep link param, landing-002 consome via query string).
  • HouseholdId é string opaca aqui — não vira VO. Segue gotcha “Strings opacas viram VO só com verbo”: referrals só compara igualdade, não tem regra de formato nem aritmética sobre ids. Promove a VO se aparecer caso (ex: validação de UUID strict, mapping para waid, namespacing por tenant).
  • Referrals.id UUID default + overload externo — simétrico a Budget.id (007), Goal/Household/Account (010), e à Waitlist.id anterior. Referrals.create() gera UUID; Referrals.create({ id }) reconstrói pelo adapter.
  • serialize* + rehydrate({...id, ...}) — pattern recorrente do adapter (gotcha “serializeX() + rehydrate({...id, X}) pareados pra adapter SQLite”). Dois arrays serializados (links + attributions) porque são duas coleções independentes dentro do aggregate.
  • Idempotência por ownerHouseholdId — primeira consulta antes de criar link novo. Substitui idempotência por email da versão anterior. Chave estável + sem PII + alinhada com anonymous-first.
  • Port em application/, Entity/aggregate/VO em domain/. Mesma régua do 007: domain é puro, application é infra-facing. Domain nunca importa de application nem infrastructure.
  • Test fake é adapter legítimo, não mock inline. InMemoryReferralsRepository em referrals/infrastructure/ segue o pattern dos repositórios anteriores (007/010). Mock inline na spec seria mais barato hoje, mas o fake vira reuso quando outros cenários (analytics K-factor, dashboard CMO) precisarem.
  • attribute silent on invalid token — UX (parceiro chega com link expirado / corrompido) não quebra. Domain não loga (sem dependência de logger); caller (composition root no whats adapter) decide se instrumenta. Mesma régua de ExpenseReconciler que silenciosamente não casa o que não bate.
  • Split de register em issue + attribute — verbos separados pra responsabilidades separadas. issue emite link (idempotente por owner); attribute registra fato append-only. Promove invariante “unique (inviter, invitee)” se aparecer demanda real.
  • Sem revoke / rotate de token. Token vive enquanto o link vive. Promove se aparecer caso (“share link expira em 30 dias”, “casal quer trocar token após vazamento”).
  • shareToken gerado pelo aggregate, não passado de fora — domain controla unicidade interna. Caller (UX) só recebe.
  • Não cria adapter SQLite agora. Round-trip aqui usa só o fake. Quando aparecer primeiro consumer prod (whats bot precisando persistir attributions entre restarts), cria SqliteReferralsRepository + spec colocada (ADR 003).
  • landing-001 (hero CTA) — refactored em paralelo. CTA primário é deep link wa.me/<bot>?text=..., não form de waitlist. Backend mock de POST /api/waitlist removido. Se manter algum tipo de form de email, fica como secondary path / nurturing (fora deste cenário).
  • landing-002 (share viral) — refactored em paralelo. Visitante chega com ?ref=<shareToken> → CTA deep link já carrega o token (ex: wa.me/<bot>?text=START%20<token> ou similar). Attribuição acontece dentro do whats flow (composition root chama referrals.attribute(...) quando o Household novo é criado), não na landing.
  • wa-001 (onboarding via whats) — composition root resolve fluxo: (a) chat novo sem Household → cria Household via chat-as-onboarding; (b) se mensagem inicial contém <shareToken> → chama referrals.attribute({ inviteeHouseholdId, shareToken }) após criar o Household. Token inválido = silent skip.
  • CEO doc (K-factor analytics) — dashboard consome referrals.findAttributionsForInviter(...) + agrega. Sem mudança no domain core.
  • 007 / 010 — sem touchup. Quinto consumer do pattern Repository, mesmo molde.
  • Decisão 006 (anonymous-first) — referenced lateralmente: Household.id (que mapeia 1:1 com resourceId) é a chave usada aqui.
  • Decisão 007 (whats canal) — referenced lateralmente: deep link wa.me/<bot>?text=... carrega o shareToken como param de attribution, não como onboarding-token-via-fila.
  • Expiração de shareToken — token vive enquanto o link vive. Sem TTL, sem revogação, sem rotate. Promove se aparecer caso.
  • Single-use vs multi-use de token — token reusável N vezes (cada uso adiciona attribuição). UX viral por design. Anti-abuse fica em layer separada.
  • Tenancy / multi-Referrals — domain suporta repo.list() por consistência mas em produção é singleton.
  • Notificação ao inviter quando o invitee chega — efeito colateral fora do aggregate. Caller (worker / agent) decide.
  • K-factor analytics agregadas — métricas (total convites, conversion rate) ficam em layer de read-model / dashboard, não no aggregate. Domain expõe primitive (findAttributionsForInviter); agregação vive fora.
  • HouseholdId como VO — string opaca por enquanto.
  • Adapter SQLite real (SqliteReferralsRepository) — fica em spec colocada futura (ADR 003).
  • Reconciliação com fluxo de Waitlist legacy — fila foi killed integralmente, não há código de migração porque não havia produção.
  1. Criar ShareToken VO em src/contexts/referrals/domain/value-objects/.
  2. Criar ReferralAttribution VO em src/contexts/referrals/domain/value-objects/.
  3. Criar ReferralLink Entity em src/contexts/referrals/domain/.
  4. Criar Referrals aggregate root em src/contexts/referrals/domain/ com issue/attribute/findBy*/findAttributionsForInviter/list/size + serializeLinks/serializeAttributions/rehydrate.
  5. Criar barrel src/contexts/referrals/domain/index.ts.
  6. Criar port ReferralsRepository em src/contexts/referrals/application/.
  7. Criar fake InMemoryReferralsRepository em src/contexts/referrals/infrastructure/ até este spec passar (tier domain).
  8. Adapter SqliteReferralsRepository + spec colocada em sessão futura quando aparecer primeiro consumer prod.