Pular para o conteúdo

ADR-0034 Datas, fusos horários e dinheiro

Padrão da empresa para tempo e dinheiro — date-fns 4 com @date-fns/tz para cálculos de datas, Intl para formatação, timestamptz no Postgres e dinheiro em unidades menores inteiras com um código de moeda.

Decisão
date-fns 4 + Intl; dinheiro em unidades menoresConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

As datas e o dinheiro são os valores que parecem números e strings simples e não o são.

  • O servidor corre em UTC; os utilizadores não. Código que se apoia no fuso horário do processo está certo num portátil em Lisboa e errado no contentor
  • Renderizamos no servidor e hidratamos no browser (ADR-0002): uma data formatada em dois fusos diferentes são duas strings diferentes, e a hidratação falha
  • O Date não tem fuso horário, e um dia nem sempre tem 24 horas: “amanhã à mesma hora” e “início do mês em Lisboa” precisam de cálculos que conheçam o fuso
  • O Postgres tem os tipos certos (ADR-0007): timestamptz para um instante, date para um dia de calendário, inteiros e numeric para números exatos
  • Os números de vírgula flutuante não conseguem guardar 0,1, e uma fatura tem de bater certo

O que está em aberto: a biblioteca de datas, onde entram os fusos horários e como se guarda e calcula o dinheiro.

Prós: nada para instalar; o Intl formata em qualquer locale e fuso. Contras: sem aritmética com fusos — “início do dia em Lisboa” ou “somar um mês atravessando uma mudança de hora” escreve-se à mão, em cada produto.

Prós

  • Funções simples sobre Date, importadas uma a uma
  • Desde a 4.0, fusos horários de primeira classe: um TZDate que funciona com todas as funções, e uma opção in: tz("Europe/Lisbon") nas funções que um fuso pode afetar
  • Pequeno: os autores medem o TZDate em cerca de 1 kB, e usa os dados de fusos do próprio runtime
  • A 4.0 saiu sem alterações incompatíveis de relevo

Contras

  • Continua a ser Date por baixo, com os seus meses a começar em zero
  • Com fusos misturados o resultado segue o primeiro argumento, pelo que a ordem dos argumentos conta
  • Dois pacotes para manter alinhados

Prós: a resposta da própria linguagem — Instant, ZonedDateTime, PlainDate — sem biblioteca. Contras: os dados de compatibilidade da MDN indicam Node 26, Chrome 144 e Firefox 139, mas Safari só a partir da 27.3: ainda não podemos contar com ele nos browsers dos nossos utilizadores, e o Drizzle e as bibliotecas TanStack falam Date.

Prós: conhecem fusos, com uma API encadeada simpática. Contras: objetos de data próprios a atravessar todas as fronteiras, e nada que o date-fns 4 não cubra.

  • Floats (number, double precision) — os erros de arredondamento acumulam-se; rejeitado
  • numeric com uma biblioteca de decimais — exato, mas um segundo tipo numérico em cada cálculo
  • Unidades menores inteiras — exato com inteiros simples, a forma que os fornecedores de pagamentos usam

date-fns 4 com @date-fns/tz para cálculos de datas, Intl para tudo o que é mostrado a uma pessoa, e dinheiro em unidades menores inteiras com um código de moeda.

Em concreto:

  • Pacotes: date-fns 4.x (4.4.0 à data de escrita) e @date-fns/tz 1.x. Não date-fns-tz, Moment, Day.js nem Luxon
  • Um instante é timestamptz: o timestamp({ withTimezone: true }) do Drizzle. Nunca timestamp sem fuso. Um dia de calendário é date, mantido como string YYYY-MM-DD (date({ mode: "string" })) — um aniversário ou uma data de vencimento não tem fuso para converter
  • O servidor e a base de dados correm em UTC. O fuso horário de um utilizador ou de uma organização é um dado: um nome IANA como Europe/Lisbon numa coluna, nunca um offset UTC
  • Na rede as datas são strings ISO 8601, validadas com Zod (ADR-0005)
  • Os cálculos que dependem de um fuso nomeiam-no: TZDate, ou { in: tz(zone) }. O código de servidor nunca faz aritmética de dias ou meses sobre um Date simples
  • A formatação para pessoas é Intl — Intl.DateTimeFormat e Intl.RelativeTimeFormat com o locale do ADR-0031 e um timeZone explícito, sempre. O servidor e o browser produzem então a mesma string. O format do date-fns é só para formatos de máquina (nomes de ficheiros, datas ISO)
  • O “agora” é um parâmetro. O código que precisa da hora atual recebe-a (ADR-0027), para que um teste a possa fixar
  • O dinheiro é um inteiro na unidade menor da moeda (cêntimos), numa coluna bigint({ mode: "number" }), com um código de moeda ISO 4217 na coluna ao lado. Nunca um float, nem no Postgres nem em JavaScript
  • As taxas, as percentagens e as quantidades decimais são numeric
  • O arredondamento acontece uma vez, onde uma regra o disser (imposto, uma divisão), e o resto é atribuído de propósito, não perdido
  • O dinheiro formata-se com Intl.NumberFormat (style: "currency"), a partir das unidades menores e do código de moeda
  • Nenhuma biblioteca de dinheiro até um produto converter entre moedas; esse produto escreve o seu próprio ADR
  • Temporal: ainda não. Reavaliar, num novo ADR, quando todos os browsers que suportamos o tiverem

Os fatores decisivos: cálculos com fusos em funções pequenas e simples, formatação idêntica no servidor e no browser, e dinheiro que bate certo sem uma biblioteca de decimais. Aceitamos as arestas do Date até o Temporal estar em todo o lado.

Mais fácil:

  • Os bugs de fusos horários têm uma só causa a procurar: um fuso em falta, nunca uma biblioteca errada
  • A mesma data renderiza a mesma string no servidor e no browser
  • Os totais são somas de inteiros; os relatórios e as exportações coincidem ao cêntimo

Mais difícil:

  • Cada data formatada precisa de receber um fuso horário — não há predefinição segura
  • Quem programa tem de saber qual dos dois é um campo: um instante ou um dia de calendário
  • As unidades menores variam por moeda (o iene não tem); o número de casas decimais vem de Intl.NumberFormat(…).resolvedOptions(), não de um 100 fixo no código

Do que abdicamos: dos tipos mais limpos do Temporal hoje, e da aritmética decimal para o dinheiro. Aceite — o date-fns 4 e os inteiros funcionam em todos os runtimes para onde entregamos agora.