An invoice that is not real until a third party says so
Some tax regimes require that an invoice is submitted to a government system and stamped before it can be handed to the customer. Not reported afterwards in a monthly return — stamped first, in real time, with the returned identifier printed on the document. Until that happens, the piece of paper is not an invoice.
The first design for this put the call to the authority inside the database transaction that creates the sale. Create the sale, submit it, write the returned stamp onto the record, commit. It reads cleanly and it is wrong in a way that only shows up in a shop.
Why the call cannot be inside the transaction
The sale transaction holds row locks on the inventory it is about to decrement. Adding a network call to a third party means those locks are held for the duration of somebody else’s latency, over somebody else’s network, with a timeout measured in tens of seconds.
One slow response and every other till trying to sell the same product is queued behind it. A regulator’s system having a bad afternoon becomes your shop having a bad afternoon, and the mechanism is invisible from the outside — the tills just stop.
Worse, the transaction cannot help you with the thing you actually need help with. If the call times out, rolling back the local sale does not roll back the submission. You do not know whether the authority received it. That is the whole difficulty, and a database transaction has no opinion about it.
Do the local work, commit, then submit. The sale exists locally as an unstamped document, the locks are released, and the submission is a separate step with its own state.
The state machine you cannot avoid
The instinct is a boolean: stamped or not. That does not survive contact with the problem, because there are at least five states and they behave differently.
Not yet submitted. The document exists locally and nothing has been sent.
Submitted, awaiting response. A request is in flight.
Accepted. A stamp came back. This is the only state in which the document may be printed and given to a customer.
Rejected. The authority looked at it and said no, with a reason. The document is not valid and never will be in its current form.
Unknown. The request timed out, or the connection dropped after sending. This is the state people forget, and it is the only one that is dangerous, because the natural response to it — retry — can create a second stamped document for one sale.
The design that survived carries the status, the returned identifier, the returned stamp, the submission timestamp, an error code, an error message, a retry count and the raw response, on the document itself. That looks like a lot of columns until you have to answer a question about a specific invoice from six months ago, at which point every one of them is the answer to a question somebody asked.
Unknown is resolved by asking, not by retrying
The rule that falls out of the unknown state: before you retry, query.
Which means the integration needs three operations, not one. Submit. Query the status of a document you already submitted. Cancel a document that was accepted. A design with only submit has no way out of ambiguity.
Querying requires that the document carry an identifier the authority can look up, which means the identifier has to be generated locally, before submission, and must not change between attempts. If the retry path regenerates the document number — and retry paths love to regenerate identifiers to dodge duplicate errors — then the query has nothing to ask about and the second submission is a genuinely new document.
The idempotency key across the boundary is the business document number, and it must be minted before the first attempt and frozen. This is the same rule as any idempotent integration, and it has more teeth here, because the duplicate is not a duplicate row in your database. It is a second tax document that exists in a state system and has to be formally cancelled.
Rejection is an operational problem, not an error path
When the authority rejects a submission, there is a customer at the counter holding goods.
The engineering instinct is to treat rejection as a failed request, log it, and return an error. Operationally that leaves a person unable to complete a sale for a reason they cannot act on, with a queue behind them.
The system needs a decided answer to this, chosen by the business and configurable, because the right answer differs by regime and by risk appetite. Block the sale. Complete the sale and mark the document pending, to be resubmitted. Complete the sale and print a provisional document with the pending status visible on it. Each has a compliance consequence, and the one thing that is not acceptable is for the choice to be an accident of how the exception happened to be caught.
The same applies to being offline. A shop with no connectivity still sells things. The design allows a queue with an explicit depth limit and an explicit behaviour when the limit is reached, because a queue without a ceiling is a way of turning a two-hour outage into a three-day backlog that nobody noticed accumulating.
The clock
Submission windows are real and unforgiving. A document carries a timestamp, and the authority has an opinion about how far in the past or future that timestamp may be relative to its own clock.
A till’s clock is not trustworthy. Point-of-sale hardware is often a tablet whose time drifts, whose timezone was set by whoever unboxed it, and which has occasionally been set deliberately wrong by someone trying to fix an unrelated problem. A device a few minutes ahead produces documents dated in the future, and a submission window is exactly the kind of check that rejects those.
The timestamp on a statutory document is server time, taken at the moment of submission, and the device’s clock is display only. If the document must show the time of sale rather than the time of submission, then the time of sale is captured on the server too, when the sale is posted, and a device that posts late is late — which is true, and better than being wrong.
Two more things the schema had to carry
Credentials are per tenant, not per deployment, and they are secrets. One organisation registers with the authority and gets its own identifiers; another has its own. They live encrypted on the tenant record, which makes the encryption key an operational concern with its own rotation story.
Environment is per tenant too. One customer is testing against the sandbox while another is live, in the same process. That is a field on the tenant, and it needs to be impossible to submit a production document to a sandbox endpoint by inheriting a default from the wrong place. Making the endpoint a derived value of the environment field, with no separate endpoint override, is worth the loss of flexibility.
And every request and response is logged, in full, with its status code and both timestamps. When there is a dispute about whether something was submitted, the only evidence that counts is the exact payload sent and the exact payload received. A log line saying “submission failed” is not evidence of anything.
The rule
Any document whose validity is granted by an external party is a distributed transaction, and it needs the three things distributed transactions need: a stable idempotency key, a way to ask what the other side thinks, and a state that means “I do not know yet”.
The honest limit: none of this makes the shop able to sell when the authority is down. It makes the shop’s records true about what happened while it was down, which is the part you can control.