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
Ligações
Contexto
Seção intitulada “Contexto”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
Datenã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):
timestamptzpara um instante,datepara um dia de calendário, inteiros enumericpara 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.
Opções consideradas
Seção intitulada “Opções consideradas”1. Só Date e Intl nativos
Seção intitulada “1. Só Date e Intl nativos”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.
2. date-fns 4 com @date-fns/tz
Seção intitulada “2. date-fns 4 com @date-fns/tz”Prós
- Funções simples sobre
Date, importadas uma a uma - Desde a 4.0, fusos horários de primeira classe: um
TZDateque funciona com todas as funções, e uma opçãoin: tz("Europe/Lisbon")nas funções que um fuso pode afetar - Pequeno: os autores medem o
TZDateem 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
Datepor 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
3. Temporal
Seção intitulada “3. Temporal”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.
4. Luxon ou Day.js
Seção intitulada “4. Luxon ou Day.js”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.
Dinheiro
Seção intitulada “Dinheiro”- Floats (
number,double precision) — os erros de arredondamento acumulam-se; rejeitado numericcom 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
Decisão
Seção intitulada “Decisão”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-fns4.x (4.4.0 à data de escrita) e@date-fns/tz1.x. Nãodate-fns-tz, Moment, Day.js nem Luxon - Um instante é
timestamptz: otimestamp({ withTimezone: true })do Drizzle. Nuncatimestampsem fuso. Um dia de calendário édate, mantido como stringYYYY-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/Lisbonnuma 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 umDatesimples - A formatação para pessoas é
Intl—Intl.DateTimeFormateIntl.RelativeTimeFormatcom o locale do ADR-0031 e umtimeZoneexplícito, sempre. O servidor e o browser produzem então a mesma string. Oformatdo 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.
Consequências
Seção intitulada “Consequências”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.

