# BILLING_BROKER — Precificação por mensagem

| Campo | Valor |
| :---- | :---- |
| Status | MVP implementado (cálculo + ledger + API parcial + webhook) |
| Go-live alvo | 2026-10-01 |
| ADR | [ADR-001](../adr/ADR-001-billing-broker.md) |

## Escopo

| Incluído | Excluído / futuro |
| :------- | :---------------- |
| Cálculo PMP (Meta + Gupshup) a partir de status `delivered` com `pricing` | Modelo legado conversation-based (CBP) |
| Ledger `billing_events` + rate cards / partner fees / markup / settings | Meta Business Agent (billing token) — v1 |
| API REST de consulta + cadastro de rate card + settings | FX na consulta; reconciliação diária; billing de calls |
| Webhook outbound `BILLING_UPDATE` (opt-in em `signed_events`) | Cobrança de voz / outros produtos não-mensagem |

**Público-alvo:** desenvolvedores OmniMeta, produto cliente (OmniPlus), QA e suporte técnico.

## Objetivo

1. Centralizar billing no OmniMeta como **broker** dos dados Meta/Gupshup (e futuros parceiros).
2. Expor custo real (`meta_fee + partner_fee + omni_fee`) de forma unificada.
3. Permitir alteração de preços sem deploy (collections com vigência).
4. Notificar o cliente por mensagem (`BILLING_UPDATE`) e permitir consulta histórica via API.

## Estado atual (evidência no código)

| Tema | Situação | Arquivo / evidência |
| :--- | :------- | :------------------ |
| Campo `pricing` em messages | Metadados do parceiro; sem valor monetário | `api/app/Models/Message.php` |
| Hook de billing (Meta/status) | Após `delivered` + `pricing` em `MessageProcessing` | `MessageProcessing::dispatchBillingCalculation` |
| Hook de billing (Gupshup) | Inbound `billing-event` (PMP) normalizado → `BillingEventProcessing` | `NormalizeIncomingWebhookPayloadUseCase`, `BillingEventProcessing` |
| Cálculo + idempotência + revision | `CalculateBillingEventUseCase` (`delivered` ou `source=webhook_billing`) | `api/src/Core/UseCase/Billing/` |
| Webhook outbound | `BILLING_UPDATE` + use case dedicado | `WebhookEventsEnum`, `BillingWebhookTriggerUseCase` |
| Front — assinatura do evento | Opção `BILLING_UPDATE` no webhook trigger | `front/.../WebhookSignedEventsOptions.ts` |
| API | events, summary, rate-cards (list/create), settings | `BillingController`, `routes/api.php` |
| Seed produção (defaults) | `billing:seed-default-prices` / `BillingDefaultPricesSeeder` | BR/USD list rates + fee Gupshup |
| Seed de exemplo (dev) | `billing:seed-example-prices` — **não usar como prod** | `SeedBillingExamplePricesCommand` |
| Backfill | `billing:resolve-pending` | `ResolvePendingBillingEventsCommand` |
| Bruno | Pasta Billing com requests + passo a passo | `request-collections/OmniMeta/Bruno/Billing` |

## Arquitetura

```mermaid
flowchart TD
  WH[Webhook inbound Meta/Gupshup] --> Norm[Normalize + ProcessWebhook]
  Norm --> MsgProc[MessageProcessing delivered+pricing]
  Norm --> BillProc[BillingEventProcessing billing-event]
  MsgProc --> Msg[(messages.pricing)]
  BillProc --> Msg
  MsgProc --> Calc[CalculateBillingEventUseCase]
  BillProc --> Calc
  Settings[(billing_settings)] --> Calc
  Rate[(billing_rate_cards)] --> Calc
  Fees[(billing_partner_fees)] --> Calc
  Markup[(billing_markup_rules)] --> Calc
  Calc --> Ledger[(billing_events)]
  Ledger --> API[GET /api/v1/billing/*]
  Ledger --> WHOut[BILLING_UPDATE webhook]
  WHOut --> Client[Produto cliente]
  API --> Client
```

### Camadas

| Camada | Responsabilidade |
| :----- | :--------------- |
| `MessageProcessing` | Handoff após delivered + pricing (try/catch + `report`) |
| UseCase `Billing` | Gate free, resolve preços, cria/revisa, dispara webhook |
| Domain / Repository | Entities camelCase; Mongo snake_case |
| API Resource | REST snake_case |
| Webhook trigger | Payload outbound camelCase |

## Collections MongoDB

Convenção: plural `snake_case`. Ver [NAMING_CONVENTIONS](../standards/NAMING_CONVENTIONS.md).

### `billing_events` (ledger)

```json
{
  "id": "uuid",
  "idempotency_key": "gupshup:wamid123:utility",
  "revision": 1,
  "status": "confirmed",
  "message_id": "uuid",
  "wamid": "wamid123",
  "gs_id": "ee4a...",
  "partner": "gupshup",
  "whatsapp_account_id": "uuid",
  "client_id": "uuid",
  "phone_number": "5511999999999",
  "billable": true,
  "pricing_model": "PMP",
  "pricing_type": "regular",
  "category": "utility",
  "country": "BR",
  "tier": "0:100000",
  "currency": "USD",
  "meta_fee": 0.0068,
  "partner_fee": 0.0010,
  "omni_fee": 0.0000,
  "total": 0.0078,
  "applied_rules": {
    "rate_card_id": "uuid",
    "partner_fee_id": "uuid",
    "markup_rule_id": null
  },
  "price_resolved": true,
  "occurred_at": "2026-10-01T12:00:00Z",
  "source": "webhook_status",
  "raw": {},
  "created_at": "...",
  "updated_at": "..."
}
```

**Fórmula:** `total = meta_fee + partner_fee + omni_fee`.

| status | Significado |
| :----- | :---------- |
| `confirmed` | Vigente (em geral revision 1) |
| `revised` | Houve correção; `revision` > 1 |
| `void` | Anulado (não soma no summary) |

**Retenção:** desacoplada de `messages` (ledger próprio).

### `billing_rate_cards`

Preço **base Meta** (por market/category/tier) com vigência. Vira `meta_fee`.

Campos: `partner`, `currency`, `market`, `category`, `tier`, `amount`, `valid_from`, `valid_to`, `source`.

**Não sobe sozinho em produção** — migrate cria collection vazia. Cadastrar via API ou Mongo. O comando `billing:seed-example-prices` só cria exemplos de dev se ainda não existirem.

### `billing_partner_fees`

Taxa do BSP (ex.: Gupshup) — `fixed` ou `percent`. Vira `partner_fee`. Para Meta Cloud API direto: `partner_fee = 0`.

### `billing_markup_rules`

Markup OmniMeta futuro. Sem regra ativa → `omni_fee = 0`. Collection/repos existem; **sem CRUD HTTP** no MVP.

### `billing_settings`

| Campo | Padrão | Efeito |
| :---- | :----- | :----- |
| `include_non_billable` | `false` | Free (`billable=false`) fora do ledger/webhook |
| `webhook_granularity` | `per_message` | 1 evento = 1 callback |
| `resend_on_revision` | `true` | Revisão reenvia `BILLING_UPDATE` |

Escopo: `whatsapp_account` → `client` → `global`.

## Meta vs Gupshup

| | Meta | Gupshup |
| :- | :--- | :------ |
| `partner` no evento | `meta` | `gupshup` |
| `meta_fee` | rate card `partner=meta` | idem (custo Meta) |
| `partner_fee` | `0` | `billing_partner_fees` |
| Filtro API | `?partner=meta` | `?partner=gupshup` |
| `idempotency_key` | `meta:wamid:category` | `gupshup:wamid:category` |

Sem filtro, a API retorna o ledger unificado.

## Idempotência e revisão

1. Chave: `{partner}:{wamid}:{category}`.
2. Primeiro delivered elegível → `revision=1` + webhook.
3. Mesmo fato com fees diferentes → `revision++`, `status=revised`, reenvia webhook (se `resend_on_revision`).
4. Cliente deduplica por `eventId` e aplica a maior `revision`.

Sem rate card vigente: cria com `price_resolved=false`; `billing:resolve-pending` tenta recalcular.

## Webhook `BILLING_UPDATE`

- Enum + opção no front (webhook trigger).
- Opt-in: precisa estar em `signed_events`.
- Payload outbound **camelCase**.

```json
{
  "id": "req-uuid",
  "event": "BILLING_UPDATE",
  "subscriptionId": "uuid",
  "timestamp": 1759320000,
  "billing": {
    "eventId": "uuid",
    "revision": 2,
    "status": "revised",
    "messageId": "uuid",
    "wamid": "wamid123",
    "partner": "gupshup",
    "whatsappAccountId": "uuid",
    "clientId": "uuid",
    "billable": true,
    "category": "utility",
    "pricingType": "regular",
    "country": "BR",
    "tier": "0:100000",
    "currency": "USD",
    "metaFee": 0.0068,
    "partnerFee": 0.0010,
    "omniFee": 0.0000,
    "total": 0.0078,
    "occurredAt": "2026-10-01T12:00:00Z"
  }
}
```

## API REST (implementada)

Prefixo: `/api/v1`. Auth: `auth:api` + `verified`. Respostas snake_case.

| Método / rota | Uso |
| :------------ | :-- |
| `GET /billing/events` | Lista paginada |
| `GET /billing/events/{id}` | Detalhe do evento atual |
| `GET /billing/summary` | Agregados (`count`, `total`; `group_by` opcional) |
| `GET /billing/rate-cards` | Lista rate cards |
| `POST /billing/rate-cards` | Cria rate card |
| `GET /billing/settings` | Lê settings por escopo |
| `PUT /billing/settings` | Upsert settings |

Query params de lista/summary: `from`, `to` (intervalo em `occurred_at`), `client_id`, `whatsapp_account_id`, `phone_number`, `partner`, `category`, `billable`, `group_by` (summary), `page`, `per_page`.

`phone_number` filtra pelo número real de WhatsApp (ex.: `5511999999999`), não pelo `whatsapp_account_id` interno — mesma convenção de [Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno). Como cada `billing_event` já grava o `phone_number` real no próprio evento (uma `WhatsappAccount` pode ter vários números), o filtro é direto na collection — não precisa resolver `phoneId`/`whatsappAccountId` antes.

### Como trocar preço

1. Preferir **novo** rate card com `valid_from` novo (`POST /billing/rate-cards`).
2. Opcional: encerrar o anterior com `valid_to`.
3. Eventos já gravados não mudam sozinhos; pendentes com `price_resolved=false` → `php artisan billing:resolve-pending`.

Não há variável de ambiente de preço. Preços vivem no Mongo.

## Gupshup inbound `billing-event` (PMP)

Fonte oficial: [PMP Events](https://partner-docs.gupshup.io/docs/pmp-events-1).

No PMP, a Gupshup envia `type: billing-event` com `deductions` (`billable`, `category`, `model=PMP`, `type=regular|…`) e `references` (`id`/`gsId`). Isso **não traz valor em USD** — confirma o fato de cobrança; o OmniMeta aplica rate card + partner fee.

Fluxo: callback Gupshup → `NormalizeIncomingWebhookPayloadUseCase` → field `billing-event` → `BillingEventProcessing` → `CalculateBillingEventUseCase` (`source=webhook_billing`) → ledger + `BILLING_UPDATE` outbound.

Calls (`conversation_type=call`) são ignorados no v1. Subscription Gupshup já inclui modo `BILLING` na criação do webhook config.

## Go-live (produção)

1. Deploy + `php artisan migrate`.
2. `php artisan billing:seed-default-prices` (defaults BR/USD; ajustar depois se necessário).
3. Assinar `BILLING_UPDATE` nos webhook triggers das WABAs.
4. Validar Meta: delivered+pricing → ledger → outbound.
5. Validar Gupshup: `billing-event` inbound → ledger → outbound.
6. Monitorar Sentry/logs (`report` nos hooks não quebra o fluxo de mensagem).

## Pendências conhecidas

| Item | O que é | Status |
| :--- | :------ | :----- |
| FX na consulta | Converter `total` para outra moeda só na leitura (ledger continua na moeda de origem). Query `currency` + campos `fx_*` | Adiado (padrão USD end-to-end) |
| `waba_id` no ledger | Guardar WABA no `billing_events` para o `resolve-pending` reenviar `BILLING_UPDATE` com waba real (hoje usa skip efetivo) | Não implementado |
| Reconciliação diária | Cruzar ledger vs analytics/usage do parceiro | Fora do MVP |
| CRUD HTTP markup / partner-fees | Repos existem; rotas HTTP não | Fora do MVP atual |

## Critérios de aceite

- [x] Evento respeita `include_non_billable` / `billable`
- [x] `total = meta_fee + partner_fee + omni_fee`
- [x] Idempotência por `idempotency_key`
- [x] Revisão incrementa `revision` e pode reenviar webhook
- [x] API lista/filtra (incl. `from`/`to`, `partner`)
- [x] Markup ausente ⇒ `omni_fee = 0`
- [x] Gupshup inbound `billing-event` (PMP) → ledger / revision
- [ ] FX opcional na consulta (adiado)
- [ ] Backfill com reenvio confiável de webhook (`waba_id` no ledger)

## Referências

- Meta WhatsApp — per-message pricing / rate cards / pricing em status webhooks
- Gupshup — [PMP Events / billing-event](https://partner-docs.gupshup.io/docs/pmp-events-1)

## Histórico

| Data | Nota |
| :--- | :--- |
| 2026-07-24 | Desenho inicial (Fase 0) e convenções OmniMeta |
| 2026-07-24 | MVP: collections, calculate, hook, webhook, API parcial, seed/resolve |
| 2026-07-28 | Doc alinhada ao código: API real, Meta vs Gupshup, go-live, pendências explícitas; removidos CRUD completo de rate-cards e UI de preços do escopo documentado |
| 2026-07-28 | Inbound Gupshup `billing-event` (PMP) + seeder default de produção |
| 2026-09-02 | `GET /billing/events` e `GET /billing/summary` ganharam filtro `phone_number` (número real), seguindo a mesma convenção de [Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno) já usada por Flow e Groups — `billing_events` já gravava `phone_number` por evento, então o filtro é direto na collection, sem precisar resolver `whatsappAccountId` antes |
