Every limit in Nakodo is counted since the start of the current billing period. There is no job that resets anything at midnight on the 1st, and no counter column to zero. The period is rolled forward by whoever reads it next.
src/lib/subscription.ts is 70 lines and this is the function that does it:
export async function getSubscription(userId: string, exec: Executor = db) {
const now = new Date();
let sub = await exec.query.subscriptions.findFirst({ where: eq(subscriptions.userId, userId) });
if (!sub) {
await exec
.insert(subscriptions)
.values({ userId, plan: "free", currentPeriodStart: now, currentPeriodEnd: addMonths(now, 1) })
.onConflictDoNothing();
sub = (await exec.query.subscriptions.findFirst({ where: eq(subscriptions.userId, userId) }))!;
}
if (sub.currentPeriodEnd <= now) {
let start = sub.currentPeriodStart;
let end = sub.currentPeriodEnd;
while (end <= now) {
start = end;
end = addMonths(start, 1);
}
[sub] = await exec
.update(subscriptions)
.set({ currentPeriodStart: start, currentPeriodEnd: end })
.where(eq(subscriptions.userId, userId))
.returning();
}
// A lapsed or unpaid plan falls back to Free limits.
const plan = grantsPlan(sub.status) ? PLANS[sub.plan] : PLANS.free;
return { ...sub, effective: plan };
}
It is called at 51 sites through four entry points. Three properties of it are worth pulling apart.
A read that writes, twice over
The first write is the row itself. There is no "create a subscriptions row" step at sign-up; the first code path that needs a plan creates a Free row, with onConflictDoNothing because two requests can arrive together, then reads again. The ! on the second read is the honest kind of non-null assertion: the row exists because we either found it or just inserted it or lost a race to someone who did.
The second write is the roll-forward. A dormant Free account read after four months runs the loop four times and writes once. Two parallel reads both roll, and the race is harmless because the loop is deterministic: both start from the same stored dates, so both compute the same new pair and the second UPDATE sets what the first one set. That is the only reason this can be an unconditional UPDATE rather than a compare-and-set, which the rest of this codebase reaches for by habit.
What makes a read-time roll attractive is that no clock has to be right. There is no 1st-of-the-month cron to miss, no backfill if a deploy was down, and a user who has not signed in for a year costs one UPDATE when they come back. The cost is that nothing in the database is true until it is read, and a dashboard querying subscriptions directly would see expired periods for every inactive account.
setUTCMonth overflows, so the anniversary moves
export function addMonths(d: Date, n: number): Date {
const r = new Date(d);
r.setUTCMonth(r.getUTCMonth() + n);
return r;
}
setUTCMonth keeps the day of the month and lets it overflow. February has no 31st, so:
$ node -e 'const a=(d,n)=>{const r=new Date(d);r.setUTCMonth(r.getUTCMonth()+n);return r};
for (const s of ["2026-01-31","2026-03-31","2026-05-31","2026-02-28"])
console.log(s, "->", a(new Date(s+"T10:00:00Z"),1).toISOString().slice(0,10))'
2026-01-31 -> 2026-03-03
2026-03-31 -> 2026-05-01
2026-05-31 -> 2026-07-01
2026-02-28 -> 2026-03-28
A free account opened on 31 January gets a first period that ends on 3 March: 31 days rather than 28, so the first month's allowance covers three extra days. The interesting part is what the loop then does with it:
rolled forward from 31 Jan: 2026-03-03 2026-04-03 2026-05-03 2026-06-03 2026-07-03 2026-08-03
The drift happens once and then sticks, because the 3rd exists in every month. The account's renewal day has quietly migrated from the 31st to the 3rd and will stay there for as long as the account lives. Nothing is lost and nobody is overcharged, because this arithmetic only governs Free and hand-granted plans: a paid plan's dates come from Stripe's own current_period_start and current_period_end through the webhook, and Stripe clamps a month-end anchor to the last day of a short month instead of overflowing it. So the two systems disagree about what "a month later" means, and the one that bills people is the one that is right.
Three day-of-month values can trigger it, and the honest version of the finding is that it costs a Free user a slightly longer first window and the product nothing. The fix is four lines of clamping, and it is on the list precisely because "nobody noticed for a year" is how a date bug gets copied into a path where it does matter.
When a subscription ends, the period starts over
// A fresh monthly period from the end of a paid one. A subscription that was
// never paid for keeps the Free period it started from.
...(grantsPlan(before.status) && { currentPeriodStart: now, currentPeriodEnd: addMonths(now, 1) }),
That is the Stripe sync dropping someone back to Free. A paid plan that ends gives you a fresh monthly window from that moment, because you paid for the month you were in. A checkout that was never paid for does not: it keeps the Free period it started from, so an abandoned attempt at paying cannot be used to restart the Free allowance. One conditional spread, two product decisions.
The same file decides what a lapsed plan is allowed to do at all:
// Subscription statuses that keep the paid plan. past_due is Stripe's retry
// window after a failed payment: the plan stays until Stripe gives up and
// cancels the subscription.
export const grantsPlan = (status: SubscriptionStatus) =>
status === "active" || status === "trialing" || status === "past_due";
Of Stripe's eight statuses, three keep your plan. The effective field returned by getSubscription is the plan the app enforces, and plan is the plan the row says you are on. Every limit check reads effective, and the billing screen shows both, which is how a card that stopped working produces Free limits and a page that still says what you are subscribed to.
Two cached variants and a comment that is a rule
// The same, read once per page render however many components ask
// (React.cache). For rendering only: an action that changes the plan and then
// reads it must use the uncached ones above.
export const subscriptionForPage = cache((userId: string) => getSubscription(userId));
A page can ask for the plan in the header, in a limit note, in an upgrade offer and in a results table, and cache() makes that one query. A Server Action that changes the plan and then renders a result must not, because cache() would hand it the plan from before its own write. That is a rule a comment cannot enforce, and the two names are the only thing separating them.
There is one more quirk in the same file:
// Limits for a campaign's owner. Campaigns without an owner (created from the
// command line) get the top plan.
export async function planForUser(userId: string | null | undefined): Promise<Plan> {
return userId ? (await getSubscription(userId)).effective : PLANS.business;
}
An ownerless campaign is one created from a script before anybody has claimed it, and it gets the largest plan rather than the smallest. That is right for the thing it is for and it is the sort of default that would be very wrong if ownership could ever be lost rather than only gained.
What I would change
- Clamp
addMonthsto the last day of the target month, and add the three test cases above. Stripe's behaviour is the specification, and today two different definitions of "a month" live in one database. - The roll-forward write makes a read non-idempotent. It is worth a comment at the call sites that run inside a transaction, because a read that writes inside someone else's transaction is a surprise waiting to be found by a deadlock.
-
effectiveversusplanis the right design and a bad pair of names.
The plans those limits come from, with what each one includes, are on pricing; what a period is spent on is described on how it works; and the retention rules that use the same kind of date arithmetic are published in the privacy policy.
Top comments (0)