Files
in100tiva 87d434ec80 fix(kanban): o total da etapa separa as moedas em vez de somá-las
Quando uma etapa do funil tinha negócios em moedas diferentes, a
StageColumn somava todos os value_cents e escrevia o resultado na moeda
do primeiro negócio com valor: R$ 5.000 e 5.000 € saíam "R$ 10.000,00",
um número que não existe em moeda nenhuma. A linha "ponderado" fazia o
mesmo, e a moeda do cabeçalho mudava conforme a ordem dos cards.

lib/money.ts ganha dois exports:
- somaPorMoeda: cada moeda no seu balde, sem conversão. Sem valor não
  entra nem cria a moeda; zero entra e cria.
- formatSomaPorMoeda: com uma moeda devolve exatamente o formatador;
  ordem é `primeira` e depois alfabética; separador " + "; o código
  ISO só aparece quando o símbolo se repete na soma (MXN e USD escrevem
  os dois "$"). O formatador é parâmetro obrigatório por causa das
  duas réguas de _cents: valor de negócio vai por formatValorDoNegocio
  (x100), e um padrão escondido repetiria o 100x em guarani.

A coluna usa os dois no total e no ponderado, com a mesma função de
moeda (currency NULL conta como BRL, como no card) e a mesma ordem:
primeiro a moeda com mais negócios com valor, empate alfabético. A
moeda da organização ainda não chega ao quadro; o comentário ponytail
registra o caminho de upgrade.

Com uma moeda só, texto e DOM ficam idênticos: os quatro casos antigos
do teste seguem verdes sem edição. O caso "a moeda vem do primeiro
lead" foi reescrito como "lead sem valor não entra no total". Os casos
novos (duas moedas, ordem, empate, MXN/USD, ponderado) ficam vermelhos
contra a StageColumn da main: 5 vermelhos de 11, os previstos.

Levantamento de @franceschini-lucas.

Refs #1531
2026-09-27 23:10:47 -03:00

443 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Conversão de valor digitado (reais) para centavos.
*
* A versão anterior vivia copiada em três formulários de lead e fazia
* `reais.replace(/\./g, "").replace(",", ".")` — ou seja, tratava TODO ponto
* como separador de milhar. Quem digitasse "249.90" (o jeito natural em
* teclado numérico) tinha o valor gravado como R$ 24.990,00: cem vezes maior,
* sem erro na tela, alimentando funil e relatórios com número errado.
*
* A regra aqui desambigua sem adivinhação, porque em pt-BR grupo de milhar tem
* SEMPRE 3 dígitos:
*
* "249,90" → vírgula é decimal → 24990
* "249.90" → ponto seguido de 2 dígitos é decimal → 24990
* "1.234" → ponto seguido de 3 dígitos é milhar → 123400
* "1.234,56" → tem vírgula: pontos são milhar → 123456
* "1,234.56" → formato en: o último separador é decimal→ 123456
* "1.234.567" → todos os grupos com 3 dígitos → 123456700
*
* Devolve null quando não dá para ler um número — quem chama decide a mensagem.
*/
export function parseReaisToCents(input: string): number | null {
const raw = input.trim();
if (raw.length === 0) return null;
if (!/^[\d.,\s]+$/.test(raw)) return null;
const s = raw.replace(/\s/g, "");
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
let decimalSep = "";
if (lastComma >= 0 && lastDot >= 0) {
// Os dois presentes: o que vem por último é o decimal (pt-BR ou en).
decimalSep = lastComma > lastDot ? "," : ".";
} else if (lastComma >= 0) {
decimalSep = ",";
} else if (lastDot >= 0) {
// Só ponto: é milhar apenas se o grupo final tiver exatamente 3 dígitos.
decimalSep = s.length - lastDot - 1 === 3 ? "" : ".";
}
let normalized: string;
if (decimalSep === "") {
normalized = s.replace(/[.,]/g, "");
} else {
const cut = decimalSep === "," ? lastComma : lastDot;
const inteiro = s.slice(0, cut).replace(/[.,]/g, "");
const frac = s.slice(cut + 1);
if (!/^\d*$/.test(frac)) return null;
normalized = `${inteiro || "0"}.${frac}`;
}
const n = Number(normalized);
if (!Number.isFinite(n) || n < 0) return null;
return Math.round(n * 100);
}
/** Centavos → "R$ 249,90". Para eco na tela do que foi entendido. */
export function formatCentsBRL(cents: number): string {
return (cents / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
}
/**
* Centavos de DÓLAR → "US$ 249,90". Para todo número que sai de
* `llm_calls.cost_cents` / `ai_invocations.cost_cents`.
*
* ⚠️ EXISTE PORQUE O NÚMERO É DÓLAR E SETE TELAS O ESCREVIAM EM REAL.
* `lib/agent-engine/edge/llm/pricing.ts` cota o provedor em USD e grava centavo
* de USD; formatar em BRL fazia o dono do negócio ler um valor ~5x menor do que
* o que estava sendo cobrado dele — e, depois que o teto passou a vincular,
* armar um limite ~5x maior do que pensava. A conversão de moeda NÃO é feita
* (exigiria fonte de câmbio, dependência externa nova num produto self-host):
* o que muda é o rótulo dizer a unidade real.
*
* Vírgula decimal porque a frase é pt-BR; "US$" porque a moeda é dólar. É a
* mesma escolha de `emDolares` em `lib/agent-engine/edge/llm/orcamento.ts` —
* mas ali ela não pode importar daqui (o módulo é do engine e roda no worker).
*/
export function formatCentsUSD(cents: number): string {
return ((cents ?? 0) / 100).toLocaleString("pt-BR", { style: "currency", currency: "USD" });
}
/**
* Centavos → o preço escrito na convenção da MOEDA. `formatCents(24990, "MXN")`
* devolve `$249.90`.
*
* ─── Por que a moeda escolhe o formato, e não o idioma da interface ────────
*
* O reflexo era reusar `tagDeIdioma()` — o produto já resolve `pt-BR`/`es` para
* quem está lendo, e passar isso ao `Intl` parecia de graça. Medido antes de
* escrever, e é o contrário:
*
* Intl.NumberFormat("es", {currency:"MXN"}) -> "249,90 MXN"
* Intl.NumberFormat("es-MX", {currency:"MXN"}) -> "$249.90"
*
* `es` puro resolve para a ESPANHA: vírgula decimal e o código ISO depois do
* número. Quem vende no México lê `$249.90`. São duas perguntas diferentes com
* a mesma cara: o idioma da tela é preferência de quem LÊ e muda de pessoa para
* pessoa; a moeda é fato do NEGÓCIO e é a mesma para todo mundo que abre aquele
* catálogo. Quem manda no separador decimal é a segunda.
*
* ─── Por que não há tabela de moeda → locale ───────────────────────────────
*
* Toda tabela escrita à mão nasce incompleta, e a coluna aceita qualquer
* `^[A-Z]{3}$`. O código ISO-4217 já carrega a região nas duas primeiras letras
* (BRL→BR, MXN→MX, JPY→JP), e `Intl.Locale#maximize()` diz o idioma provável
* dessa região. **O subtag de script sai de propósito**: `maximize()` devolve
* `es-Latn-MX`, e o CLDR indexa o símbolo de moeda por `es-MX` — com o script
* no meio, o MXN volta a sair como `"249,90 MXN"`. Medido nos dois sentidos.
*
* Código desconhecido cai em `en-US`, que escreve o código ISO e não mente
* sobre a unidade. Moeda SEM país é a exceção que a regra acima não alcança, e
* tem locale declarado em `LOCALE_DA_MOEDA_SEM_PAIS`.
*
* ─── `_cents` nem sempre é centésimo ───────────────────────────────────────
*
* ⚠️ JPY e CLP não têm subunidade; KWD tem três casas. Dividir por 100 em duro
* mostraria `¥250` onde são `¥25.000` — cem vezes menos, no número que o agente
* cota ao cliente. As unidades menores saem do próprio `Intl`, não de uma lista.
*
* ─── ⚠️ ESTA É A FONTE. As cópias que existem hoje têm os dois defeitos ─────
*
* Não é função nova por falta de uma: o repo já tinha CINCO formatadores locais
* de dinheiro, copiados entre si, e todos carregam exatamente os dois defeitos
* acima — `Intl.NumberFormat("pt-BR", …)` em duro **recebendo `currency` por
* parâmetro**, mais o `/100` fixo:
*
* components/kanban/KanbanCard.tsx:34 formatBRL(cents, currency)
* components/kanban/LeadDossier.tsx:28 formatBRL(cents, currency)
* components/kanban/StageColumn.tsx:31 formatBRL(cents) (só BRL)
* components/inbox/CRMSidePanel.tsx:193 formatMoney(cents, currency)
* lib/lgpd/pdf-renderer.tsx:107 fmtMoney(cents, currency) (pior:
* `${currency} ${v.toFixed(2)}`, sem
* locale e sempre 2 casas)
*
* Aceitar `currency` e fixar `pt-BR` é o defeito medido: o lead em MXN sai
* `MX$ 249,90`, com vírgula decimal brasileira, para quem opera no México.
*
* **As três do kanban já convergiram** — em `formatValorDoNegocio`, e não em
* `formatCents`, porque o negócio guarda ×100 em qualquer moeda (ver a função);
* o `maximumFractionDigits: 0` delas virou a opção `semCentavos`. Faltam o
* inbox e o PDF de LGPD, frentes que a doutrina não deixa misturar com esta.
* Enquanto não convergem, a duplicação fica DECLARADA aqui
* — que é o que separa o anti-pattern 2 ("duplicação sem source of truth
* declarado") de uma dívida com dono e endereço.
*
* `formatCentsBRL` e `formatCentsUSD` ficam pelo mesmo motivo: atendem o valor
* do negócio no kanban e o gasto de IA, que são essas outras frentes.
*
* ⚠️ Havia uma SEXTA, não listada aqui na primeira versão deste comentário:
* `lib/mcp/tools/comercio.ts` tinha `precoLegivel()`, o mesmo defeito — e era a
* mais grave das seis, porque é o preço que o AGENTE DE IA cota ao cliente por
* WhatsApp. Uma revisão pegou a omissão antes do merge; já converge aqui.
*/
const formatadores = new Map<string, Intl.NumberFormat>();
/**
* Moeda cujo código ISO não começa por um país. `EU` maximiza para `en-EU`, e
* `en-EU` escreve `€249.90` — ponto decimal e símbolo na frente, a convenção da
* Irlanda. Português, espanhol, francês, alemão e italiano escrevem `249,90 €`,
* e é o que `pt-PT` devolve (medido no ICU do Node 22, junto com `de-DE`,
* `fr-FR`, `es-ES` e `it-IT`).
*
* ponytail: um formato só para a zona euro. Holanda (`€ 249,90`) e Irlanda
* (`€249.90`) leem a convenção vizinha; quando `organizations.country` puder
* escolher o locale, é ele que decide, e esta tabela some.
*/
const LOCALE_DA_MOEDA_SEM_PAIS: Readonly<Record<string, string>> = {
EUR: "pt-PT",
// PYG tem país, mas a maximização responde o idioma ERRADO para dinheiro:
// `und-PY` vira `gn-Latn-PY` (guarani), que o ICU não tem, e o formatador
// caía em `en-US` — `PYG 125,000`, com vírgula de milhar. `es-PY` escreve
// `Gs. 125.000`, como se lê no Paraguai. Medido no ICU do Node 22.
PYG: "es-PY",
};
function localeDaMoeda(moeda: string): string {
let locale = LOCALE_DA_MOEDA_SEM_PAIS[moeda] ?? "en-US";
if (!LOCALE_DA_MOEDA_SEM_PAIS[moeda]) {
try {
const provavel = new Intl.Locale(`und-${moeda.slice(0, 2)}`).maximize();
const tag = `${provavel.language}-${provavel.region}`;
if (Intl.NumberFormat.supportedLocalesOf(tag).length > 0) locale = tag;
} catch {
// Região que o ICU não conhece: fica o padrão.
}
}
return locale;
}
function formatadorDa(moeda: string): Intl.NumberFormat {
const cacheado = formatadores.get(moeda);
if (cacheado) return cacheado;
const novo = new Intl.NumberFormat(localeDaMoeda(moeda), { style: "currency", currency: moeda });
formatadores.set(moeda, novo);
return novo;
}
const formatadoresSemCentavos = new Map<string, Intl.NumberFormat>();
function formatadorSemCentavosDa(moeda: string): Intl.NumberFormat {
const cacheado = formatadoresSemCentavos.get(moeda);
if (cacheado) return cacheado;
const novo = new Intl.NumberFormat(localeDaMoeda(moeda), {
style: "currency",
currency: moeda,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
formatadoresSemCentavos.set(moeda, novo);
return novo;
}
/**
* Quantas casas decimais a moeda tem — a régua de `_cents` de `formatCents`
* (unidades menores). Sai do MESMO formatador de `formatCents`, para as duas
* pontas nunca divergirem: BRL 2, PYG e JPY 0, KWD 3. Moeda que o `Intl`
* recusa fica em 2.
*/
function casasDaMoeda(moeda: string): number {
try {
return formatadorDa(moeda).resolvedOptions().maximumFractionDigits ?? 2;
} catch {
return 2;
}
}
/**
* ⚠️ `formatCents` é EXPORTADA e roda em client component (a lista de
* produtos). `new Intl.NumberFormat(locale, {style:"currency", currency})`
* LANÇA para moeda malformada — medido: `""`, `"BR"` (2 letras), `undefined`,
* `null` — mesmo com o CHECK do banco (`^[A-Z]{3}$`) garantindo o formato em
* toda linha que passa por ele. A função não pode presumir que todo chamador
* futuro respeita essa garantia: um valor ruim não pode derrubar a lista
* inteira. As cinco cópias que esta função substitui tinham `try/catch`
* (ex.: `CRMSidePanel.tsx:201`); esta usa a mesma rede.
*/
export function formatCents(cents: number, moeda: string): string {
const valor = (cents ?? 0) / 100;
try {
const nf = formatadorDa(moeda);
// O tipo do `Intl` deixa o campo opcional; 2 é o que a esmagadora maioria
// das moedas usa e é o que o código fazia em duro antes desta função existir.
const casas = nf.resolvedOptions().maximumFractionDigits ?? 2;
return nf.format((cents ?? 0) / 10 ** casas);
} catch {
// Moeda que o `Intl` recusa: mostra o número certo em vez de travar a
// tela. Sem `style: "currency"` porque é justamente o `currency` inválido
// que lançou — um código de moeda cru é mais honesto que esconder o erro.
return `${moeda || "?"} ${valor.toFixed(2)}`;
}
}
/**
* O VALOR DE UM NEGÓCIO (`crm_leads.value_cents`), escrito na moeda dele.
*
* ⚠️ DUAS RÉGUAS DE `_cents` CONVIVEM, e esta função é a ponte entre elas.
* `formatCents` lê UNIDADES MENORES da moeda — as casas vêm do `Intl`, e em
* moeda sem subunidade (guarani, iene, peso chileno) o número já é a moeda
* inteira. O negócio guarda o valor × 100 em QUALQUER moeda: o formulário
* (`parseReaisToCents`) multiplica por 100 sem olhar a moeda, e o card e o
* dossiê do funil sempre dividiram por 100 fixo.
*
* O total da coluna do funil passava o valor do negócio direto a `formatCents`:
* dois pedidos de ₲125.000 (`value_cents` 12.500.000 cada) apareciam como
* `Gs. 25.000.000` — cem vezes mais —, enquanto o card, com o `/100` fixo, dizia
* o certo. Medido numa instalação real em 25/09/2026. Em BRL, USD e MXN (duas
* casas) as duas réguas coincidem, e por isso o defeito só aparece em moeda sem
* centavos.
*
* `semCentavos` é o formato do card e do dossiê do funil, que nunca mostraram
* centavos; o total da coluna mostra.
*/
export function formatValorDoNegocio(
valueCents: number,
moeda: string,
opcoes: { semCentavos?: boolean } = {},
): string {
const valor = (valueCents ?? 0) / 100;
try {
if (opcoes.semCentavos) return formatadorSemCentavosDa(moeda).format(valor);
// Da régua do negócio (×100) para a de `formatCents` (unidades menores).
return formatCents(Math.round((valueCents ?? 0) / 10 ** (2 - casasDaMoeda(moeda))), moeda);
} catch {
return `${moeda || "?"} ${valor.toFixed(opcoes.semCentavos ? 0 : 2)}`;
}
}
/**
* SOMA POR MOEDA — cada moeda no seu balde, nunca uma soma entre elas (#1531).
*
* R$ 5.000 mais 5.000 € não são 10.000 de nada: somar `_cents` de moedas
* diferentes produz um número que não existe em moeda nenhuma, e escrevê-lo no
* símbolo de uma delas o faz parecer verdade. O total da coluna do funil fazia
* isso — somava tudo e escrevia na moeda do primeiro negócio. A regra já valia
* em dois lugares (`lib/leads/previsao.ts` e `lib/metrics/perdas.ts`, cada um
* com o seu balde interno); esta é a versão exportada, para o próximo total não
* reinventar a terceira.
*
* Item com `cents` `null`/`undefined` não entra e NÃO cria a moeda — "sem valor"
* não é "zero". Zero entra e cria: é valor declarado, e é o que mantém o
* ponderado de uma etapa de perda (chance 0) com as mesmas moedas do total.
* Nada é convertido.
*/
export function somaPorMoeda<T>(
itens: readonly T[],
cents: (item: T) => number | null | undefined,
moeda: (item: T) => string,
): Map<string, number> {
const soma = new Map<string, number>();
for (const item of itens) {
const valor = cents(item);
if (valor == null) continue;
const m = moeda(item);
soma.set(m, (soma.get(m) ?? 0) + valor);
}
return soma;
}
/**
* Escreve uma soma por moeda como texto: `"R$ 5.000,00 + 5000,00 €"`.
*
* ⚠️ O FORMATADOR É OBRIGATÓRIO, e não um padrão escondido, por causa das duas
* réguas de `_cents` (ver `formatValorDoNegocio`): valor de negócio é ×100 em
* qualquer moeda, preço e lançamento financeiro são unidades menores. Um padrão
* serviria a uma e mostraria a outra cem vezes maior em guarani — o defeito que
* o total da coluna já pagou uma vez.
*
* - Com UMA moeda, o texto é exatamente `formatar(cents, moeda)`: quem somava
* uma moeda só não vê diferença nenhuma.
* - Ordem: `primeira` (quando está na soma), depois as demais em ordem
* alfabética do código ISO — determinística, para o texto não mudar de lugar
* a cada render.
* - Peso mexicano e dólar escrevem os dois `$` (e também CLP, COP e ARS), e
* `"$1,500.00 + $100.00"` não diz qual é qual. Só quando o símbolo se repete
* DENTRO da soma, cada parcela afetada leva o código: `"$1,500.00 MXN +
* $100.00 USD"`. Símbolo único fica limpo — `"5000,00 € EUR"` seria ruído.
* - Soma vazia devolve `""`.
*/
export function formatSomaPorMoeda(
soma: ReadonlyMap<string, number>,
formatar: (cents: number, moeda: string) => string,
opcoes: { primeira?: string } = {},
): string {
const moedas = [...soma.keys()].sort((a, b) =>
a === opcoes.primeira ? -1 : b === opcoes.primeira ? 1 : a.localeCompare(b),
);
// `simboloDaMoeda` lança para código malformado; aí o próprio código é o
// símbolo — é o que `formatCents` escreve nesse caso.
const simbolo = (moeda: string) => {
try {
return simboloDaMoeda(moeda);
} catch {
return moeda;
}
};
const simbolos = moedas.map(simbolo);
return moedas
.map((moeda) => {
const texto = formatar(soma.get(moeda) ?? 0, moeda);
const s = simbolo(moeda);
const repetido = simbolos.filter((outro) => outro === s).length > 1;
return repetido ? `${texto} ${moeda}` : texto;
})
.join(" + ");
}
/**
* As moedas que o produto SERVE — e servir quer dizer três coisas juntas: o
* seletor da organização a oferece, o schema a aceita, e `formatCents` sabe
* escrevê-la na convenção de quem a usa.
*
* É a mesma disciplina de `LOCALES = IDIOMAS` em `lib/schemas/settings.ts`, e
* pelo mesmo motivo: com duas listas, uma moeda aceita na validação e ausente
* no seletor vira um valor que ninguém consegue mais escolher de volta, e uma
* oferecida na tela e recusada no schema faz a tela salvar e o servidor
* devolver `validation_failed` sem explicar.
*
* ⚠️ Esta lista NÃO é o CHECK do banco, e a diferença é deliberada.
* `organizations_currency_iso` valida a FORMA (`^[A-Z]{3}$`), não o conjunto:
* um clone pode ter linha com moeda que este produto ainda não serve, e um
* CHECK fechado quebraria o `update.sh` dele — é a mesma exceção de vocabulário
* ABERTO que a doutrina de modelagem descreve. O conjunto vive só aqui, no
* TypeScript.
*
* As cinco têm subunidade de 2 casas, então nenhuma esbarra na ressalva de
* unidades menores de `formatCents`. Acrescentar JPY ou CLP funciona — o
* formatador já os cobre —, mas exige olhar `precoParaCentavos`, que ainda
* multiplica por 100 na leitura do que a pessoa digita.
*
* `AOA` (kwanza, Angola) entrou por pedido de quem instala lá. Medido antes de
* entrar, porque servir é o conjunto das três coisas acima: `formatCents(24990,
* "AOA")` → `"249,90 Kz"` (o `formatadorDa` maximiza `und-AO` para `pt-AO`) e
* `maximumFractionDigits` responde 2, então a régua de centavos vale. Entrar na
* lista NÃO muda o padrão de ninguém — `MOEDA_PADRAO` continua `BRL`, e é isso
* que o `default` da coluna grava em quem não escolheu.
*
* `EUR` entrou pelo mesmo motivo, para quem opera em Portugal e no resto da
* zona euro. É o caso que a maximização não resolve (ver
* `LOCALE_DA_MOEDA_SEM_PAIS`): sem a exceção, `formatCents(24990, "EUR")`
* sairia `€249.90`, e a varredura de degradação deixaria passar.
*/
export const MOEDAS_SERVIDAS = ["AOA", "BRL", "EUR", "MXN", "USD"] as const;
export type MoedaServida = (typeof MOEDAS_SERVIDAS)[number];
/** O que o `default` da coluna grava quando ninguém escolheu. */
export const MOEDA_PADRAO: MoedaServida = "BRL";
/**
* A moeda guardada, se o produto souber servi-la — senão, o padrão.
*
* Existe pela mesma razão que `normalizarIdioma`: o CHECK do banco valida a
* FORMA e não o conjunto, então a linha pode trazer uma moeda que este produto
* ainda não oferece (clone antigo, instalação que a gravou por outro caminho).
* Devolver isso cru ao seletor daria um `<Select>` com valor que não está entre
* as opções — o campo aparece vazio e, ao salvar, leva junto a moeda errada.
*/
export function moedaServidaOu(bruta: string | null | undefined): MoedaServida {
return (MOEDAS_SERVIDAS as readonly string[]).includes(bruta ?? "")
? (bruta as MoedaServida)
: MOEDA_PADRAO;
}
/**
* O símbolo da moeda — "R$", "$" —, tirado do mesmo formatador que escreve o
* preço, para o rótulo do seletor não precisar de tradução.
*
* ⚠️ Existe para fechar um buraco de i18n que a primeira versão do seletor
* abriu: os nomes ("Real brasileiro", "Peso mexicano") saíam por
* `t(NOME_DA_MOEDA[moeda])`, e chave DINÂMICA o guarda
* `i18n-espanhol-cobre-a-tela` não enxerga — ele varre o AST atrás de literais.
* Passariam pelo CI e cairiam no português na tela em espanhol, calado, que é
* exatamente o modo de falha que aquele teste existe para impedir.
*
* Código ISO + símbolo não se traduz e não envelhece: é o que o comerciante
* reconhece, e sai da mesma fonte que formata o preço.
*/
export function simboloDaMoeda(moeda: string): string {
const partes = formatadorDa(moeda).formatToParts(0);
return partes.find((p) => p.type === "currency")?.value ?? moeda;
}