The calendar is reference data, not a utility function
14 min read
Every date helper gets written on an ordinary Tuesday in March. Nothing is closed, the clocks do not change, the month does not end, and the two lines that ship that afternoon are correct for the next nine weeks.
const isBusinessDay = (d: Date) =>
d.getDay() !== 0 && d.getDay() !== 6;
Then Good Friday. Then an instruction submitted at 17:40 that your ledger books to today and the bank books to tomorrow. Then a payment to Karachi that sits unsettled through an Eid holiday your calendar table never had, because the date was fixed by a moon sighting announced the previous evening. Then the last business day of December, where an interest period ends on the 31st in your system and on the 30th in the counterparty's, and the gap is a day of interest on a balance with nine figures in it.
Date bugs in financial systems are seasonal. They do not surface under load, they surface on the calendar. They pass review, pass staging, run correctly for a quarter, and then fail on a day when half the people who could fix them are away.
The mistake underneath all of them is the same one: treating a business day as something you compute. It is not a function. It is reference data, with an owner, a jurisdiction, a publication date and a version. created_at is a fact about your server. Every date that decides what the money does is a fact about somebody else's calendar, and the second does not follow from the first.
One payment, several dates, all different on purpose
A payment that a user experiences as a single event has at least four dates attached to it, and they routinely disagree by days.
| Date | Answers | Owned by |
|---|---|---|
| Instruction date | when the customer asked | you |
| Booking date | when your ledger recorded it | you |
| Value date | from when the money counts, and interest runs | the scheme or the contract |
| Settlement date | when funds moved between institutions | the settlement system |
A transfer instructed at 18:00 on the Friday before a bank holiday weekend has an instruction date of Friday, a booking date of Friday if you book on receipt, a value date of the following Tuesday, and a settlement date of Tuesday afternoon. Four answers to "when did this happen", none of them wrong, none of them interchangeable.
Most schemas have one date column. That means somebody picked one of these and discarded the other three, and which one they picked is now implicit in whichever query gets written next. The reporting query wants booking date, because that is the day the ledger moved. The interest calculation wants value date, because that is the day the money started earning. The reconciliation job wants settlement date, because that is the day the provider's file says it happened. Give all three the same column and at least two of them are quietly answering a different question than they think they are.
The tell that this has already happened to you: a report that is right except in the first week of the month, or a support ticket that says the money arrived on Tuesday but the statement says Friday. Both of those are one column doing three jobs.
The cut-off is what turns a timestamp into a date
An instant is not a date. A cut-off is the rule that maps one onto the other, and every financial system you touch has one.
Fedwire funds transfer closes at 18:30 Eastern, with third party payments cut off half an hour earlier. The euro system stops taking customer payments in the late afternoon Central European Time and closes to banks about an hour after that. Card networks have their own daily cycle. Your own batch has one too, whether or not anybody wrote it down: there is a moment each day after which a payment lands in tomorrow's file, and if nobody has named it, it is wherever the cron happens to be pointed.
Three properties of a cut-off that code gets wrong.
It lives in an institution's timezone, not in UTC and not in the user's. A 17:00 Central European Time cut-off is 16:00 UTC in winter and 15:00 UTC in summer. Store it as a UTC instant and it is wrong for roughly half the year, in a way that only misfires for payments submitted in a one hour band, which is exactly narrow enough to look like a fluke when the first ticket arrives.
Crossing it moves the date, not the time. After the cut-off, a payment does not become late. It becomes tomorrow's payment, with tomorrow's value date, and tomorrow might be Tuesday. The correct output of a cut-off rule is a business date, and the business date is what everything downstream should use.
Different products have different cut-offs against the same clock. Same currency, same rail, same institution, but the treasury desk's deadline is not the retail deadline. This is a per product configuration value, not a constant.
The workable shape is one function, at the edge, that answers a single question: given this instant and this product, which business date is this? Everything after that point deals in dates. Ledger entries, files, reports, interest, all of it. The moment a timestamp gets carried deeper into the system so that some later stage can "work out the day", you have two components that can disagree about what day it is, and one of them will be running in a different timezone than the other on the machine where it matters.
The weekend is not Saturday and Sunday
Saudi Arabia and Egypt rest on Friday and Saturday. The UAE did too, until it moved to a Saturday and Sunday weekend with a half day on Friday in January 2022, which broke a great many hardcoded assumptions in a single step. Saudi Arabia had made its own move a decade earlier, from Thursday and Friday to Friday and Saturday in 2013. Pakistan's weekly holiday was Friday until 1997, when it went back to Sunday.
Three countries, three changes, well within the lifetime of code that is still running.
d.getDay() !== 0 && d.getDay() !== 6 is not a simplification of the rule. It is one jurisdiction's rule, hardcoded, in a place where nobody will look for it when the assumption changes.
The weekend belongs in the calendar data alongside the holidays, because it is the same kind of thing: a fact about a place that someone else decides and occasionally changes.
Whose calendar, exactly
The calendar that applies is the one belonging to the currency and the settlement system, not the one belonging to your office.
The euro settlement system closes on six weekdays a year: 1 January, Good Friday, Easter Monday, 1 May, 25 December and 26 December. That is fewer closures than the national holiday list of any member state. A euro payment settles perfectly well on a German unification day or a French national day, and does not settle on Easter Monday even where Easter Monday is a normal working day. The settlement calendar and the public holiday calendar are different data sets that happen to overlap.
Meanwhile the Federal Reserve observes Columbus Day and Veterans Day, which are not holidays in London or Frankfurt, and it handles weekend collisions with a rule of its own: a holiday falling on a Sunday moves to the Monday, while one falling on a Saturday does not move at all. The UK does the opposite thing in its own way, substituting a weekday whenever a bank holiday lands on a weekend, which is why Boxing Day sometimes produces a bank holiday on 28 December. Two systems, same situation, incompatible rules.
Then the composite cases. A cross currency payment needs both currencies' calendars, and the usable settlement days are the intersection, which is always a shorter list than either. Foreign exchange settlement conventionally drags the US dollar calendar in even when neither leg is dollars. Japan's Golden Week and the Lunar New Year each take a run of consecutive days out of the year, so a T plus 2 instruction in early May can land a week away.
And the part that quietly invalidates the whole approach of shipping a holiday table with the build: some holidays are not known in advance. Both Eids move with the lunar calendar and are fixed by a sighting announcement, which in Pakistan comes from the Ruet-e-Hilal Committee on the evening before, so a bank holiday of two or three days materialises with less than a day of notice. The UK created two one off bank holidays in the space of a year, for the state funeral in September 2022 and the coronation in May 2023, both announced with a few weeks of notice. A generated table covering next year is wrong by construction, because some of next year has not been decided yet.
Which makes the calendar an operational feed, not a constant. It needs a source, a refresh, an owner who notices when the refresh fails, and an alert when a payment is being priced against a calendar that has not been updated in six weeks. Getting a stale calendar is not a crash. It is a wrong value date that looks completely normal.
Rolling conventions have money attached
Once a date lands on a closed day, something has to move it, and which direction it moves is a commercial term rather than a technical preference.
- Following: roll forward to the next business day.
- Preceding: roll back to the previous one.
- Modified following: roll forward, unless that crosses into the next calendar month, in which case roll back.
- End of month: if the start date was the last business day of a month, every subsequent date lands on the last business day of its month.
Modified following exists because interest accrues monthly and periods that leak across a month boundary make a mess of accrual, reporting and the general ledger cut. That is the whole reason for a rule that looks arbitrary from the outside.
The cost is not abstract. Rolling a date changes the number of days in the period, and days multiplied by notional multiplied by rate is money. Whether the extra day gets paid for depends on the day count convention sitting next to it, because under ACT/360 the extra day is real and gets paid, and under 30/360 every month is thirty days long and the calendar's opinion is politely ignored.
So the convention is part of the instrument, and it belongs in data next to the instrument, not inside a shared helper called nextBusinessDay. The moment two products with different conventions call the same helper, one of them is wrong, and it is wrong by an amount that shows up in someone's interest statement rather than in an exception.
Store the date you decided, do not recompute it
Here is the failure that turns all of the above from an annoyance into an incident.
A holiday gets added late. A vendor corrects last year's calendar. Someone fixes a bug in the rolling code. Now re-running the value date calculation for a payment from March returns a different answer than the one you gave the customer, wrote into the ledger, and sent to the counterparty.
Any code that derives a historical date on demand has this problem. A report that computes "the business date of this payment" from created_at at the moment it runs is not reading history, it is re-deciding history against today's reference data. Every calendar update silently rewrites the past, and it rewrites it differently in each place that recomputes.
The value date is a decision, and decisions are facts. Record it:
alter table payments
add column business_date date not null,
add column value_date date not null,
add column calendar_id text not null,
add column calendar_version text not null,
add column roll_convention text not null;
The date columns are the answer. The other three are why, which is what makes an argument with a counterparty six months later a five minute lookup rather than an archaeology project. This is the same argument as an append only ledger: a correction is a new row, not an edit to the old one, and history is what was decided at the time rather than what today's code would decide about then.
It is also what makes the daily reconciliation possible at all. Matching your day against a provider's day requires both sides to agree on what a day is. If your file boundary is midnight UTC and theirs is a cut-off at 17:00 local, every single day has a band of payments that appear on different sides, forever, and no amount of matching logic fixes it. It gets fixed by adopting their business date as the one you store.
Midnight is a bad place to keep a date
A business date is a calendar date. It is not an instant, and the type should say so: date in Postgres, LocalDate in Java, a plain year month day string in TypeScript, where the built in Date is a timestamp wearing a misleading name.
Store it as a timestamp at midnight and you have created a value that changes meaning when it crosses a timezone. Midnight on the 1st in UTC is the 31st in New York, so a value date renders one day earlier for anyone whose display layer localises it, which nobody notices until a user in a negative offset screenshots their statement.
Midnight is worse than merely ambiguous, because in some places it does not exist. Zones that start daylight saving at 00:00 jump straight from 23:59 to 01:00, and Brazil did this every year until it dropped the practice in 2019. Constructing a date as "this day at midnight in that zone" throws or silently shifts, depending on which library you used and which of its two constructors you reached for.
Month arithmetic has the same shape of trap. Adding one month to 31 January gives 28 February in most libraries, and adding one more month gives 28 March rather than 31 March, so twelve additions do not land where anyone expects. That is precisely why the end of month convention exists as an explicit flag: the correct behaviour is a business decision and the library's default is only a guess at it.
The bugs are seasonal, so the tests have to be
Two changes make the whole category testable.
Inject the clock. A single call to LocalDate.now() buried in a pricing path means the only way to test December is to wait for December. Pass the clock in, and running the year end case in August takes a second.
Keep an explicit list of dates that hurt and run the suite against every one of them. It is short and it does not change:
- 31 December and 1 January
- 29 February, and 1 March in a leap year
- Good Friday and Easter Monday, which move every year
- a substitute bank holiday, where the holiday and the closure are on different days
- both daylight saving transitions, in the cut-off's timezone rather than yours
- a month end that falls on a Sunday
- a run of consecutive closures such as Lunar New Year
- the day before and the day after each of the above, because off by one errors are the point
Property tests earn their place here, in the same way they do anywhere invariants are cheap to state and expensive to violate. Rolling any date lands on a business day. Modified following never leaves the starting month. Adding n business days and then subtracting n returns the original, given the original was a business day. Each of those is one line, and each has caught a real bug in a real calendar library.
And one test with no obvious home, which is the one that matters most: update the holiday table, re-run the historical valuation, and assert that no stored value date moved. That is the regression test for the entire preceding section, and it is the one nobody writes.
What this doesn't buy you
- Not agreement with the counterparty. Two systems can each be internally consistent and still book a payment to different days. That is a permanent condition, which is why reconciliation exists rather than being a sign that something is broken.
- Not correct reference data. Storing the calendar version tells you which data you used. It does not tell you the vendor had the holiday right, and vendors get one off holidays late.
- Not general timezone correctness. Business dates are a narrow slice. The rest of the application still has to decide what a user's "today" means, and that is a different question with a different answer.
- Not a replacement for the contract. Conventions are commercial terms that someone negotiated. Code implements them, it does not get to choose them.
- Not immunity from your own clock. If the server's time is wrong, a perfect cut-off implementation produces a perfectly wrong date, and NTP failures are silent.
The short version
- A business day is reference data with an owner, a jurisdiction and a version, not a function you write once.
- One payment has an instruction date, a booking date, a value date and a settlement date. One column cannot hold all four, and each report wants a different one.
- The cut-off is the rule that maps an instant onto a business date. It lives in the institution's timezone, so storing it in UTC breaks it for half the year.
- Past the cut-off, work becomes tomorrow's rather than late. Convert to a date at the edge and pass the date, not the timestamp.
- The weekend is data, and it changes. Saudi Arabia and Egypt rest on Friday and Saturday, the UAE moved to Saturday and Sunday in 2022, and Pakistan moved off Friday in 1997.
- The calendar belongs to the currency and settlement system, not to your office. Euro settlement closes six weekdays a year and ignores national holidays.
- Cross currency means the intersection of calendars, which is always shorter than either.
- Some holidays are not knowable in advance, so a table baked at build time is wrong by construction. Feed it, refresh it, alert when it goes stale.
- Rolling conventions are commercial terms. Modified following exists to keep interest periods inside their month, and the day count convention decides whether the rolled day gets paid for.
- Never recompute a historical business date. Store the decision, the calendar version and the convention, because updating the calendar otherwise rewrites the past.
- A business date is a
date, never a timestamp at midnight. Midnight does not exist in every zone on every day. - The bugs are seasonal, so inject the clock and keep a standing list of the dates that hurt.
Two things worth doing this week. Grep for the codebase's weekend check, whatever form it takes, and count how many callers assume the answer applies in every market you operate in. Then take last December's payments, recompute their value dates with today's calendar and today's code, and diff against the stored values. If anything moves, the past is being rewritten on every deploy, and the only reason nobody has complained is that nobody has looked.