Off by One Month: The Date Bug That Looked Simple
The Starting Point
I've been building a bill viewer for a platform that handles periodic billing data. The idea is simple: you pick a billing period like 2026-01, and the viewer shows you the correction passes that make up that bill — an initial pass, then progressively later revisions that catch adjustments over time.
Each run type gets published at a fixed offset after the original period. The first pass comes out about a month later, the second a few months after that, then progressively later corrections. The bill viewer needs to reverse-engineer which source periods correspond to a given billing month.
I'd built the whole thing — line-item charge breakdowns, variance calculations between passes, a detail modal, the lot. It all looked great. Then I actually checked the numbers.
What Actually Happened
When I selected 2026-01 as the billing period, I expected to see:
- Pass 1 for
2025-12 - Pass 2 for
2025-10 - Pass 3 for
2024-08
Instead I was getting Pass 1 for 2026-01 (the selected period itself), Pass 2 for 2025-11, and Pass 3 for 2025-05. Everything was wrong.
My first thought was "classic off-by-one." The billing period represents when the bill arrives, not the source period itself. If Pass 1 is published one month after the source period, then the Pass 1 data appearing in the January 2026 bill is for December 2025 — not January. I was using the selected period directly as the base, when I should have been subtracting a month first.
Easy fix, right? Just add ->subMonth() to the Carbon base period. That sorted Pass 1 and Pass 2 immediately. But Pass 3 was still wrong.
This is where it got interesting. With the base corrected to 2025-12, Pass 3 at 8 months back gave me 2025-04. But I needed 2024-08 — which is 16 months back from 2025-12. The offset itself was wrong.
I'd originally set up the bill cycle config like this:
$billCycle = [
['pass' => 'initial', 'months_back' => 0],
['pass' => 'second', 'months_back' => 2],
['pass' => 'third', 'months_back' => 8],
['pass' => 'final', 'months_back' => 16],
];
The third and final pass offsets were just wrong. Not off-by-one — off by a factor of two. I think when I originally wrote these, I was half-thinking about months between passes rather than months from the base period. Whatever the reason, it was two separate bugs conspiring to make the output look "approximately right but not quite" — the most dangerous kind of wrong.
The error logs also showed some Maximum execution time exceeded errors from earlier in the month. The bill viewer was hitting the database with multiple existence checks and aggregation queries per pass, per billing line. With the wrong offsets, some of those queries were probably scanning periods with huge datasets. Not directly related to the date bug, but a reminder that bad date logic doesn't just show wrong data — it can also tank performance by querying the wrong partitions.
What Worked
The fix was two changes to the controller:
// Before: base period was the selected period itself
$basePeriod = Carbon::createFromFormat('Y-m', $period);
// After: subtract one month — the bill contains data PUBLISHED in this period
$basePeriod = Carbon::createFromFormat('Y-m', $period)->subMonth();
And correcting the offsets:
$billCycle = [
['pass' => 'initial', 'months_back' => 0], // same month as base
['pass' => 'second', 'months_back' => 2], // 2 months earlier
['pass' => 'third', 'months_back' => 16], // was 8, should be 16
['pass' => 'final', 'months_back' => 28], // was 16, should be 28
];
Now selecting 2026-01 correctly resolves to Pass 1 of 2025-12, Pass 2 of 2025-10, Pass 3 of 2024-08.
While I was in there, I also added a feature I'd been wanting: a download button on each pass's summary card that exports a CSV of all records with differences. The data was already loaded client-side (the viewer fetches everything and renders it in JS), so I just needed:
- Store the API response in a
lastBillDatavariable - Filter records where the variance for that run is non-zero
- Generate a CSV blob and trigger a download
function exportRunDifferences(labelIndex) {
if (!lastBillData) return;
const label = lastBillData.labels[labelIndex];
const rows = [];
lastBillData.items.forEach(item => {
if (item.variances && item.variances[label] &&
parseFloat(item.variances[label]) !== 0) {
rows.push({
reference: item.reference,
charge: item.lines[label] !== undefined ? item.lines[label] : '',
variance: item.variances[label],
});
}
});
// ... generate CSV and trigger download
}
The download icon only shows on cards that actually have variances — no point offering a download of nothing.
Both changes needed applying in two places (the app has a main controller and a modular copy), which is its own kind of fun.
What I Learned
- Two bugs can hide behind one symptom. "The dates are off by a month" turned out to be an off-by-one in the base period and wrong offsets in the cycle config. Fixing one without the other would've left the later passes still broken.
- "Approximately right" is the most dangerous wrong. The original offsets produced dates that were in the right ballpark — close enough that I didn't catch it until I sat down with a calendar and counted months manually.
- Domain logic in date offsets is fragile. A lookup table of month offsets is clean and readable, but there's no guardrail if the numbers are wrong. I'm tempted to add a comment block with the actual reconciliation timetable so the next person (me in 6 months) can verify the offsets.
- Client-side CSV generation is underrated. When the data is already in the browser, you don't need a server endpoint for simple exports. A Blob, an object URL, and a temporary anchor element — done.
- Duplicate code in module systems means duplicate bugs. Had to apply the same fix in two controllers and two Blade templates. This codebase has a modular architecture where some features are mirrored. Every fix is a reminder to check both locations.
Tech Notes
- Stack: Laravel 12, PHP 8.2, Carbon for date manipulation, vanilla JS frontend with Tailwind CSS
- Key pattern: Bill cycle calculation uses a base period (selected month minus one) with fixed month offsets per reconciliation pass
- Client-side export: Uses
Blob+URL.createObjectURLfor CSV downloads — no server round-trip needed - Files changed:
app/Http/Controllers/Admin/BillingController.php— date logic fixmodules/.../BillingController.php— same fix in module copyresources/views/admin/billing/bill.blade.php— export function + download buttonsmodules/.../bill.blade.php— same UI changes in module copy
Zero Focus Dev
Breaking things, learning fast, sharing it all.