KRA's eTIMS requires invoices to be transmitted for validation. You can batch that at month end, or you can file each invoice at the moment it is issued. We file per invoice, and the reason is not purity; it is that a month-end batch turns every individual failure into one large, undifferentiated failure that surfaces on the worst possible day.
Filing continuously means the failures are small, individually attributable, and discovered while there is still time to act. It also means you inherit a different and more interesting set of problems.
One at a time, deliberately
The submission worker is deliberately single-flight. That looks like a performance mistake until you read the constraint.
The device signs submissions, and firing several at once produces signature collisions that KRA rejects. There is no throughput to win here; the ceiling is imposed upstream. Accepting it explicitly, in one line, is cheaper than discovering it as an intermittent rejection rate nobody can reproduce.
A retry budget measured in hours, not attempts
Retries run on a fixed cadence, and the budget is expressed in hours of wall clock rather than in a number of attempts. The attempt count is derived from the two, not chosen.
Exponential backoff is the reflex here and it is the wrong reflex. Backoff is for protecting a service you might be overwhelming. A national tax gateway that is down is not down because of you, and it will come back on its own schedule. What you actually want is a steady cadence that is polite enough not to matter and frequent enough to catch the recovery quickly, running for long enough to survive an outage that spans a weekend.
Framing the cap in hours rather than attempts also makes it a business decision instead of a tuning parameter. "We keep trying for two days, then a human is told" is a sentence an accountant can agree or disagree with.
Retryable and terminal are different questions
Not every failure deserves 48 hours. A malformed payload will be malformed on the last attempt exactly as it was on the first. So will an unprovisioned device; if KRA does not recognise the device you are filing from, retrying is not a recovery strategy, it is a way of turning a clear configuration error into two days of silence followed by an alert.
Classifying the response correctly is most of the work. A retryable error goes back on the queue; a non-retryable one moves the row to a terminal failed state immediately and alerts operations, because the fix is a person changing something, not time passing.
Idempotency, from three directions
A payment callback can race the periodic sweep. BullMQ can redeliver a job after a crash. An admin can hit replay. Filing the same invoice twice with a tax authority is not a duplicate-row problem, so the guard is layered:
- A UNIQUE constraint on invoiceId, so the database row is the source of truth rather than the queue.
- A deterministic job id (submit-<invoiceId>), so enqueuing twice for the same invoice is a BullMQ no-op rather than a second job.
- An accepted-state check before every attempt, so an already-filed invoice short-circuits even if a job reaches the worker anyway.
Any one of these would usually be enough. Together they mean no single component has to be correct for the invariant to hold, which is the property you want when the cost of being wrong is a conversation with a revenue authority.
The bug worth writing about
The queue depends on Redis. Redis was, for a period, deliberately disabled. The enqueue path handled that by returning early; before creating the pending database row.
Read that again, because the failure is not the one it looks like. It was not that filings were delayed. It was that every payment completed during that window left no trace anywhere that a filing was owed. There was no backlog to drain, because nothing had recorded that there was anything to drain. Silent data loss, wearing the costume of a disabled feature.
The fix is a one-line reordering with a large consequence: always persist the pending row, and skip only the queue job when Redis is off.
That turns the dormant window into a reconcilable backlog. A periodic sweep and an operator-triggered replay both look for rows that are waiting with no work in flight and put them back on the queue. Nothing is lost; it is only late, and lateness is something you can see.
The general rule we took from it: durable intent is written before ephemeral mechanism. The database row says what must happen; the queue is only how it happens sooner. If your queue and your record of obligation are the same object, an outage in the queue is an outage in your memory.
Sweeps need to not race their own retries
The sweep re-enqueues rows that are pending with no job in flight. Done naively, it fights the retry schedule; a row waiting out its five-minute delay looks exactly like a stalled row. So a row only becomes a sweep candidate after ten minutes of staleness, comfortably longer than the retry interval. Two mechanisms that both want to make progress need an explicit boundary, or they will spend their time undoing each other.
What this buys
An append-only record per invoice of what was submitted and what KRA said, discovered within minutes rather than at month end, with a bounded and explainable retry policy, and a backlog that can be reconciled rather than a gap that cannot be detected.