# Dossiê de Paridade PROFUNDO — Alçada (maker-checker, regra por campo)

Auditoria READ-ONLY. Cada afirmação abaixo tem prova (arquivo:linha, tabela.coluna ou linha viva do DB). Sem prova = veredito INCONCLUSIVO. Convenção do CLAUDE.md: pt-br sem travessão, sem expor segredo.

Legado analisado: `/Users/luizpenha/monetarie/.scratch/legado-pix-decompiled/*.decompiled.cs` + DB vivo `crk_spi` (SQL Server) + Angular `LegadoPIX/Pix/SPI/angular`.
Nosso: `pix/backend/apps/{spi_service,settlement_service,shared}` + `pix/frontend/admin/src` + DDL `apps/shared/priv/repo/sql/mon_pix_schema_clean.sql`.

Unidade: no legado o valor de alçada/limite é REAIS decimais (`numeric(13, escala 2)`, provado em `crk_spi.dbo.SpiCadParamAlcada.VlrMinimo/VlrMaximo/VlrDiario`). No nosso, `alcada_parameters` é `numeric(18,2)` (schema sql:4630) mas o comparador usa o valor extraído do XML/params (`payments.amount`, `params["amount"]`), cuja unidade real precisa ser conferida (ver g12).

---

## 1. LEGADO — comportamento provado

### 1.1 Modelo de dados (config)
`crk_spi.dbo.SpiCadParamAlcada` (colunas vivas confirmadas):
`IdAlcada, IdInstFinanc, NmAlcada, IdPeriodoVerificacao(tinyint), NrQtdeVistos, UsuariosAlcada(varchar1000), GruposAlcada, IdSystemsAlcada, UsuariosAprovadores, GruposAprovadores, VlrMinimo(numeric), VlrMaximo, VlrDiario, HrInicioVerificacao(char5), HrFimVerificacao(char5), JsonMsgs(varchar max), FlAtiva(char1), FlEstendeLimites(char1)`.
Domínio: `SPI.Core.Domain.decompiled.cs:484` (`ParametroAlcada`) e `:587` (`ParametroAlcada2`).
Regras por mensagem: `JsonMsgs` = `List<MensagemAlcada>` (`SPI.Core.General:14416`), cada `{ CdMsg, Regras: List<PropriedadeFiltro> }`; `PropriedadeFiltro` (`:13521`) = `{ Id (caminho do campo por reflection, ex "PaymentData.IdAccountDebtor"), Operador (enumOperadorSql), Filtro: List<string> }`.
`enumOperadorSql` (`SPI.Core.General:7679`): `Igual=1, Diferente, Maior, MaiorOuIgual, Menor, MenorOuIgual, In(=7), NotIn(=8)`.
`enumPeriodoVerificacao` (`:8468`): `Diariamente=1, DiasUteis, DiasNaoUteis`.
`enumStatusAlcada` (`:8477`): `Pendente=37, Rejeitada=38, Aprovada=39`.

Config REAL viva (2 linhas em `SpiCadParamAlcada`):
- Id 14 "Cenário 4": PACS.008, `Regras=[]`, VlrMinimo=1.00, VlrMaximo=1.000.000,00, VlrDiario=15.000.000,00, janela 06:00-20:00, NrQtdeVistos=0, FlAtiva=N.
- Id 15 "cenário 2": PACS.008, Regra `{Id:"PaymentData.IdAccountDebtor", Operador:7(In), Filtro:["075480356"]}`, VlrMinimo=0, VlrMaximo=100.000, VlrDiario=1.500.000, janela 04:00-20:00, NrQtdeVistos=0, FlAtiva=N.

### 1.2 Runtime
- `SpiRegAlcada` (`RegistroAlcada` `SPI.Core.Domain:1033`): `IdRegistroAlcada, IdAlcada, IdOperacao, DtHrEntrada, DtHrFinal?, IdStatusAlcada, NrVistos, DtBase(date), ValorLimite(numeric)`.
- `SpiRegVistoAlcada` (`VistoAlcada` `:1210`): PK `(IdRegistroAlcada, IdUsuario)`, `DtHrVisto, IdStatusVisto, DsVisto`.
- `SpiRegLimitesOperacao` (`RegistroLimiteOperacao` `:1057`): acumulado por `DtBase, Cliente, ISPB, Agencia, Conta, IdAlcada`, com **coluna computada `ValorDisponivel`** cuja definição viva é:
  `case when (ValorLimite+TotalCredito-TotalDebito)>ValorLimite then ValorLimite when (...)<0 then 0 else (ValorLimite+TotalCredito-TotalDebito) end` (clamp em `[0, ValorLimite]`).
  Writers do acumulado = SPs `SPI_SPCHECAALCADACREDITO` / `SPI_SPUPDALCADACREDITO` (existem em `crk_spi.sys.procedures`; corpo não presente neste backup).

### 1.3 Motor de avaliação (na ENTRADA, antes do BACEN)
`ValidacaoAlcadaUseCase.ValidaAlcada(MessageFull)` — `SPI.Core.Worker.FilaEntrada.Application.decompiled.cs:72`:
1. `Sentido == Retorno` → sem alçada (só IDA/envio).
2. `GetAlcadaAplicavelParaOperacao` (seleção por escopo+janela+regras). Nenhuma → segue sem alçada.
3. Com `Payment.Vlr > 0`:
   - `ConsistirValorAcumulado` → `(acumuladoOk, DtBase)`.
   - `dentroFaixa = Vlr <= VlrMaximo && Vlr >= VlrMinimo`.
   - **`dentroFaixa && acumuladoOk` → Aprovada** (segue direto).
   - senão: `NrQtdeVistos==0` → **Rejeitada**; `>0` → **Pendente**.
4. Sem valor → **Pendente de aprovação**.
Aplicado em `TrataMensagem`/`GravaMensagem` (`FilaEntrada.Infrastructure:7076,7126`): `if Status != Aprovada → param.IdStatus = (enumStatusOperacao)Status`. `Verificar` (`:6121`) NÃO envia ao BACEN mensagens em `PendenteAprovacao/PendenteAprovacaoRestanteLote` (vale para LOTE: qualquer perna pendente segura tudo).

### 1.4 Seleção da alçada aplicável (escopo + horário + regra por campo)
`ValidacaoAlcadaRepository.GetAlcadaAplicavelParaOperacao` — `SPI.Core.Worker.FilaEntrada.Shared.decompiled.cs:99`:
- Filtro base: `IdInstFinanc == msg.IdInstFinanc && JsonMsgs.Contains(CdMsg) && Ativa`.
- Escopo (`:113`): `(UsuariosAlcada vazio OU contém IdUsuario) && (GruposAlcada vazio OU intersecta grupos do usuário) && (SistemasAlcada vazio OU contém IdSystem) && ChecaHorario`.
- Período: dia útil prioriza `DiasUteis+Diariamente` na janela; fora → `DiasUteis EstendeLimites`; dia não útil junta `Diariamente+DiasNaoUteis+DiasUteis EstendeLimites+overnight` (`:136`).
- `FiltraRegras` (`:200`): avalia `RegraAlcadaEvaluator.Atende` (regras por campo) e **desempate = alçada com MAIS regras vence** (`OrderByDescending Regras.Count`, `:212`).
`RegraAlcadaEvaluator` (`:301`): `ResolveCaminho` acha o campo por reflection (`a.b.c`, `?`); `ConverteParaTipo`; aplica operador (Igual/Diferente/Maior/MaiorOuIgual/Menor/MenorOuIgual/In/NotIn). TODAS as regras AND. Nuance: campo nulo (`valor==null`) ou `Filtro` vazio → regra passa vacuamente (`:325,320`).

### 1.5 Acumulado diário
`ConsistirValorAcumulado` (`Shared:215`): `DtBase = ObtemDataBase(agora, ehDiaNaoUtilOuFeriado, feriados)` (respeita janela overnight/feriado/EstendeLimites — `SPI.Core.Domain:527`); busca `RegistroLimiteOperacao` por `DtBase+Cliente(CpfCnpjDebtor)+ISPB(IspbDebtor)+Agencia(IssuerAccountDebtor)+Conta(IdAccountDebtor.TrimStart('0'))+IdAlcada`; usa `ValorDisponivel` (ou `VlrAcumulado` se sem registro); retorna `Vlr <= disponivel`.

### 1.6 Maker-checker (o visto) — `ManAlcada`
`TratamentoAlcadaUseCase.ManAlcada(ParamAcaoAlcada, idUsuario, status)` — `SPI.Core.Angular.Infrastructure.decompiled.cs:3476`. Controller `AlcadaController.Aprovar/Rejeitar` (`SPI.Web.Angular.Mensagens.Api:373,393`) fixa `status` (Aprovada/Rejeitada) e `idUsuario = claim IdUser`. Invariantes (fail-closed):
1. Só aprovadores: `UsuariosAprovadores.Contains([idUsuario]) OU GruposAprovadores intersecta grupos do usuário`; senão `NaoPermitido` (`:3498`).
2. Alçada tem que estar `Pendente`; senão "já se encontra {status}" (`:3503`).
3. Um usuário 1 voto: `Vistos.Exists(IdUsuario)` → erro (`:3508`).
4. Grava `VistoAlcada` (`DsVisto`, timestamp, status) (`:3514`).
5. Terminal: **rejeição finaliza imediato**; **aprovação finaliza quando `Vistos.Count == NrQtdeVistos`** (`:3522`).
6. **Maker != checker: `messageFull.IdUsuario == idUsuario` → NaoPermitido** (`:3529`, o autor da operação não aprova).
7. Ao finalizar Aprovada: `PaymentData.DtHrAcceptance = now` (libera envio) (`:3540`) + `MessageHistory` com ator.

### 1.7 Alerta e retenção
`WorkerEnvioAlertaAlcada` (loop ~60s, `SPI.Core.Worker.EnvioAlertaAlcada`) dispara e-mail aos aprovadores de operação pendente. Expurgo preserva `RegistroAlcada+AlcadaParaOperacao+VistoAlcada` na base histórica.

---

## 2. NOSSO — comportamento provado

Existem DOIS motores + o gate real:

### 2.1 Motor advisory `SpiService.Alcadas` (endpoint `/alcada`, NÃO no money-path)
`apps/spi_service/lib/spi_service/alcadas.ex`:
- `check_and_register/4` (`:53`): `Enum.find` do 1º parâmetro (ordem `priority asc, name asc`, `:20`) que casa `matches_amount? && matches_time? && matches_message_type?`. Match → cria `Registration` `pending`; sem match → `{:ok, :approved}`.
- `matches_amount?` (`:181`): `amount ∈ [min, max]`.
- `matches_time?` (`:187`): `Time.compare(now,start_h)>=0 && <=end_h` (naive, sem overnight/feriado/dia-útil).
- `matches_message_type?` (`:191`): lista vazia/nil = todos; senão `type in types`.
- `add_visto/3` (`:113`): `not_found` / `already_resolved` (status != pending) / **`cannot_self_approve` (`created_by == user_id`)** / insere visto (unique idx `[registration_id,user_id]`); `rejected`→terminal; aprova quando `current_vistos+1 >= required_vistos`.
- NÃO consulta `accumulated_amount`. NÃO tem escopo (users/groups/systems). NÃO tem regra por campo. NÃO checa lista de aprovadores.

### 2.2 Schema `alcada_parameters` (DDL viva, schema sql:4630)
`id, name, description, min_amount(numeric18,2 default 0), max_amount, accumulated_amount, accumulated_period(default 'daily'), required_vistos(int default 1), start_hour(time default 00:00), end_hour(time default 23:59), message_types(jsonb default []), is_active(bool), priority(int default 0)`.
`Parameter.changeset` (`apps/shared/lib/shared/schemas/alcada/parameter.ex:34`): casta só esses; `required_vistos` `validate_number greater_than: 0` (**0 vistos é PROIBIDO**); `name` @required.
NÃO EXISTEM colunas: users/groups/systems (escopo), approvers, regras por campo, período (dia-útil/não-útil), horário overnight/estende-limites.

### 2.3 Gate real de money-path `Shared.Alcada.SendGate` (flag OFF por default)
`apps/shared/lib/shared/alcada/send_gate.ex`:
- `enabled?` (`:113`): `ALCADA_PIPELINE_ENABLED` default "false" (`config/runtime.exs:84`). Flag OFF → `check` retorna `{:ok, :proceed}` para tudo (`:139`).
- `check/1` (`:137`): flag off → proceed; `system_source?` → proceed; senão `gate`.
- `@system_sources` (`:81`): `core_event payment_request recurrence return_processor inbound_processor med scheduler system automated` — **isentos por construção** (sem 2º operador).
- `evaluate/4` (`:180`): 1º parâmetro aplicável (`sort_by {priority, id}`) que:
  - `matches_amount?(param, amount)` (dentro de `[min,max]`) → `{:needs_approval, :per_message_limit}` (`:194`);
  - senão `exceeds_accumulated?` → `{:needs_approval, :accumulated_limit}` (`:197`).
- `accumulated_today/2` (`:229`): `sum(payments.amount)` para `direction=OUTBOUND && message_code == msg_type && movement_date == hoje-UTC`, **GLOBAL** (sem cliente/conta/ISPB/agência).
- `matches_time?` (`:265`): mesmo comparador naive do advisory (sem overnight/feriado/dia-útil).
- Fail-OPEN em erro (`:161`): loga e segue.

Wiring do gate:
- Tela Construir Mensagem: `SettlementServiceWeb.MessageController.do_send_message_after_validation` (`:189`) chama `SendGate.check(source: "manual")` (`:205`); needs_approval → `hold_message`; senão dispatch.
- Funil de pagamentos manual: `SpiServiceWeb.PaymentController.create` → `check_alcada` → `evaluate_alcada` chama `SendGate.check(message_type:"pacs.008", source:"manual")` (`:212`); needs_approval → `HeldMessages.hold` + 202 awaiting_approval.
- Fluxo AUTOMÁTICO do Core (`CoreEventProcessor`, `payment_request`): **NÃO invoca alçada** (grep confirma zero ocorrência de "alcada" em `core_event_processor.ex`); e é `@system_source` = bypass.
- `OutboundSender.dispatch_message` (`apps/spi_service/lib/spi_service/workers/outbound_sender.ex:415`): defesa em profundidade, pula (ack sem envio) mensagem com `"alcada_status" => "awaiting_approval"`.

### 2.4 Retenção e 4 olhos `Shared.Alcada.HeldMessages`
`apps/shared/lib/shared/alcada/held_messages.ex`:
- `hold/1` (`:50`): cria `HeldMessage awaiting_approval` + `Registration` (required_vistos do parâmetro, default 1).
- `authorize/2` (`:108`): `already_processed` (status != awaiting) / `invalid_approver` (id nil/0) / **`cannot_self_approve` (`created_by == approver_id`)**.
- `approve/3` (`:133`) / `reject/3` (`:152`): registra visto (unique) via `record_visto`; aprova ao atingir `required_vistos`; CAS terminal `claim_transition` (`:274`, `UPDATE ... WHERE status=awaiting_approval`) garante despacho único; rejeição terminal.
- Aprovador via controller `MessageController.approve_held/reject_held` (`:329,386`): `held_permission_or_forbid` exige RBAC `can_approve` do tipo de mensagem (`:417`, admin bypassa) + segregação vem do `HeldMessages`.
- Unique index vivo: `idx_alcada_vistos_user_reg (registration_id,user_id)` (schema sql:11463).

### 2.5 Frontend
- `AlcadaParametersView.vue` (canônica): campos = schema (name, min/max, accumulated, required_vistos, start/end hour, message_types, priority). Sem escopo/aprovadores/regra-por-campo.
- `AlcadaPendingView.vue` / `AlcadaHistoryView.vue`: monitor + visto.
- `participants/ApprovalLimitsView.vue`: tela SEPARADA que envia `operation_type, approver_groups, required_approvals, entity_code` a `POST /api/v1/alcada/parameters` — mas o backend (`Parameter.changeset`) NÃO casta nenhum desses e exige `name` (que o form não envia). Campos descartados em silêncio; criação falharia por `name` ausente. Ver g13.

---

## 3. GAPS (legado x nosso)

Resumo: o legado tem um MOTOR DE ALÇADA universal na entrada da cabine (escopo + faixa + acumulado por cliente/conta + janela com feriado/overnight + regra por campo com 8 operadores + N vistos + aprovadores por alçada), SEMPRE ativo. O nosso tem um gate mais simples, restrito ao envio MANUAL, DESLIGADO por default, com semântica de faixa invertida, sem escopo, sem regra por campo, sem acumulado por cliente, sem janela real, e o money-path AUTOMÁTICO (o volume do Core/IB/Partner) não passa por alçada nenhuma na cabine.

Ver objeto estruturado retornado (g1..g13). Itens COBERTOS: maker!=checker, 1-usuário-1-voto, rejeição terminal, aprovação por N vistos, corrida de aprovação dupla (CAS), retenção antes do envio, justificativa/ator/timestamp.
