How do you calculate the next billing date for a recurring subscription?
Adding one month to 31 January is a decision, not a calculation, and every library makes a different one. What to pick and why anchoring matters.
Answer
Store the original billing day separately from the next billing date and derive forward from it, rather than repeatedly adding an interval to the last computed date. Otherwise a subscription anchored on the 31st drifts permanently to the 28th after one February, and no library will warn you — they simply clamp and move on.
Leutrim Miftaraj
Founder, SubTracker · Updated September 5, 2026
The short answer
The bug is not the February clamp; every library clamps and that is correct. The bug is applying the clamp to the stored value, so the anchor day is lost and the subscription silently migrates to the 28th for the rest of its life.
The failure, concretely
A subscription bills on 31 January. Add one month.
Every date library returns 28 February (or 29 in a leap year), because 31 February does not exist. That is correct and unavoidable.
The bug is what you do with it. If you write 28 February back as the new next-billing date and add a month to *that* next time, you get 28 March. Then 28 April. The subscription has permanently moved from the 31st to the 28th, and the user's actual bank charge is still on the 31st.
Nothing errors. Nothing logs. The dates are simply wrong from February onward, and the divergence is invisible until a user says the renewal date does not match their statement.
The fix: anchor plus derive
Two columns instead of one.
""`sql alter table subscriptions add column anchor_day smallint check (anchor_day between 1 and 31); ""`
"anchor_day" is the day of the month the subscription was originally billed on. It never changes. "next_billing_date" is derived from it and may be clamped for any given month without that clamp being written back to the anchor.
""`ts export function nextBilling( anchorDay: number, from: Date, unit: 'day' | 'week' | 'month' | 'year', every: number, ): Date { if (unit === 'day') return addDays(from, every) if (unit === 'week') return addDays(from, 7 * every)
const months = unit === 'year' ? 12 * every : every const target = addMonths(startOfMonth(from), months) const lastDay = getDaysInMonth(target) // Clamp for display, never for storage. The anchor stays 31. return setDate(target, Math.min(anchorDay, lastDay)) } ""`
Now 31 January gives 28 February, then 31 March, then 30 April, then 31 May. Which is what the provider actually does.
The other edge cases, ranked by how often they bite
Month-end anchors, 29 to 31. The one above. Affects roughly a tenth of subscriptions and is the only one that produces permanent drift.
Leap day. An annual subscription anchored on 29 February. Providers differ — some bill 28 February in non-leap years, some 1 March. Pick 28 February and document it; being consistent matters more than being right, because you cannot be right for every provider.
Timezones. A billing date is a day, not an instant. Store it as "date", not "timestamptz". Storing it with a timezone means it shifts under conversion and a user in Auckland sees a different renewal date from the one in their contract.
Daylight saving. Only bites if you store instants. Another reason not to.
Very long intervals. Every 18 months, every three years for a domain. The unit-plus-multiplier model handles these; a fixed enum does not, and the workarounds produce wrong dates.
What we got wrong
We shipped the naive version: add the interval to the stored next-billing date, write the result back.
It survived until the first February. Then a handful of subscriptions quietly moved to the 28th and stayed there. Nobody reported it as a bug — the dates looked plausible, just wrong by three days, and a renewal reminder three days early is not obviously broken.
It surfaced when a user pointed out that their reminder and their statement disagreed. By then several months of advancement had compounded across the affected rows, and fixing it meant recomputing anchors from the earliest known billing date rather than from the current one.
The lesson generalises past dates: never write a derived value back over the input it was derived from. The clamp is fine; storing the clamped result is what destroyed the information.
Testing it
Four cases catch nearly everything, and they are cheap:
""`ts expect(nextBilling(31, d('2026-01-31'), 'month', 1)).toEqual(d('2026-02-28')) expect(nextBilling(31, d('2026-02-28'), 'month', 1)).toEqual(d('2026-03-31')) expect(nextBilling(29, d('2026-02-28'), 'year', 1)).toEqual(d('2027-02-28')) expect(nextBilling(15, d('2026-01-15'), 'month', 18)).toEqual(d('2027-07-15')) ""`
The second is the important one. It is the assertion that fails on the naive implementation and passes on the correct one, and it is the one nobody writes because 28 February plus a month looking like 28 March feels reasonable until you say it out loud.
Die ehrliche Bau-oder-Kauf-Linie
Das Schema ist der einfache Teil. Es ist auch der Teil, der sich nach Fortschritt anfühlt — weshalb das Projekt unmittelbar danach aufhört, Spass zu machen.
Was tatsächlich Zeit kostet, in der Reihenfolge, in der es zubeisst: geplante Erinnerungen mit Idempotenz und Zeitzonen, Wiederholungs-Arithmetik über unregelmässige Intervalle, Währungsbehandlung, und ein Import, aus dem heraus überhaupt etwas entsteht.
Nichts davon ist intellektuell schwierig. Es ist schwierig im Sinne von vier Wochenenden, und das vierte ist das, an dem das Projekt stirbt.
Wenn das Ziel Lernen ist: bau es. Der Code hier ist echt und funktioniert. Wenn das Ziel ist, kein Geld mehr an vergessene Abos zu verlieren, ist der Bau ein Umweg.
Was SubTracker damit macht
Zur Einordnung, weil die Entscheidungen oben aus einem laufenden System stammen und nicht aus einer Entwurfsübung.
SubTracker ist eine Next.js- und PostgreSQL-Anwendung (Supabase), die Abos manuell verfolgt — es gibt keine Bankverbindung, jede Zeile stammt aus einem Formular oder einem CSV-Import. Diese Einschränkung prägt das Schema: kein „erkannt"-Zustand, kein Konfidenzwert, keine Händler-Zuordnungstabelle. Jede Zeile ist eine Behauptung, die jemand aufgestellt hat.
Der kostenlose Plan verfolgt unbegrenzt viele Abos inklusive CSV-Import und Live-Kalender-Feed. Erinnerungen und Preis-Alarme sind Plus für $5.90/Monat; Family kostet $9.90/Monat und fügt einen geteilten Workspace für bis zu zehn Personen hinzu — an dem Punkt hören die Besitzverhältnisse im Schema auf, theoretisch zu sein.
Frequently asked questions
What happens when a subscription bills on the 31st?+
Every date library clamps to the last day of a shorter month, which is correct. The bug is writing that clamped date back as the new anchor — the subscription then stays on the 28th permanently and diverges from the provider's actual billing date.
How do you store a recurring billing date?+
Two values: an anchor day that never changes and a derived next-billing date that may be clamped for a given month. Deriving from the anchor each time prevents the drift that comes from repeatedly adding to a clamped value.
Should billing dates be stored with a timezone?+
No. A billing date is a day, not an instant, so store it as a date type. A timestamptz shifts under conversion and shows users in other timezones a different renewal date from the one in their contract.
How do you handle a subscription anchored on 29 February?+
Pick a rule and document it — 28 February in non-leap years is the common choice. Providers differ, so consistency matters more than matching any particular one, because you cannot match them all.
