Recurring billing is where time stops being ambient and becomes a domain object. Renewals, trial expiry, instalment schedules, proration, dunning retries - all of it is logic whose output depends on what day it is. Written the obvious way, none of it is testable and all of it fails on the awkward dates.
Reach for the current time directly and you have written something you cannot test:
if subscription.renews_at <= Time.current charge(subscription) end
To test the renewal you must either manufacture records with dates relative to now - which makes the test say nothing about the actual boundary - or freeze global time, which works until two tests do it concurrently.
Worse, it hides the interesting cases. Monthly billing that starts on the 31st. A trial ending during the hour the clocks change. An instalment plan spanning a leap day. Renewal falling on a bank holiday when settlement will not run. These are not edge cases in billing, they are Tuesday, and none of them are reachable from a test suite that uses real time.
The fix is small and unglamorous: nothing in the domain reads the system clock. Time arrives as a value.
class BillingCycle
def initialize(subscription, clock:)
@subscription = subscription
@clock = clock
end
def due?
@subscription.renews_at <= @clock.now
end
end
In production the clock returns the real time. In tests it returns whatever you need. No global freezing, no ordering dependencies, and tests that can run in parallel because each one carries its own notion of when it is.
The knock-on benefit is bigger than testability. Once time is explicit, the awkward questions become answerable: which timezone is "the 1st of the month" in, the customer's or the merchant's? Is a renewal due at midnight, or at the time of day the original charge was taken? Those were always decisions - the system clock just made them implicitly, and usually wrongly.
The second thing worth making explicit is the cycle. A subscription is not a renewal date, it is a sequence of periods, and most billing bugs come from computing the next one by adding a month to the last one.
Add a month to 31 January and different libraries disagree. Do it repeatedly and a subscription that started on the 31st drifts to the 28th and stays there - the customer is now billed three days early, permanently, and nobody notices for a year.
Anchor to the original start date and derive each period from the sequence number rather than from its predecessor. Drift becomes impossible rather than unlikely.
Every billing period should be derivable from the subscription's start date and an index. If you have to look at the previous period to compute the next one, errors compound.
Billing jobs get retried. The worker times out after the charge succeeded but before the record was written. A deploy restarts the queue mid-run. Someone triggers the job manually to check something.
Every one of those double-charges a customer unless charging is idempotent, and a refund does not undo the damage - the customer saw two debits, and trust is the thing you actually lost.
Derive an idempotency key from the subscription and the period index rather than generating one at call time. Then the same billing period produces the same key on every attempt, and the provider rejects the duplicate for you. Store it. Check it before you charge, not after.
These become straightforward, and each one is a real defect we have seen ship:
None of these are reachable with real time and all of them are trivial with an injected clock. The pattern costs an afternoon to introduce and it is the difference between a billing system you can change confidently and one everyone is quietly frightened of.
We build subscription and instalment billing, including retrofitting testable time into systems that already run on the system clock. If yours has bugs that only appear at month end, that is usually why.