Formatting Brazilian money, dates and amounts in words
Four small things that make a document look Brazilian or look foreign — and the invisible character in Intl's output that breaks string comparison in your tests.
A document reads as Brazilian or reads as a translation, and the difference is almost entirely
formatting. R$ 1.234,56 not R$ 1,234.56. 07/08/2026 not
08/07/2026. And on a formal receipt, the amount written out in words.
Each of these has a trap that survives code review.
Everything here is what FaturaPDF applies internally, so you can either implement it or send raw numbers and let the document layer handle it. The related domain rules live in the CPF/CNPJ reference and the PIX BR Code reference; this page is only about numbers, dates and words.
Currency: dots and commas, swapped
Brazilian formatting uses dot for thousands, comma for decimals — the exact inverse of
US convention — with the symbol first: R$ 1.234,56. Negative amounts are usually
-R$ 1.234,56, and in accounting contexts parentheses appear.
The invisible character
The obvious approach is Intl, and it works. But look closely at what it returns:
const f = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" });
const s = f.format(1234.56);
console.log(s); // R$ 1.234,56
console.log(s === "R$ 1.234,56"); // false (!)
[...s].map((c) => c.charCodeAt(0).toString(16));
// [ '52', '24', 'a0', '31', '2e', '32', '33', '34', '2c', '35', '36' ]
// ^^^^ U+00A0 NO-BREAK SPACE, not U+0020The separator between R$ and the number is a no-break space (U+00A0), not an
ordinary space. That is typographically correct — you do not want a line break between the symbol and
the amount — but it means:
- String equality against a literal you typed with a normal space fails, which is a genuinely baffling test failure the first time.
- A naive
parsethat splits on" "does not split. - Some PDF fonts do not carry a glyph for U+00A0 and render it as a box or drop it.
// In tests, normalize before comparing:
const normalize = (s) => s.replace(/\u00a0/g, " ");
expect(normalize(format(1234.56))).toBe("R$ 1.234,56");The other Intl problem: it is not always there
Intl with full locale data depends on the runtime's ICU build. Node built with
small-icu, some edge runtimes, and trimmed containers silently fall back to
en-US — so your currency comes out as R$1,234.56 in production and
R$ 1.234,56 on your laptop. If the output is a document rather than a UI, formatting by
hand removes the dependency entirely:
/** "R$ 1.234,56" — no Intl, no locale data, identical on every runtime. */
export function formatBRL(value) {
const cents = Math.round((Number(value) || 0) * 100);
const negative = cents < 0;
const abs = Math.abs(cents);
const reais = Math.floor(abs / 100);
const centavos = String(abs % 100).padStart(2, "0");
const withDots = String(reais).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return `${negative ? "-" : ""}R$ ${withDots},${centavos}`;
}
formatBRL(1234.56); // "R$ 1.234,56"
formatBRL(-89.9); // "-R$ 89,90"
formatBRL(1000000); // "R$ 1.000.000,00"
formatBRL(0.5); // "R$ 0,50"The regex \B(?=(\d{3})+(?!\d)) inserts a dot at every position that has a multiple of
three digits to its right, and \B keeps it from firing at the start of the string.
Round in cents, not in floats
Notice that formatBRL converts to integer cents first. That is not decoration:
(0.1 + 0.2).toFixed(2); // "0.30" fine
(1.005).toFixed(2); // "1.00" <-- expected "1.01"
(8.615).toFixed(2); // "8.62" fine
// Why: 1.005 is stored as 1.00499999999999989341858963598497211933...
// so toFixed rounds it DOWN, correctly, to a value you did not intend.The failure is data-dependent: 8.615 works, 1.005 does not, and which of your
prices land on the bad side is essentially random. There is no rounding mode that fixes it, because the
value was already wrong before rounding. The only robust fix is to keep money in integer cents from the
database to the document, and divide only at the last moment.
Store cents as integers. Sum cents as integers. Convert to reais once, for display or for the API payload. FaturaPDF does the same internally — it takes your reais, converts to integer cents, sums there, and formats from the integer, so a hundred-line invoice cannot drift by a centavo.
Dates: DD/MM/YYYY, and the timezone bug
Brazil writes 07/08/2026 for 7 August. The ambiguity with US MM/DD/YYYY is
silent for the first twelve days of every month, which is exactly long enough for a bug to reach
production.
The trap is not the format — it is the parse:
// "YYYY-MM-DD" without a time is parsed as UTC midnight.
const d = new Date("2026-08-07"); // 2026-08-07T00:00:00Z
// Rendered in Brazil (UTC-3), that instant is still 6 August, 21:00.
d.toLocaleDateString("pt-BR", { timeZone: "America/Sao_Paulo" });
// -> "06/08/2026" <-- off by one day
// "YYYY-MM-DDT00:00:00" (no Z) is parsed as LOCAL time instead. Different
// bug, same class: the answer depends on the server's timezone.A date on an invoice is a calendar date, not an instant. Do not route it through a timestamp at all:
/** "2026-08-07" or a Date -> "07/08/2026". No timezone involved. */
export function formatDateBR(input) {
if (typeof input === "string") {
const iso = input.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) return `${iso[3]}/${iso[2]}/${iso[1]}`;
const br = input.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (br) return `${br[1].padStart(2, "0")}/${br[2].padStart(2, "0")}/${br[3]}`;
throw new Error(`unrecognized date: ${input}`);
}
// If you must start from a Date, read its parts in the intended zone.
const parts = new Intl.DateTimeFormat("pt-BR", {
timeZone: "America/Sao_Paulo", day: "2-digit", month: "2-digit", year: "numeric",
}).formatToParts(input);
const get = (t) => parts.find((p) => p.type === t).value;
return `${get("day")}/${get("month")}/${get("year")}`;
}DST was abolished in 2019, so America/Sao_Paulo is a stable UTC−3. That removes a class of
bug, but do not hard-code -03:00: the country spans several zones (Acre is UTC−5) and
historical dates before 2019 still carry DST offsets. Use the IANA zone name.
Amounts in words (valor por extenso)
Formal Brazilian receipts spell the amount out next to the figure — a habit inherited from paper documents, where digits could be altered and words could not. It is still expected on a recibo.
R$ 6.149,90 -> seis mil, cento e quarenta e nove reais e noventa centavos
R$ 1.234,56 -> mil, duzentos e trinta e quatro reais e cinquenta e seis centavos
R$ 100,00 -> cem reais
R$ 1,00 -> um real
R$ 0,05 -> zero reais e cinco centavos
R$ 1.000.000,00 -> um milhão de reais
R$ 2.000.000,00 -> dois milhões de reaisFour rules do most of the work, and each one is a place implementations go wrong:
1. "cem" versus "cento"
Exactly 100 is cem. Anything from 101 to 199 uses cento: cento e um, cento e cinquenta. Never "cem e um".
2. "mil" takes no article
1.000 is mil, not "um mil". But 1.000.000 is um milhão — the rule applies only to the thousands scale.
3. The "de" before the noun
When the number ends on a million/billion/trillion scale word, Portuguese requires de: um milhão de reais, dois bilhões de reais. But not when a remainder follows: um milhão e quinhentos mil reais — no "de", because the last group is not the scale word. And never after "mil": dois mil reais, not "dois mil de reais".
4. "e" versus comma
Groups are separated by commas, except that e introduces the final group when it is under 100 or an exact multiple of 100: seis mil, cento e quarenta e nove reais uses a comma before "cento" because 149 is neither. Within a group, e joins hundreds to tens and tens to units: cento e quarenta e nove.
const UNITS = ["", "um", "dois", "três", "quatro", "cinco", "seis", "sete", "oito", "nove"];
const TEENS = ["dez", "onze", "doze", "treze", "catorze", "quinze",
"dezesseis", "dezessete", "dezoito", "dezenove"];
const TENS = ["", "", "vinte", "trinta", "quarenta", "cinquenta",
"sessenta", "setenta", "oitenta", "noventa"];
const HUNDREDS = ["", "cento", "duzentos", "trezentos", "quatrocentos", "quinhentos",
"seiscentos", "setecentos", "oitocentos", "novecentos"];
/** 1..999 in words. */
function upTo999(n) {
if (n === 0) return "";
if (n === 100) return "cem"; // rule 1
const parts = [];
const h = Math.floor(n / 100);
const rest = n % 100;
if (h > 0) parts.push(HUNDREDS[h]);
if (rest > 0) {
if (rest < 10) parts.push(UNITS[rest]);
else if (rest < 20) parts.push(TEENS[rest - 10]);
else {
const t = Math.floor(rest / 10);
const u = rest % 10;
parts.push(u === 0 ? TENS[t] : `${TENS[t]} e ${UNITS[u]}`);
}
}
return parts.join(" e "); // rule 4, within a group
}The scale loop on top of that carries one extra piece of state: whether the last group emitted was a million-or-higher scale word, which is what decides rule 3. Implementations that return only a string cannot express that, which is why "um milhão reais" shows up in the wild.
const SCALES = [
{ value: 1e12, one: "trilhão", many: "trilhões" },
{ value: 1e9, one: "bilhão", many: "bilhões" },
{ value: 1e6, one: "milhão", many: "milhões" },
{ value: 1e3, one: "mil", many: "mil" },
];
function integerToWords(n) {
if (n === 0) return { text: "zero", needsDe: false };
let rest = n;
const groups = [];
for (const s of SCALES) {
if (rest < s.value) continue;
const count = Math.floor(rest / s.value);
rest -= count * s.value;
if (s.value === 1000 && count === 1) {
groups.push({ text: "mil", value: 1000, highScale: false }); // rule 2
} else {
groups.push({
text: `${upTo999(count)} ${count === 1 ? s.one : s.many}`,
value: count * s.value,
highScale: s.value >= 1e6,
});
}
}
if (rest > 0) groups.push({ text: upTo999(rest), value: rest, highScale: false });
const last = groups[groups.length - 1];
if (groups.length === 1) return { text: last.text, needsDe: last.highScale };
const head = groups.slice(0, -1).map((g) => g.text).join(", ");
const joiner = last.value < 100 || last.value % 100 === 0 ? " e " : ", ";
return { text: head + joiner + last.text, needsDe: last.highScale };
}
export function amountToWordsPTBR(value) {
const totalCents = Math.round((Number(value) || 0) * 100);
const negative = totalCents < 0;
const abs = Math.abs(totalCents);
const reais = Math.floor(abs / 100);
const cents = abs % 100;
const r = integerToWords(reais);
let out = `${r.text}${r.needsDe ? " de" : ""} ${reais === 1 ? "real" : "reais"}`; // rule 3
if (cents > 0) {
const c = integerToWords(cents);
out += ` e ${c.text} ${cents === 1 ? "centavo" : "centavos"}`;
}
return (negative ? "menos " : "") + out;
}Note the integer-cents split at the top, for the same reason as the currency formatter: computing
Math.floor(value) and (value % 1) * 100 on a float gives you 89 centavos where
you meant 90.
The values that break implementations are 100, 101, 1000, 1001, 1000000, 1000001, 1500000, and anything with zero centavos versus one centavo. Table-drive those eight and you will catch essentially every rule violation.
A quick checklist
- Money stored and summed as integer cents; converted once, at the edge.
- Currency formatted without
Intlif the output is a document — or withIntlplus a U+00A0-aware comparison if it is a UI. - Dates handled as calendar dates, never as timestamps that get re-rendered in another zone.
America/Sao_Pauloas an IANA name, never a hard-coded-03:00.- Amounts in words on receipts, with the cem/cento, mil, "de" and "e" rules tested at their boundaries.
Frequently asked questions
Why does my formatted BRL string not equal the literal I wrote in the test?
Because Intl.NumberFormat('pt-BR') separates R$ from the number with a no-break space (U+00A0), not a regular space (U+0020). Normalize both sides before comparing, or format by hand as shown above.
Is toFixed(2) safe for money?
No. (1.005).toFixed(2) returns "1.00" because 1.005 is not exactly representable in binary floating point — it is stored slightly below 1.005, so rounding down is arithmetically correct and commercially wrong. Keep money in integer cents and the problem disappears.
Why is my invoice dated one day earlier than expected?
new Date("2026-08-07") parses as UTC midnight; rendered in Brazil (UTC−3) that instant is 6 August at 21:00. An invoice date is a calendar date, not an instant — format it from the string parts, or extract day/month/year explicitly in America/Sao_Paulo.
Do I have to write the amount in words?
On a recibo it is the strong convention and people expect it; on a fatura it is optional and usually omitted. FaturaPDF adds it automatically on receipts and exposes mostrar_valor_por_extenso for invoices.
Does Brazil still use daylight saving time?
No, it was abolished in 2019, so America/Sao_Paulo is a stable UTC−3. Still use the IANA zone name rather than a fixed offset: the country spans several zones, and dates before 2019 carry DST offsets that a hard-coded value gets wrong.
Or let the document layer handle it
FaturaPDF applies all of this — thousands-dot currency, DD/MM/YYYY dates, integer-cent arithmetic and the amount spelled out in Portuguese — from raw numbers in your payload. You send 349.90; the PDF says R$ 349,90.