Pular para o conteúdo principal

Notificações push (proposta em discussão)

:::info Nada nesta página está implementado Este é um desenho em discussão, registrado aqui pra continuar a conversa — não existe nenhum endpoint, fila ou serviço dos descritos abaixo no código hoje. Quando (e se) isso for implementado, esta página vira a referência que descreve o que existe de verdade, igual as outras páginas desta seção. :::

Por que isso importa​

Nem toda cobrança/recarga acontece com o participante olhando pro próprio app. Dois casos concretos onde ele não está no app no momento da transação:

  • Recarga assistida pelo caixa (Caixa → Recarga) — o caixa credita a wallet, o participante só entrega o telefone/mostra o rosto, não abre o app.
  • Pagamento via QR impresso + PIN (PDV → Pagamento via QR impresso + PIN) — o participante nem precisa ter o app instalado nesse fluxo; se tiver, seria bom ele saber que foi cobrado.

No pagamento dinâmico via QR/NFC o participante já está dentro do app aprovando, então já sabe o resultado na hora, pela própria resposta da chamada de approve — mas decidimos notificar mesmo assim: funciona como um recibo persistente, do mesmo jeito que uma loja manda e-mail de confirmação mesmo depois de você ver o totalizador na maquininha. Ou seja: recarga e qualquer pagamento por QR (impresso+PIN ou dinâmico) disparam notificação, independente do canal escolhido pra entregar.

Decisão 1: onde a fila vive​

Já existe um mecanismo parecido no sistema: a outbox (ops.outbox_events + OutboxDispatcherService), que hoje só sincroniza wallet.activated pro diretório global. Duas opções:

Opção A — generalizar a outbox existente (Postgres)​

Adicionar mais tipos de evento na mesma tabela, trocar o dispatcher por um roteamento por tipo. Simples de fazer, mas herda uma limitação já documentada: Limites conhecidos → outbox dispatcher throughput — entrega sequencial, poll fixo, tudo rodando dentro do próprio processo do wallet-api (setInterval vivo o tempo todo, consumindo recursos da instância mesmo sem nada pra entregar).

Opção B — Cloudflare Queues (recomendada)​

Move a fila pra fora do processo do wallet-api inteiramente. Retry com backoff, lote e Dead Letter Queue são gerenciados pelo Cloudflare — nada disso precisa ser escrito ou mantido por nós. Serverless de verdade: o consumidor só roda quando tem mensagem, sem processo ocioso.

Limitação importante que molda o desenho: só é possível produzir mensagem de dentro de um Worker (env.QUEUE.send(...), via binding) — não existe API HTTP pra um serviço de fora (como o wallet-api, que é NestJS no Cloud Run, não um Worker) escrever direto na fila. Por isso o wallet-edge entra no meio: ele já é Worker, e já existe o padrão de wallet-api chamar ele via HTTP assinado (ver PaymentIntentEdgeNotifierService).

wallet-api (charge/recarga fecha)
│ chamada interna assinada (mesmo padrão do notifier de payment intent,
│ fire-and-forget, nunca bloqueia a resposta HTTP)
▼
wallet-edge ──produz──► Cloudflare Queue
│
▼
Worker consumidor (push-based, em lote)
│
busca token/contato do participante
▼
wallet-control-api
│
dispara pelo canal certo
▼
Expo Push (hoje) / SMS / e-mail (depois)

Trade-off: mais um salto de rede (wallet-api → wallet-edge → fila), e entrega é at-least-once — o consumidor precisa ser idempotente (mesmo princípio já usado em toda Idempotency-Key do sistema), pra não mandar o mesmo push duas vezes numa retentativa.

Escala: limite de 5.000 mensagens/segundo por fila — folga enorme pro volume que estamos planejando (5.000 transações/minuto).

Decisão 2: o Worker consumidor faz tudo, ou só repassa?​

Depois que a mensagem chega no consumidor (Worker), quem decide o canal e dispara a notificação de fato?

Opção A — Worker nativo (faz tudo)​

O próprio consumidor busca o token no wallet-control-api e chama a API do Expo direto do edge.

  • ❌ Coloca lógica de negócio (qual canal, qual sender, regra de quando notificar) dentro do wallet-edge — que hoje é deliberadamente "burro": só roteia, autentica preliminarmente e assina, nunca guarda dado nem decide regra de negócio (ver Visão geral da arquitetura). Misturar lógica de notificação ali quebra essa separação.
  • ❌ Essa lógica ficaria em Workers/TypeScript de infra, fora do padrão NestJS que já usamos em tudo (Zod, repository/service, os mesmos testes de sempre).

Opção B — Worker como relay pra um webhook (recomendada)​

O consumidor só faz mecânica de fila (lote, ack, retry) — pra cada mensagem, chama um endpoint interno assinado no wallet-control-api, e decide ack/retry pela resposta HTTP.

  • ✅ Toda a lógica de notificação (buscar token, escolher canal, disparar o sender certo) mora no wallet-control-api — mesma linguagem, mesmos testes, mesmo jeito que já fazemos tudo.
  • ✅ wallet-edge continua fino, só fazendo o que já faz hoje.
  • ✅ Continua ganhando tudo do Cloudflare Queues (retry com backoff, DLQ, lote, sem processo ocioso) — quem processa o lote de verdade continua sendo o Worker; o webhook é só o que ele chama por dentro.
  • ❌ Se o wallet-control-api estiver fora do ar/lento nesse instante, a chamada falha e o Cloudflare tenta de novo depois — isso é justamente o retry gerenciado que queremos, não uma desvantagem de verdade.

Decisão 3: separar "o que aconteceu" de "como avisar"​

O evento gravado na fila é genérico (wallet.charged, wallet.recharged) — quem decide o canal é o consumidor, através de uma interface plugável por canal (ExpoPushSender hoje; SmsSender/EmailSender depois, mesma interface). Assim, adicionar um canal novo no futuro não toca em nada no wallet-api — ele só emite o evento, nunca soube nem precisa saber quem vai notificar ou como.

Fluxo completo de exemplo: recarga​

Pra deixar concreto, o percurso completo de uma recarga pelo caixa — em duas fases claramente separadas: a recarga em si (síncrona, já existe hoje sem mudar nada) e a notificação (assíncrona, nova, nunca trava a primeira).

Fase 1 — a recarga em si (síncrona, sem mudança)​

App do caixa
│ POST /v1/events/{eventId}/cashier/topups
▼
wallet-edge ── único ponto público, roteia por eventId → região certa
│ proxy assinado (X-Origin-Signature)
▼
wallet-api (regional)
│ CashierTopupsController → CashierTopupsService.recharge()
│ transação: lock wallet FOR UPDATE → ledger.transactions
│ (type='recharge') + 2 ledger.entries → wallet_balances →
│ idempotency key → COMMIT
▼
resposta HTTP volta (walletId, availableAmountMinor, replayed)
│
▼
wallet-edge → App do caixa

Fase 2 — a notificação (nova, assíncrona)​

Logo depois do COMMIT (mesmo ponto onde hoje chama completeIdempotencyKey, antes do return) — await com timeout curto, nunca lança erro, nunca atrasa a resposta pro caixa (mesmo padrão do PaymentIntentEdgeNotifierService):

wallet-api (logo após o commit da recarga)
│ chamada interna assinada (mesmo esquema já usado hoje,
│ fire-and-forget, timeout curto)
▼
wallet-edge — rota nova: POST /internal/v1/notifications/enqueue
│ valida assinatura (mesmo padrão de payment-intent-notify.ts:
│ verifyControlPlaneNotifyRequest)
│ env.NOTIFICATIONS_QUEUE.send({ type: "wallet.recharged", userId, eventId, amountMinor, ... })
│ responde 204 IMEDIATAMENTE — não espera a fila ser consumida
▼
resposta 204 volta pro wallet-api (só confirma que ENFILEIROU)

A resposta HTTP da recarga pro caixa já foi entregue antes ou junto disso — o caixa nunca espera nada da Fase 2.

A partir daqui, desconectado de qualquer requisição HTTP — é o próprio Cloudflare que decide quando processar, em lote:

Cloudflare Queue
│ Cloudflare invoca o handler de fila do wallet-edge
│ (mesmo Worker, export `queue()` — invocação independente,
│ não é chamada por nenhum código nosso)
▼
wallet-edge (consumidor) — Decisão 2, Opção B
│ pra cada mensagem do lote:
│ chamada interna assinada → wallet-control-api
▼
wallet-control-api — rota nova: POST /internal/v1/notifications/dispatch
│ busca push token(s) do userId (tabela nova, app.push_tokens)
│ decide canal (Expo Push hoje) → chama API do Expo
│ responde 200/204
▼
wallet-edge: msg.ack() se deu certo, msg.retry({delaySeconds}) se falhou
(Cloudflare cuida do backoff/DLQ automaticamente)
▼
Expo → APNs/FCM → notificação aparece no celular do participante

Pagamento por QR (impresso+PIN ou dinâmico) segue o mesmo desenho, só troca o ponto de disparo da Fase 2: depois de PdvPaymentsRepository.chargeWallet (impresso) ou WalletPaymentIntentsRepository.approve (dinâmico), em vez de depois do recharge.

Pontos-chave:

  • A transação financeira nunca espera a notificação — são dois ciclos de vida HTTP completamente separados.
  • Se wallet-edge estiver fora do ar no momento do enqueue, a notificação simplesmente não acontece pra aquela transação — mesma degradação graciosa já aceita hoje pro aviso de payment intent; a transação em si nunca é afetada.
  • Se wallet-control-api estiver fora do ar/lento no momento do consumo, o Cloudflare tenta de novo sozinho, sem nenhum código nosso rodando em loop.

O que dispara notificação (e o que não)​

  • Dispara: recarga concluída, pagamento aprovado — QR impresso ou dinâmico, os dois — a pessoa recebe uma confirmação/recibo.
  • Provavelmente não dispara (ainda em aberto): recusas (saldo insuficiente, PIN bloqueado, etc.) — quem está sendo recusado já está na hora, cara a cara com o caixa/PDV, sabendo do resultado ali mesmo; um push a mais não agrega.

Onde mora o token do dispositivo​

Token de push é um dado global por usuário (não por evento/região) — faz sentido morar no wallet-control-api, que já é dono da identidade do participante, não no wallet-api regional. Precisa de uma tabela nova (algo como app.push_tokens, um participante pode ter mais de um dispositivo) e um endpoint pro app registrar o token depois do login.

Perguntas em aberto​

  • Catálogo exato de tipos de evento — só recarga/pagamento, ou também estorno quando existir?
  • Consentimento explícito (permissão do SO) já é padrão do Expo/RN — mas vale um toggle de preferência do usuário (“quero receber notificação de cobrança”) desde o início, ou isso fica pra depois?
  • Conteúdo da notificação: mostrar o valor cobrado é ok, mas mostrar saldo atual na tela de bloqueio é uma escolha de privacidade a decidir.
  • SMS/e-mail dependem de telefone/e-mail verificado — hoje o cadastro do participante é só telefone (OTP); e-mail nem existe como campo ainda.