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-edgecontinua 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-apiestiver 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-edgeestiver 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-apiestiver 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.