DEV Community

Davit Mkrtchyan
Davit Mkrtchyan

Posted on

Your Export Is Lying to You: 4 Bugs We Shipped in a Jira Forge App

We build a resource management app for Jira on Atlassian Forge — capacity planning, timesheets, and invoice export. That last one is the scary part. When your app produces an invoice, a bug isn't a cosmetic glitch. It's a customer billing the wrong number.

Over the past few months we ran a hard QA pass on our own product, focused almost entirely on the XLSX export path. Here are four bugs we found, in the order that we understood them. None of them threw an exception. Every single one produced a file that looked completely fine.


1. Every date happened at the same time of day

Date-only fields in the exported workbook — dates with no time semantics whatsoever — came out carrying an identical time component on every single row. Same timestamp, every row, both sheets.

The dates rendered correctly. Excel showed the right day. But underneath, every value was a fractional serial, and that meant:

  • Excel's date filters behaved unpredictably.
  • =DATE() comparisons quietly failed.
  • Any downstream system parsing the file inherited a phantom time.

The cause is the oldest trap in the JavaScript book. A date-only value has no timezone. The moment you route it through a type that doesDate, an ISO datetime string, anything with an instant in it — you've silently attached one:

new Date("2026-07-15")
// parsed as midnight UTC — which is not midnight anywhere else
Enter fullscreen mode Exit fullscreen mode

Render that in a UTC+3 tenant and you get 03:00. Render it in UTC-5 and you're on the previous day. Now your report is off by a day for half your customers, and it only reproduces if you happen to test from the right hemisphere.

The fix wasn't a timezone offset. Chasing offsets is how you end up with +3 hardcoded somewhere and a new bug the moment someone in Denver installs your app. The fix was to stop letting date-only values carry a time at all — we now write integer Excel serial numbers. No fractional part, nothing to drift.

The rule we settled on: a date-only field must never be represented by a timezone-aware type. If it is, you don't have a date. You have an instant that's cosplaying as one.


2. The numbers that were strings

Same export. Columns for total hours, overtime, and total cost — full of clean, well-formatted figures.

A user selects the column in Excel. Status bar: Sum: 0.

They were text.

In XLSX, cells are typed. A number lives as a numeric value. A string — even a string that reads exactly 1250.00 — is a different cell type entirely, and Excel treats it accordingly: SUM() skips it, pivots ignore it, charts drop it to zero. It displays perfectly. It just doesn't participate in arithmetic.

This is embarrassingly easy to do by accident, because most export libraries write whatever you hand them. If you formatted the value for display anywhere upstream — a toFixed(), a template literal, a currency helper — the type is already gone by the time it reaches the writer.

The fix was to explicitly cast those columns to numbers on write. The lesson was to stop trusting the rendered spreadsheet and start reading the file:

import openpyxl

wb = openpyxl.load_workbook("invoice.xlsx", data_only=False)
ws = wb.active
for row in ws.iter_rows(min_row=2, max_row=5):
    for cell in row:
        print(cell.coordinate, repr(cell.value), cell.data_type)
        # data_type: 'n' = number, 's' = string, 'd' = date
Enter fullscreen mode Exit fullscreen mode

For date serials specifically, go one level lower. An .xlsx is just a ZIP of XML — unzip it and read xl/worksheets/sheet1.xml directly. Library abstractions will helpfully convert serials back into datetime objects for you, which hides exactly the fractional-day problem from bug #1. The raw XML doesn't do you any favours, which is precisely why you want it.


3. The zero that ate the invoice — and the warning that then lied about it

This one has two acts, and the second act is the one worth reading.

Act one. Our billing multiplies hours by a resource's effective rate. If no rate is configured, the rate resolves to 0. Multiply, and the cost is 0. Export it, and you get a perfectly formatted invoice where one person's month of work is worth nothing at all — no error, no asterisk, no hint.

This is the bug class I've come to fear most: not a crash — a plausible number. A stack trace is a gift. A silently wrong invoice is a furious customer six weeks later.

So we fixed it: the export still proceeds, but it now raises a clear warning naming every resource with a missing rate.

Act two. The warning started firing when it shouldn't.

The dialog would announce "Effective rates are not configured for: [name]. The export used 0 for their rates and costs" — while the file it had just generated listed that resource's rates, applied them, and billed a non-zero total. The warning was contradicting the document sitting in the user's downloads folder.

We nearly dismissed it. We'd first seen it while testing with a deliberately absurd rate (1e16 — a separate bug about missing upper-bound validation), so the obvious hypothesis was that our own nonsense test data was the cause. We re-ran it with a completely ordinary rate. The warning still appeared. Not an artifact. A real defect.

The root cause: two independent code paths were each resolving "does this resource have an effective rate?" on their own — one driving the warning, one driving the cost computation. They disagreed. The cost path found the rate and billed it. The detection path didn't, and shouted about it.

The fix was to collapse them into a single effective-rate resolution that both paths consume.

The general shape of this bug is worth internalising, because it has nothing to do with rates or invoices: if two parts of your system independently answer the same question, they will eventually give different answers. And when one of those parts is a warning about the other, you get the worst possible outcome — a system that is confidently wrong about itself. A user who trusts that banner would have believed their invoice was billed at zero while it quietly billed at full rate.


4. Forge-specific: the file that wouldn't download, and the file Excel called corrupt

Two problems that only exist because we're inside a Forge iframe.

The download didn't reliably happen. Standard file-writing approaches don't behave predictably inside the Forge sandbox. We ended up generating the workbook as a Blob and triggering a programmatic anchor click — which is the mundane, boring solution, and also the one that actually works consistently in that environment. If you're building a Forge app that emits files, budget time for this; it's not where you expect to lose a day, and you will.

And Excel for Mac called our valid file corrupt. Users on macOS got the "we found a problem with some content" recovery prompt. Excel on Windows opened the exact same file without complaint. The culprit was structural XML in the workbook — invalid merge ranges — that Windows Excel silently tolerated and Mac Excel refused to.

That's a good reminder that "it opens fine" is a claim about one reader. XLSX is a spec, and the spec is enforced with wildly varying enthusiasm depending on who's doing the reading.


What actually generalises

Four bugs. Only one of them — the merge ranges — was a coding mistake in the ordinary sense. The rest were modelling mistakes:

  • A date-only value was represented by a timezone-aware type.
  • A number was represented by a string.
  • "Unset" and "zero" collapsed into the same value.
  • One question was answered independently in two places.

Every one of them produced output that looked correct. Nothing threw. The tests that existed passed. And every one was found by a human opening the artifact and reading the actual bytes — not by a unit test, and not by clicking around the UI.

If you take one thing from this:

For any file your app produces, write a test that reads the output, not the code that generated it. Assert on cell types, not just cell values. Assert that date serials have a zero fractional part. Assert that the total in the summary reconciles with the sum of the details.

It is a deeply unglamorous test to write. It is also structurally capable of catching an entire class of bug that your unit suite, by construction, cannot see.


We're a small team building Resource Management for Jira — capacity planning, timesheets, and billing analytics, running on Forge. If you're fighting any of the above in your own Forge app, I'd genuinely like to compare notes in the comments.

Top comments (0)