# Fluxo do Lote Multi-BM — do salvar do cliente até a finalização

> Documento de referência gerado por auditoria de código em 12/07/2026. Fonte canônica do
> ciclo de vida: comentário "CICLO DE VIDA INVARIANTE" em `server/multibmDispatch.ts:18-33`.
> Toda seta do fluxograma tem âncora `arquivo:linha` na seção correspondente. Nenhum código
> foi alterado por este documento — é o mapa fiel do que está programado.

## 1. Fluxograma do ciclo de vida

```mermaid
flowchart TD
    A[Cliente salva o lote<br/>POST /api/multibm/campaigns] --> B{Validações<br/>serviço wa_multibm · template com<br/>submissão APPROVED em BM do dono ·<br/>conteúdo/termos UTILITY · header mídia ·<br/>duas etapas · lote mínimo}
    B -- falha --> B400[400/402/403]
    B -- ok --> C[Dedupe por telefone normalizado<br/>mantém 1ª ocorrência → totalNeeded]
    C --> D[Assinatura SHA-256 de idempotência<br/>owner+serviço+template+contatos<br/>bucket de 5 min]
    D --> E[(TRANSAÇÃO ATÔMICA<br/>claim idempotência PK ·<br/>débito INTEGRAL totalNeeded<br/>condicionado a saldo ·<br/>INSERT campanha + contatos pending)]
    E -- saldo insuficiente --> E402[402 sem débito]
    E -- assinatura duplicada --> EDUP[Devolve campanha existente<br/>sem débito duplicado]
    E -- ok --> F{Modo Teste<br/>do serviço?}
    F -- sim --> PM[pending_moderation]
    F -- não --> P[pending]
    PM -- admin aprova --> P

    P --> G{scheduledAt<br/>futuro?}
    G -- não --> H[Auto-dispatch imediato<br/>setImmediate fire-and-forget]
    G -- sim --> W1[Worker agendado 60s<br/>busca pending com scheduled_at vencido]
    W1 --> H

    H --> I[buildSenderPool<br/>senders ATRIBUÍDOS ao dono ·<br/>ativos · status ACTIVE ·<br/>BM ativa/ACTIVE]
    I --> J{Por BM: submissão APPROVED<br/>com assinatura de cluster<br/>compatível com o Base?}
    J -- nenhuma BM --> ERR[status error<br/>NO_ACTIVE_SENDERS]
    J -- ok --> K[status processing<br/>pendingContacts = pending SEM<br/>multibm_message dispatchedPhones]

    K --> L{{LOOP por contato}}
    L --> L1{Pedido de pausa?}
    L1 -- sim --> PAUSED[paused<br/>para com ≤1 envio de atraso]
    L1 -- não --> L2{eligible = pool com capacidade<br/>1º teto agregado da BM ·<br/>2º useWabaLimit ignora limite próprio ·<br/>3º sentToday < dailyLimit do sender}
    L2 -- vazio --> THR[throttled: break<br/>campanha SEGUE processing<br/>contatos restantes pending]
    L2 -- ok --> L3[Ordena: quality_first<br/>HIGH>MED>LOW e menor volume<br/>ou round_robin menor volume]
    L3 --> L4{Guardas: placeholders<br/>completos · botão URL dinâmica<br/>com sufixo?}
    L4 -- falha --> LF[contato failed · continue<br/>NÃO consome capacidade]
    L4 -- ok --> L5[Envio real via rate-limit<br/>compartilhado por serviço<br/>messagesPerSecond]
    L5 --> L6{Aceito pela Infobip?}
    L6 -- SENT --> L7[grava multibm_message SENT<br/>incrementa contadores do<br/>sender e da BM]
    L6 -- FAILED --> L8[grava FAILED · failedCount++<br/>NÃO consome limite diário]
    L7 --> L
    L8 --> L
    LF --> L

    THR --> W2[Worker retry 30 min<br/>processing parado >5min com<br/>pending sem mensagem → re-dispatch]
    W2 --> H
    PAUSED -- resume --> H
    PAUSED -- cancel --> CAN[cancelled<br/>não-enviados → nao_enviado<br/>ESTORNO SEMPRE dos não-enviados]

    L -- fim dos contatos --> M{Todas falharam<br/>na hora?}
    M -- sim --> FIN
    M -- não --> N[PERMANECE processing<br/>aguarda webhooks terminais]
    N --> W3[Webhook DELIVERED/SEEN/FAILED<br/>+ fallback 24h SENT→DELIVERED]
    W3 --> O{Todos os despachados<br/>com status terminal E<br/>nenhum contato sem despacho?}
    O -- não --> N
    O -- sim --> FIN[finalizarCampanhaMultibm<br/>idempotente · só age em processing]
    FIN --> Q[contacts → enviado/nao_enviado<br/>status completed + completedAt]
    Q --> R{regraCobranca<br/>= enviados?}
    R -- sim --> S[Estorno = reservados − enviados<br/>ao dono do billing]
    R -- não --> T[Sem estorno<br/>cobrança integral antecipada]
    S --> U[Log + callback ao cliente]
    T --> U
```

### Diagrama de estados

```mermaid
stateDiagram-v2
    [*] --> pending_moderation: criação com Modo Teste
    [*] --> pending: criação normal
    pending_moderation --> pending: admin aprova
    pending --> processing: dispatch (auto/worker 60s)
    processing --> paused: pause (UPDATE atômico)
    paused --> processing: resume (re-dispatch)
    paused --> cancelled: cancel (estorno não-enviados)
    processing --> processing: throttled (sem capacidade)
    processing --> completed: finalização (webhooks terminais)
    processing --> error: sem config/template/senders
    completed --> [*]
    cancelled --> [*]
    error --> [*]
```

## 2. Âncoras de código por etapa

| Etapa | Arquivo:linha |
|---|---|
| POST criação + validações | `server/routes.ts:4645-4744` |
| Dedupe por telefone → totalNeeded | `routes.ts:4755-4766` |
| Assinatura SHA-256 (bucket 5 min) | `routes.ts:4808-4823` |
| Transação atômica (claim + débito + inserts) | `storage.ts:4913-4947` (`createMultibmCampaignAtomic`) |
| Débito é SEMPRE integral antecipado | `routes.ts:4805` + `storage.ts:4937-4947` |
| Auto-dispatch (exceto agendado/moderação) | `routes.ts:4923-4938` |
| Worker agendado (60s) | `server/index.ts:852-881` (`runScheduledDispatch`) → `storage.ts:5069` |
| buildSenderPool | `multibmDispatch.ts:615-670` |
| Template físico por BM (cluster match) | `multibmDispatch.ts:633-647` + `:485-509` |
| hasCapacity (ordem: BM → useWabaLimit → sender) | `multibmDispatch.ts:722-737` |
| Loop de envio | `multibmDispatch.ts:746-933` |
| Rate-limit por serviço (fila serializada) | `multibmDispatch.ts:409-432` (`withServiceRateLimit`) |
| SENT incrementa / FAILED não conta | `multibmDispatch.ts:904-926` + `storage.ts:5029-5035` |
| Pause/Resume/Cancel | `routes.ts:4950/4976/5006` + `storage.ts:4855-4898` + `multibmDispatch.ts:275-320` |
| Throttle (break, segue processing) | `multibmDispatch.ts:760-764` + `:948-957` |
| Worker retry throttled (30 min) | `index.ts:995-1022` → `storage.ts:5090` |
| Dedupe anti-reenvio (dispatchedPhones) | `multibmDispatch.ts:690-699` |
| Finalização | `multibmDispatch.ts:147-263` |
| Contadores 24h deslizantes | `storage.ts:5026-5057` |

### Workers de fundo

| Worker | Intervalo | O que faz | Onde |
|---|---|---|---|
| Disparo agendado | 60s (1ª aos 45s) | `pending` com `scheduled_at` vencido → dispatch | `index.ts:852-881` |
| Retry throttled | 30 min (1ª aos 90s) | `processing` parado >5min com contato pending sem mensagem → re-dispatch | `index.ts:995-1022` |
| Fallback 24h | (ver index.ts:1072) | SENT antigos sem webhook → DELIVERED e tenta finalizar | `index.ts:1072+` |
| Webhook Infobip | tempo real | status terminais → quando todos terminais, finaliza | `/webhooks/infobip` |

## 3. Walkthrough do exemplo: lote de 1.000 números · 1 BM · 10 senders · 100/dia cada

Premissa: senders com `useWabaLimit=false`, `dailyLimit=100`, sem teto agregado na BM (ou teto
≥ 1000). Capacidade diária efetiva = 10 × 100 = **1.000**.

1. **Criação**: 1.000 números dedupados → `totalNeeded=1000`. Débito atômico de 1.000 créditos
   (integral, mesmo em regra "enviados" — o estorno vem depois). Status `pending`, auto-dispatch.
2. **Pool**: 10 senders elegíveis (atribuídos ao dono, ativos, ACTIVE, BM com submissão APPROVED
   compatível).
3. **Loop**: a cada contato, `eligible` é recalculado. Com `quality_first` e qualidades iguais,
   desempate por menor volume do dia → distribuição praticamente uniforme (~100/sender). Com
   `round_robin`, uniforme por construção.
4. **Consumo de capacidade**: só mensagens ACEITAS (SENT) contam. Se a Infobip recusar algumas
   (FAILED), elas NÃO consomem limite — a capacidade sobra para os demais contatos. Não existe
   reserva de capacidade: um lote de exatamente 1.000 com 100% de aceite fecha o dia justo, sem
   "contato 1001" órfão.
5. **Sender no limite**: ao atingir 100 SENT, sai do `eligible` no próximo contato (recalculado
   a cada iteração). A rotação pula ele corretamente.
6. **Se o lote fosse 1.200**: os 200 excedentes ficariam `pending` sem mensagem; campanha
   permanece `processing` (com log de throttle). O worker de 30 min re-dispara quando a
   capacidade reabre — que NÃO é à meia-noite (ver achado ⚠️ abaixo), e sim conforme a janela
   deslizante de 24h vai liberando.
7. **Finalização**: quando todos os 1.000 têm status terminal via webhook (ou fallback 24h),
   `finalizarCampanhaMultibm` marca contatos, `completed`, e estorna não-enviados apenas se
   `regraCobranca='enviados'`.

## 4. Achados da auditoria

### ✅ Sólido (verificado com evidência)

- **Anti-duplicação em retomadas**: qualquer re-dispatch (resume, retry de 30 min, restart)
  exclui telefones que já têm `multibm_message` (`dispatchedPhones`,
  `multibmDispatch.ts:695-699`); o SQL do worker de retry também filtra `mm.id IS NULL`
  (`storage.ts:5099-5105`). Contatos ficam `pending` até a finalização de propósito — é a chave
  anti-reenvio.
- **Criação idempotente**: assinatura SHA-256 com bucket de 5 min como PK — duplo-clique não
  duplica débito nem campanha (`routes.ts:4808-4823`, `storage.ts:4930-4934`).
- **Débito atômico condicionado a saldo** na mesma transação da criação
  (`storage.ts:4937-4947`) — sem estado intermediário.
- **FAILED não consome limite diário** (`storage.ts:5035`, filtro `status <> 'FAILED'`;
  incremento local só em SENT, `multibmDispatch.ts:904-909`).
- **Rotação respeita limites** por contato (recálculo de `eligible` a cada iteração, `:759`).
- **Pausa atômica** (`UPDATE ... WHERE status='processing'`, `storage.ts:4855`) + sinal em
  memória com atraso máximo de 1 envio.

### ⚠️ Comportamentos a conhecer (não são bugs, mas surpreendem)

1. **Não existe "virada de meia-noite"**. O limite "diário" é uma **janela deslizante de 24h**
   sobre `multibm_messages.sent_at` (`storage.ts:5036` e `:5057`:
   `sent_at >= NOW() - INTERVAL '24 hours'`). Um lote travado por capacidade NÃO recomeça às
   00:00 — a capacidade reabre gradualmente 24h após cada envio, e o worker de 30 min
   (`index.ts:995`) é quem re-dispara. *Decisão de produto em aberto: se o desejado for "dia
   calendário", seria preciso trocar a query por `date_trunc('day', ...)` — hoje NÃO é assim.*
2. **`useWabaLimit=true` ignora o limite individual do sender** (`multibmDispatch.ts:734`) —
   vale só o teto agregado da BM (`effectiveBmDailyLimit`). No exemplo dos 10×100: se os senders
   estiverem migrados para `useWabaLimit`, o "100/dia por número" não é aplicado.
3. **Cobrança sempre integral na criação**: mesmo com `regraCobranca='enviados'`, o débito é de
   `totalNeeded` na hora; a diferença só volta como estorno na finalização/cancelamento.

### 🔴 Furo real identificado (janela estreita de duplicação)

Entre o aceite da Infobip (`enviarMensagemTemplate` retorna SENT, `multibmDispatch.ts:864`) e a
gravação da linha `multibm_messages` (`:891`), se o processo cair (crash/deploy/restart do PM2),
a mensagem **saiu para o destinatário mas não existe registro local**. Na retomada, o telefone
continua `pending` e fora de `dispatchedPhones` → **é reenviado (o destinatário recebe 2×)**.

- Impacto: janela de milissegundos por mensagem; só materializa em crash exatamente nesse
  instante. Relatórios não contam em dobro (derivam das linhas gravadas), mas o destinatário
  recebe duplicado.
- **Recomendação (NÃO implementada — aguarda aprovação)**: padrão *outbox* — gravar a linha
  com status `PENDING_SEND` ANTES de chamar a Infobip e promovê-la a SENT/FAILED depois. Na
  retomada, linhas `PENDING_SEND` órfãs são tratadas como "estado desconhecido" (consultar
  status na Infobip ou contabilizar como despachadas para não reenviar). Alinha com a regra do
  projeto "guardas de estado devem ser UPDATE atômico condicional".

---

## 5. Simulação executável (12/07/2026) — `tests/multibmDispatchSimulation.test.ts`

Prova de que o exemplo do usuário flui sem lacunas, rodando o orquestrador REAL
(`dispatchMultibmCampaign`/`finalizarCampanhaMultibm`) contra storage 100% mockado em memória e
Infobip substituída por mock — **zero disparo real** (um guard de `global.fetch` lança se
qualquer URL "infobip" for chamada; disparou 0 vezes). 9 cenários, todos verdes.

| # | Cenário | Resultado |
|---|---------|-----------|
| 1 | **Canônico: 1000 números × 10 WABAs × 100/dia** | Exatamente **1000 envios**, **100 por BM** (distribuição uniforme), **zero telefone duplicado**; campanha fica `processing` aguardando webhook; após marcar as 1000 `DELIVERED` → `completed`, `sentCount=1000`, estorno=0. ✅ Flui perfeito. |
| 2 | 1200 contatos, capacidade 1000 | Envia 1000, **200 ficam `pending` sem mensagem**, campanha segue `processing` (throttle) — dependem do retry de 30min. Confirma a borda ⚠️. |
| 3 | **R1 — falha INFRA (429) na rajada** | 50 envios viram `FAILED` terminal; **re-dispatch NÃO reenvia nenhum** (dispatchedPhones inclui FAILED). 🔴 Perda permanente confirmada. |
| 4 | Crash no meio + retomada | 1º run cai após 500; 2º run envia só o restante → **total único 1000, nenhum telefone com 2 mensagens**. Dedupe pós-crash sólido. ✅ |
| 5 | Pausa/resume | Pausa para o loop em ~200; resume completa os 1000 **sem duplicar**. ✅ |
| 6 | **R3 — concorrência** | 2 campanhas de 600 do mesmo dono em paralelo → **total > 1000 enviados** (estouro do teto diário). 🔴 Overshoot confirmado. |
| 7 | Caveat `useWabaLimit` | `false` → sender para nos 100; `true`+BM sem teto → ignora os 100 e envia tudo. Comportamento documentado ✅. |
| 8 | **Pacing por BM (feature nova)** | `messagesPerMinute=60` (1/s) → gap ≥ ~900ms entre envios da mesma BM. ✅ Espaçamento por BM funciona. |
| 9 | **Mesmo template em 5 campanhas simultâneas** | 5 campanhas paralelas, mesmo templateId, params distintos → **cada envio leva os params da SUA campanha, cada `multibm_message` tem o `campaignId` certo, totais 100/campanha exatos**. ✅ **Zero contaminação.** |

### Resposta direta às perguntas do usuário
- **"5 campanhas ao mesmo tempo usam o mesmo template sem conflito/misturar/trocar dados?"** →
  **Sim, comprovado (cenário 9)**. O template é stateless na Meta/Infobip (cada request carrega
  `to`+nome do template+placeholders; enviamos 1 mensagem por request); no nosso código não há
  estado compartilhado por `templateId` — `deriveTemplate`/`resolvePhysical` são locais ao run e
  os parâmetros vêm dos contatos da própria campanha. **Único cuidado:** *editar* o template
  durante os disparos (a edição volta pra revisão da Meta e lotes iniciados depois usam o texto
  novo; lotes em andamento são imunes pois carregam o conteúdo no início do run).
- **"Limite de 100 por WABA, o lote usa várias BM/WABA"** → correto e simulado (cenário 1): o
  teto por WABA é `business_managers.dailyLimit/Override`, checado ANTES do limite do sender
  (`hasCapacity` :743); 10 WABAs × 100 = capacidade 1000, distribuída uniformemente.
- **"Não achei msgs/minuto na plataforma"** → agora existe: **vazão por BM em mensagens/minuto,
  padrão 1000/min** (migration 029, campo na Usina Multi-BM). A fila interna OnSMS
  (`withServiceRateLimit`) espaça os envios de cada BM independentemente; compõe com o teto do
  serviço (`messagesPerSecond`). Na Infobip o `sendingSpeedLimit` nativo só existe para bulk
  agendado (não se aplica ao nosso envio 1-a-1), por isso o controle é interno.

## 6. Confirmação em dados reais (banco dev, 12/07/2026, somente SELECT)
- **mps do serviço:** 10/s → um lote de 1000 leva ~100s de disparo. Vazão por BM: 1000/min em
  ambas as BMs (a migration 029 preencheu o default corretamente nas BMs existentes).
- **BMs:** 2 ativas, tier `LIMIT_2K` (2000/24h sincronizado da Infobip), sem override.
- **Senders:** 2, ambos **legado** (`use_waba_limit=false`) → hoje o caveat do useWabaLimit
  **não afeta** a operação (o limite do sender vale, somado ao da BM).
- **Falhas históricas:** apenas 2, ambas de NEGÓCIO (`BAD_REQUEST` / template args inválidos) —
  **nenhum 429/INFRA/timeout até hoje**. Volume real ainda é baixo (poucas mensagens), então os
  riscos R1/R3 **ainda não se materializaram** — eles aparecem AO ESCALAR para lotes grandes.
- **Campanhas presas:** nenhuma.

## 7. Barragens — tabela consolidada e priorização

| Barragem | Severidade | Materializou? | Recomendação (não implementada) |
|----------|-----------|---------------|--------------------------------|
| R1 — 429/5xx/timeout no envio queima o contato (FAILED terminal, sem retry) | 🔴 Alta | Não (baixa escala) | **P1:** usar `fetchWithRetry` (já existe) no envio com backoff+`Retry-After`; OU não gravar `FAILED` de `errorKind:INFRA` como terminal (deixar o contato re-elegível no retry). |
| R2 — `fetch` de envio sem timeout trava a fila do serviço | 🔴 Alta | Não | **P1:** `AbortController` com timeout (~30s) no envio. |
| R3 — concorrência de campanhas do mesmo dono estoura o teto diário | 🔴 Média-alta | Não | **P2:** reconciliar o contador com o banco a cada N envios, OU lock de capacidade por BM entre runs concorrentes. |
| Furo de duplicação (crash entre aceite e gravação) | 🔴 Baixa freq. | Não | **P2:** padrão outbox (gravar `PENDING_SEND` antes do envio). |
| Campanha presa em `processing` (contatos nunca despachados + sem capacidade) | ⚠️ Média | Não | **P3:** alerta/observabilidade; o retry 30min cobre a maioria. |
| Limite "diário" = janela deslizante 24h (não vira à meia-noite) | ⚠️ Design | — | Decisão de produto: manter ou trocar por dia-calendário. |
| BM sem `dailyLimit` sincronizado → sem teto agregado | ⚠️ Média | — | Garantir health-sync rodando; alertar se tier ausente. |

**Prioridade sugerida de correção (quando escalar):** R1 + R2 juntos (mesma área do envio, alto
impacto e baixo esforço reusando `fetchWithRetry`), depois R3, depois o outbox. Nenhuma
implementada nesta task — todas aguardam decisão do usuário.

---

## 8. Reforma implementada (12/07/2026) — R1/R2/R3

Os riscos auditados foram corrigidos numa reforma única. Resumo do que mudou:

### R1a — erro transitório não queima mais o contato
- Envio de template agora usa `fetchWithRetry` (retry em 429/5xx respeitando `Retry-After`) +
  timeout de 30s por tentativa (infobip.ts). Falha de INFRA **determinada** (Infobip respondeu
  429/5xx e esgotou) deleta a linha do outbox → o contato volta a ser elegível (reenviado pelo
  worker de retry), em vez de virar FAILED terminal. `errorKind: BUSINESS` (4xx de domínio)
  continua terminal (reenviar não resolve).
- **Apagão**: 15 falhas de INFRA consecutivas suspendem o run (segue `processing`); o retry de
  30min retoma quando a Infobip normaliza — não queima o lote inteiro.

### R1b + furo de crash — OUTBOX + reconciliação
- A linha `multibm_messages` nasce com status **`PENDING_SEND` ANTES** do envio e é promovida a
  SENT/FAILED depois. Crash/timeout em qualquer ponto deixa ou NADA ou uma linha PENDING_SEND —
  nunca um envio sem registro.
- Worker **reconciliador** (novo, index.ts, a cada 5min): linhas PENDING_SEND com mais de 3min
  são consultadas na **Logs API** da Infobip por destinatário (`buscarRelatorio` estendido com
  `{to, from, sentSince}`). Achou → promove (SENT + status real + messageId); não achou → deleta
  (contato re-elegível). Elimina tanto a perda quanto a duplicata do timeout ambíguo.
- `computeMultibmFinalizationResults`: PENDING_SEND sem messageId nunca é presumido entregue
  (nem no fallback 24h) — tratado como FAILED.

### R2 — timeout + paralelismo (escala)
- Timeout de 30s em todos os envios (template com retry; texto/mídia sem retry, para não
  duplicar free-form ao humano).
- A antiga fila que serializava TODOS os envios do serviço (teto real ~5/s) foi substituída por
  `reserveSendSlot` — reserva síncrona de timestamp por serviço E por BM. Envios de **BMs
  diferentes agora rodam em paralelo** (backpressure `MULTIBM_MAX_INFLIGHT`, default
  min(64, 2×nºBMs); `=1` restaura serial sem deploy). O throughput escala com o número de BMs.

### R3 — reserva de capacidade diária na criação
- Coluna `campaigns.capacity_reserved` (migration 030). Na criação do lote, dentro da transação
  atômica (com `pg_advisory_xact_lock` por serviço), calcula-se a capacidade disponível
  agregada das BMs/WABAs do dono (`Σ effectiveBmDailyLimit − enviados 24h − reservas ativas`).
  Lote acima do disponível → **402 "Capacidade diária esgotada — disponível hoje: N"**. A
  reserva é liberada (zerada) na finalização, cancelamento e erros terminais.
- Modelo conservador (reservas intersectantes contam por inteiro): nunca sobre-admite. Lotes
  **agendados** e em **moderação** não reservam na criação (janela de 24h é móvel); reservam
  best-effort no início do dispatch.

### Provas (tests/multibmDispatchSimulation.test.ts + infobipSendRetryTimeout.test.ts)
11 cenários de dispatch + 4 unitários de retry/timeout, todos verdes, sem tocar a Infobip:
INFRA determinado recupera o contato (não queima); apagão suspende; outbox promove/deleta;
concorrência ainda documentada; pacing por BM; mesmo template em 5 campanhas sem contaminação;
retry 429→200; timeout aborta. Suíte completa sem regressões (só falhas pré-existentes alheias).

**Kill-switches operacionais:** `MULTIBM_MAX_INFLIGHT=1` (volta ao envio serial). Migration 030
é aditiva; em rollback de código, limpar `PENDING_SEND` órfãos e zerar `capacity_reserved`.
