For handling the different lengths between the months, I've considered a few approaches.
1. If re-bill is supposed to be the Nth of each month, that simply means N-1 days after the 1st. If that falls into another calendar month, fine. So if someone asks to be billed on the 30th of each month, they will be billed on Jan 30th for January, Mar 2nd for February, Mar 30 for March, and so on. I'd make sure the receipt explicitly says "Re-bill for January", "Re-bill for February", etc., so that bill the see on Mar 2nd hopefully will not confuse them, nor will seeing another bill later that month.
2. If re-bill is supposed to be on the 29th-31st, for February it takes place on the 28th (29th in leap years). For other months, if re-bill is supposed to be on 31st, it takes place on the 30th in April, June, September, and November.
3. If you initially order on day 1-21 of the month, your re-billing is the same day ever month. If you initially order on day 22+, your re-billing is on the same number of days before the end of the month as was your initial order. For example, someone who orders on May 30th would re-bill on the 30th in 31 day months, the 29th in 30 days months, and the 27th in February (28th in leap years). If you ask for a change in fixed billing date, you can do so either as Nth day of the month or Mth day from the end of the month, but N or M must be <= 28.
In general, however:
Don't fall victim to one of the classic dev blunders -- the most famous of which is "don't roll your own crypto" -- but only slightly less well-known is this: "Never write your own billing logic when business is on the line!"
> 1. If re-bill is supposed to be the Nth of each month, that simply means N-1 days after the 1st.
I have a credit card that takes the much simpler approach "Your payment due date is the Nth of each month. N can be any value from 1 to 24."
This seems to basically match your approach 3.
If you ask how I think you should bill ( :p ), do it on a fixed day and don't try to do rebilling at a fixed interval. If the day somebody buys your thing is unsuitable for fixed-date billing, the answer is prorating their first period, not interval billing.
This may have unintended consequences. Some examples we faced around this:
1. Stress on our APIs when 15,000 invoices are created in the span of a couple of minutes. We had to build more queueing mechanisms around this. Some services just can't handle more than 100 calls a second.
2. Getting rate limited by MasterCard
3. Some payment providers thinking we're brute-forcing them
I'm just saying this needs consideration when you reach a certain scale.
Brunei weekends are Friday and Sunday. Those poor folks work Mon-Thurs, and then go back to work Saturday!
Jan 27 + 5 days = Feb 1
Feb 1 + 1 month = Mar 1
Jan 27 + 1 month = Feb 27
Feb 27 + 5 days = Mar 4 (or Mar 3 in a leap year!)
Therefore... Jan 27 + 5 days + 1 month = Mar 1
Jan 27 + 1 month + 5 days = Mar 1
AND Jan 27 + 5 days + 1 month = Mar 4 (or 3)
Jan 27 + 1 month + 5 days = Mar 4 (or 3)
Before someone claims that this is just a February problem, please first consider that you can create these scenarios with any set of months that have differing lengths.Then, please consider that your argument that ignoring February is not a good way to approach any calendaring library....
I had specifically worked on functions like EXTRACT to pull out different parts like ISO Week numbers, as well as DATEADD and DATEDIFF functions which do date math. It is exceptionally unpleasant!
Add a month to the epoch time? Can't do it because that doesn't adequate indicate what day we think it is. "select '2021-01-30'::date + make_interval(months := 1)" says Feb 28? Done!
Do they do this? I don't know.
Even if they could do this in a single expression, there are absolutely scenarios where two date-arithmetic operations may be performed in two separate places in code. If those operate on the same logical date value, then we will see the same commutativity issues.
[0]: My sibling comment on this: https://news.ycombinator.com/item?id=26707864
One solution is to distinguish between a "datetime" and a "duration". You cannot convert a duration to days (or really anything). You can add or subtract a duration from a datetime, and you can convert the difference between two concrete datetimes into days.
And that's actually how some libraries already implement it.
So the answer to "how long is a month" is "it depends, and I can only say if you apply this duration to a concrete time".
You use the standard datetime libraries so you don't have to deal with all that complexity. Only, they automagically muck with the data, including irrelevant precision, such as time and timezone and daylight savings time for a calendar date.
And for the sake of completeness, your application allows the customer to specify a default timezone (usually that of their headquarters or something) and individual users to specify their own (useful if they work remote or in another office or something).
So now you have your database's timezone (and datetime handling), your server's timezone, your application's timezone (and datetime library handling), the customer's configured timezone, the user's configured timezone, their client's own timezone handling (which may be different if they're on a business trip in a different location), all screwing with the date of something that shouldn't even have a time, timezone, or daylight savings in the first place.
That's an endless whack-a-mole of bugs. And trying to just use UTC everywhere possible only gets you so far.
I can only imagine needing to toss in standard/daylight timezone switch in there. Billing code really is the pinnacle of developer pain. It mixes the arbitrariness of special case business and customer rules with the absolute horror of time and date math.
SELECT CreatedDate AT TIME ZONE 'UTC' AT TIME ZONE 'Eastern Standard Time' FROM MyTable
would in fact return `CreatedDate` at Eastern Daylight Time if executed today.Looking back I'm amazed at how easily companies got off the ground by hiring eager (but exceptionally green) young web devs to write sensitive code for ordering/sign-up, recurring billing, and more.
I still don't exactly know how to handle this. If you start an account that compounds interest continuously, does the daily interest rate change if one year is longer than another?
It might make some of the math/algorithms a bit painful but I think you could guarantee correctness with the above approach.
I like "730.5 hours" (usually rounded to just 730). It's a good fit for services billed by the hour or minute. I use it regularly to answer "how much per month" for AWS stuff. But yeah - it fits in there with your "Just no..." category.
- An even 30 days = Easy to explain and do math with. Adding/subtracting 1 month can leave you in the same month or skip February entirely! Anniversary dates bounce around from month-to-month. Maps poorly to yearly calculations.
- However many days >this< month has = Harder to explain and do math with. Months without 31 days can get skipped when adding/subtracting 1 month. Recurring events get pushed away from the end of the month to cluster around the beginning of the month. It does result in a steady anniversary date for edge cases.
- Closest numeric day, but prev/next month (e.g. Jan 31 -> Feb 28): Easy and hard to explain and do math with. It's great for ensuring events only ever happen once per calendar month, but adds extra ambiguity (e.g. Jan 31 > Feb 28 > March 30? ... or March 28th?)
- 30.4... = Just no... except for the few times this is right and you need consistency to avoid unfairly comparing 28 days vs 31 days.
The consequence is some very surprising things the first time you see them. Some billing systems simply avoid doing work past the 28th of each month (either doing it a bit early or a bit late). Some just embrace the weirdness (whichever you flavor you pick) and you get used to the quirks (e.g. end-of-month lulls and start-of-month spikes).