A customer emailed me on 1 September. Her invoices were dated 31 August, and they should have said 1 September.
She was right. 328 of them.
One line
The invoices arrive from a billing platform by webhook. Each one carries a unix timestamp, and we turn it into a date:
const issueDate = new Date(inv.date * 1000).toISOString().slice(0, 10);
That looks like it produces a date. It produces a UTC date.
Poland runs UTC+2 in summer. The billing platform runs its subscription cycle at 00:00 Europe/Warsaw. So an invoice issued at midnight on 1 September is 2026-08-31T22:00:00Z, and toISOString() renders it as 2026-08-31.
Every invoice in that run was wrong.
Why it hid for a month
The bug only fires between midnight and 02:00 local time. Every invoice issued during the working day converts correctly, because the UTC date and the Warsaw date are the same date.
The integration had been live for a month. Invoices had been arriving one or two at a time, whenever someone signed up, and every one of them was right. Then the monthly billing run went out at exactly the wrong minute, and 328 invoices landed together in the wrong month.
The failure mode was invisible until it hit at scale, at the only time of day that triggers it.
Wrong day vs wrong month
An off-by-one day on a date field is usually a cosmetic bug. This one was not, for a boring reason: 31 August and 1 September are in different VAT periods.
These invoices go to Poland's national e-invoicing system. Once an invoice is filed there it cannot be edited or withdrawn. The only remedy is issuing a correcting invoice afterwards. So the difference between "wrong day" and "wrong month" is the difference between a typo and a tax filing in the wrong period.
The 328 had not been filed yet. That was luck, not design.
Finding the wrong rows without guessing
I could have selected everything dated 31 August and moved it forward one day. That would also have moved genuinely-August invoices, because 31 August is a real date on which real invoices exist.
The tempting shortcut is to use the arrival time: if a row arrived on 1 September and is dated 31 August, fix it. That is wrong too. An invoice legitimately issued at 23:58 on 31 August, whose webhook landed at 00:01, is correctly dated and would be corrupted by the fix.
What saved me was a second, independent signal. The billing platform's own invoice numbers carry the month:
PSA-09-2026-17
^^ September
So the rule became: correct a row only when the invoice number says September and the row arrived on 1 September Warsaw time and the stored date is exactly one day earlier. Three facts agreeing, two of them from the source system rather than from my own inference.
That found 328 rows, zero ambiguous ones, and left the 23:58 case alone. After the fix, all 490 invoices agreed with the platform's own numbering, where 328 had not.
If you are writing a data repair script, find the second signal before you write the UPDATE. The one-signal version would have quietly broken correct rows while looking like it worked.
The fix
Intl.DateTimeFormat with an explicit time zone. The sv-SE locale is the convenient one here because its date format is already YYYY-MM-DD:
const WARSAW_YMD = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
export function polishDate(d) {
return WARSAW_YMD.format(d);
}
Build the formatter once outside the function. Intl.DateTimeFormat construction is not free, and this runs per invoice.
Do not be tempted by the arithmetic version:
// Wrong six months a year
const local = new Date(ts * 1000 + 2 * 3600 * 1000).toISOString().slice(0, 10);
Poland is UTC+2 in summer and UTC+1 in winter. A hardcoded offset is correct until the last Sunday in October, then silently wrong until March. Intl reads the timezone database, so DST is handled for you.
The test that catches it
Two cases, and the second is the one that matters:
// 2026-09-01 00:00 Europe/Warsaw (22:00 UTC on 31 August)
expect(polishDate(new Date(1788213600 * 1000))).toBe("2026-09-01");
// 2026-01-01 00:00 Europe/Warsaw (23:00 UTC on 31 December), UTC+1
expect(polishDate(new Date(1767222000 * 1000))).toBe("2026-01-01");
The January case fails against a hardcoded +2. Without it, the fix looks correct for six months.
I also reverted the fix and re-ran the tests, to confirm they actually caught the original bug. A test you have never seen fail is a test you have not written yet. Mine passed on the first run before I checked, which is exactly when I should have been suspicious.
The general shape
toISOString() is a timestamp serializer. It is not a date formatter. It answers "what is this instant in UTC", and if you slice the first ten characters off it, you have asserted that UTC is the calendar your users live in.
That is fine for a log line. It is not fine when the date has meaning to somebody: a tax period, an invoice date, a delivery date, a contract term, a birthday.
Three questions worth asking about any date field in your system:
- Does this date belong to a place? An issue date on a Polish invoice is a Polish calendar date, no matter where the server or the billing platform is. If the answer is yes, the timezone is part of the data, not a display concern.
- What happens near midnight? Every timezone bug lives between 00:00 and the UTC offset. If your tests use midday timestamps, they will all pass.
- What happens in the other season? Half the year is DST. Test in January and in July.
The customer found this one before we did. She had been sending invoices through the system for six weeks, noticed the dates were a day out, and told us the same morning. That is a good customer, and it is not a substitute for a test at midnight.
I hit this building FakturaFlow, which files invoices to Poland's e-invoicing system in bulk. That link goes to a longer write-up of what else breaks at a thousand invoices a month, in Polish, since that is who has to deal with it.
If you convert a timestamp to a date anywhere in your codebase, it is worth ten minutes with grep today:
grep -rn "toISOString().slice(0, 10)" src/
That found eight more in mine. Seven turned out to be fine: a download filename, today's date in a prompt, a test fixture. Being a day out in a filename costs nothing.
The one I am still looking at is a central bank exchange-rate lookup, because the rate published "for 1 September" is a Polish calendar date too, and asking for the wrong day returns the wrong number rather than an error.
That is the test. Not "is this UTC", but who cares what day this is, and what does it cost them if it is wrong. Most of the time, nobody. Occasionally, a tax authority.
Top comments (0)