Cliente liga: “o extrato do meu título tá errado, vocês cobraram a mais.” Você abre a SE1, olha o E1_SALDO, fala que está certo. Cliente insiste. Você cruza com a SE5 (movimento bancário) e percebe que tem um abatimento de R$ 200 que ninguém viu e o tal saldo da SE1 não considera.

Esse é o problema clássico do financeiro Protheus: o E1_SALDO é um campo armazenado, não calculado em tempo real, e nem sempre reflete a realidade. Quem confia cego nele acaba gerando relatório errado, demanda judicial, ou pior — emitindo boleto com valor inflado.

Por que E1_SALDO mente (às vezes)

O Protheus mantém o saldo do título como um campo persistido na própria SE1 (contas a receber) ou SE2 (contas a pagar). Esse campo é atualizado quando rotinas padrão (FINA070, FINA340, baixa via FINA070, etc.) chamam funções internas que recalculam.

O problema aparece em três cenários:

  • Customização que altera SE1/SE5 fora do fluxo padrão — alguém fez um RecLock direto sem chamar o recálculo de saldo.
  • Importação de títulos via SQL bruto — INSERT direto na SE1 sem rodar a função de recalcula.
  • Abatimentos lançados depois da última baixa — em algumas versões, o saldo só é atualizado na próxima movimentação.

A verdade é que o saldo real é uma conta, não um campo. Sempre.

A fórmula que sempre funciona

SALDO_REAL = E1_VALOR
           - SOMA(baixas em SE5 vinculadas)
           - SOMA(títulos AB- vinculados na própria SE1)
           - SOMA(títulos DC- vinculados — descontos)
           + SOMA(títulos JR- vinculados — juros)
           + SOMA(títulos MUL vinculados — multas)
           + SOMA(títulos TX- vinculados — taxas)

Os tipos AB-, DC-, JR-, MUL e TX- são títulos auxiliares com a mesma chave (PREFIXO + NUM + PARCELA) que o original, mas com tipo diferente. Não confunda com o título principal que tem tipo NF, FAT, DP, etc.

SomaAbat() — o atalho pros abatimentos

O framework já oferece uma função pronta pra somar abatimentos de um título: SomaAbat(). Assinatura:

SomaAbat(cPrefixo, cNumero, cParcela, cTipo, cAlias) // retorna numérico

Exemplo prático: cliente quer saber quanto já foi abatido do título 001/A:

Local nAbat := 0

DBSelectArea("SE1")
DBSetOrder(1)  // E1_FILIAL+E1_PREFIXO+E1_NUM+E1_PARCELA+E1_TIPO

If DBSeek(xFilial("SE1") + "001" + "000001" + "A" + "NF ")
    nAbat := SomaAbat(SE1->E1_PREFIXO, ;
                      SE1->E1_NUM, ;
                      SE1->E1_PARCELA, ;
                      SE1->E1_TIPO, ;
                      "SE1")
EndIf

ConOut("Abatimentos: " + AllTrim(Str(nAbat)))

Pegadinha: o quinto parâmetro define se você está em SE1 (receber) ou SE2 (pagar). Não tem default — passar NIL aqui dá erro.

SaldoTit() — o método mais elegante

Pra evitar a conta manual, o framework também tem SaldoTit(). Assinatura simplificada:

SaldoTit(cAlias, dDataBase, cMoeda, lConvMoeda) // retorna numérico

O grande diferencial: já considera baixas, abatimentos, descontos, juros e multas, e ainda permite calcular o saldo numa data passada (útil em relatórios retroativos).

DBSelectArea("SE1")
DBSetOrder(1)

If DBSeek(xFilial("SE1") + "001" + "000001" + "A" + "NF ")
    nSaldoHoje  := SaldoTit("SE1", Date(), 1, .F.)
    nSaldoMesPas := SaldoTit("SE1", LastDate(MonthSub(Date(), 1)), 1, .F.)

    ConOut("Saldo hoje: " + AllTrim(Str(nSaldoHoje)))
    ConOut("Saldo no fim do mês passado: " + AllTrim(Str(nSaldoMesPas)))
EndIf

Requisito crítico: a área precisa estar posicionada no título (depois do DBSeek). Se a área não estiver no registro certo, a função retorna saldo de outro título — bug silencioso.

Função reutilizável: saldo real do jeito certo

Em rotinas customizadas, vale embrulhar tudo numa função própria que combina SaldoTit com fallback manual:

User Function ATSaldoReal(cPrefixo, cNum, cParcela, cTipo)
    Local nSaldo  := 0
    Local cAreaAt := GetArea()

    DBSelectArea("SE1")
    SE1->(DBSetOrder(1))

    If SE1->(DBSeek(xFilial("SE1") + cPrefixo + cNum + cParcela + cTipo))
        nSaldo := SaldoTit("SE1", Date(), 1, .F.)
    Else
        // título não existe ou está deletado
        nSaldo := -1
    EndIf

    RestArea(cAreaAt)
Return nSaldo

Três cuidados no código acima:

  • SE1->(DBSetOrder(1)) com prefixo da área evita afetar a área corrente do chamador.
  • GetArea / RestArea garante que a função não quebra o estado de quem chamou.
  • Retornar -1 quando não acha é convenção — vale documentar pra quem for consumir.

Quando partir pro SQL direto

SaldoTit() é elegante mas chama o framework do Protheus, faz vários DBSeek internos. Se você precisa do saldo de 10 mil títulos num relatório, fazer um loop chamando essa função leva minutos. SQL agregado resolve em segundos:

cQuery := "SELECT SE1.E1_PREFIXO, SE1.E1_NUM, SE1.E1_PARCELA, SE1.E1_TIPO, "
cQuery += "       SE1.E1_VALOR, "
cQuery += "       COALESCE(AB.VLR_AB, 0) AS VLR_AB, "
cQuery += "       COALESCE(BX.VLR_BX, 0) AS VLR_BX, "
cQuery += "       SE1.E1_VALOR - COALESCE(AB.VLR_AB, 0) - COALESCE(BX.VLR_BX, 0) AS SALDO "
cQuery += "FROM " + RetSqlName("SE1") + " SE1 "
cQuery += "LEFT JOIN ( "
cQuery += "    SELECT E1_FILIAL, E1_PREFIXO, E1_NUM, E1_PARCELA, SUM(E1_VALOR) AS VLR_AB "
cQuery += "    FROM " + RetSqlName("SE1") + " "
cQuery += "    WHERE E1_TIPO LIKE 'AB%' AND D_E_L_E_T_ = ' ' "
cQuery += "    GROUP BY E1_FILIAL, E1_PREFIXO, E1_NUM, E1_PARCELA "
cQuery += ") AB ON AB.E1_FILIAL = SE1.E1_FILIAL "
cQuery += "   AND AB.E1_PREFIXO = SE1.E1_PREFIXO "
cQuery += "   AND AB.E1_NUM = SE1.E1_NUM "
cQuery += "   AND AB.E1_PARCELA = SE1.E1_PARCELA "
cQuery += "LEFT JOIN ( "
cQuery += "    SELECT E5_FILIAL, E5_PREFIXO, E5_NUMERO, E5_PARCELA, "
cQuery += "           SUM(E5_VALOR) AS VLR_BX "
cQuery += "    FROM " + RetSqlName("SE5") + " "
cQuery += "    WHERE E5_TIPODOC IN ('VL','BA') AND D_E_L_E_T_ = ' ' "
cQuery += "    GROUP BY E5_FILIAL, E5_PREFIXO, E5_NUMERO, E5_PARCELA "
cQuery += ") BX ON BX.E5_FILIAL = SE1.E1_FILIAL "
cQuery += "   AND BX.E5_PREFIXO = SE1.E1_PREFIXO "
cQuery += "   AND BX.E5_NUMERO = SE1.E1_NUM "
cQuery += "   AND BX.E5_PARCELA = SE1.E1_PARCELA "
cQuery += "WHERE SE1.E1_FILIAL = '" + xFilial("SE1") + "' "
cQuery += "  AND SE1.E1_TIPO NOT IN ('AB-','JR-','DC-','MUL','TX-') "
cQuery += "  AND SE1.D_E_L_E_T_ = ' ' "

cAlias := GetNextAlias()
TCQuery cQuery New Alias (cAlias)

While !(cAlias)->(EOF())
    // (cAlias)->SALDO já tem o saldo calculado
    (cAlias)->(DBSkip())
End
(cAlias)->(DBCloseArea())

Esse SQL não cobre juros e multas — adicione mais LEFT JOIN se precisar. Em geral, pra relatório gerencial de saldo, abatimento + baixa já dão 95% do caso de uso.

Fluxo de decisão

flowchart TD Start[Preciso do saldo de um título] --> Q1{Quantos títulos?} Q1 -->|1 ou poucos
em rotina interativa| Q2{Preciso de saldo
retroativo?} Q1 -->|Centenas/milhares
em relatório| SQL[SQL agregado
com TCQuery] Q2 -->|Sim, data passada| ST[SaldoTit com
dDataBase] Q2 -->|Não, hoje basta| Q3{Só preciso
dos abatimentos?} Q3 -->|Sim| SA[SomaAbat] Q3 -->|Não, saldo completo| ST2[SaldoTit hoje] SQL --> Done[Saldo calculado] ST --> Done SA --> Done ST2 --> Done

Pegadinhas que travam projeto

1. Filtro de filial implícito

Tanto SomaAbat quanto SaldoTit respeitam xFilial("SE1") automaticamente — mas em SQL direto você precisa adicionar E1_FILIAL = '...' manualmente. Esquecer disso soma valores de todas as filiais e gera saldo errado.

2. Tipo do título com espaço

Campos E1_TIPO e E5_TIPODOC têm largura fixa (geralmente 3 chars). "NF" no DBSeek precisa virar "NF " com espaço, senão o seek não encontra. Use PadR("NF", TamSx3("E1_TIPO")[1]) pra garantir.

3. D_E_L_E_T_ com espaço, não vazio

O Protheus marca delete lógico com '*' e mantém ativo com ' ' (espaço, não string vazia). Filtrar D_E_L_E_T_ = '' retorna zero registros. Sempre D_E_L_E_T_ = ' '.

4. SaldoTit em loop é veneno

Dentro de While processando milhares de títulos, cada chamada faz seeks internos. Se a rotina tá lenta, troque por SQL agregado — fica 10-50x mais rápido.

Tabela de decisão rápida

CenárioFunção recomendadaPor quê
Tela de consulta de 1 títuloSaldoTit()Simples, já considera tudo
Validar abatimentos antes de gravarSomaAbat()Foco só no AB-
Relatório de inadimplência (1k+ títulos)SQL via TCQueryPerformance
Saldo numa data passadaSaldoTit() com dDataBaseÚnico que calcula retroativo
Auditoria de E1_SALDO vs realSQL + comparaçãoDetecta divergências em massa

Não confie, calcule

A regra é simples: nunca use E1_SALDO em customização crítica sem antes validar com SaldoTit() ou SQL. Em produção a divergência aparece justamente nos casos que você menos esperava — uma rotina antiga que faz RecLock direto, um migrado de outro ERP, um lançamento manual fora do padrão.

Se vai gerar boleto, NF de cobrança, relatório pra cliente: calcule. Custa 5ms a mais e evita trinta minutos de telefone com cliente bravo. Pra documentação oficial do framework financeiro, vale acompanhar o TDN do módulo Financeiro.