QuickBooks Online and the Update That Deletes What It Omits
Eloquent sends the attributes that changed. QuickBooks Online wants the object it should become. Between those two sentences is a supplier record with its address gone.
A QuickBooks Online integration ships. Three weeks later a supplier record has no billing address, no payment terms and no tax code, and the audit log says the last person to touch it only changed an email address.
Nothing is broken. The integration did exactly what it was told, and what it was told was the default behaviour of two systems that disagree about what an update means.
Eloquent sends changes, the API wants the object
A model's save() sends the attributes that are dirty. That is the right
behaviour for a database and the wrong payload for this API, which treats the
body of an update as the record it should become. Fields you omit are not left
alone - they are cleared.
So the mapping layer cannot be a thin translation of a model's changed attributes. It has to produce a complete object, which in practice means reading the current record from QuickBooks, applying the change to it, and sending the result.
The API does support a partial update, and it is worth using. It is not worth relying on alone, because it is a flag somebody can forget on the one call that matters. Reading before writing costs a request and removes a class of silent data loss, and in code that edits somebody's books that is a trade worth making without discussion.
The structural conclusion is that this integration needs its own mapper rather than a method on the model. The object sent to the accounting system is not the model, it is a document assembled from it, and treating those as the same thing is where this failure comes from.
The version makes a blind retry wrong
Every record carries a version, and an update has to send the version it was based on. If the record moved in the meantime, the update is rejected.
That is good behaviour and it breaks the default retry. A queued job that holds a finished payload and resends it after a failure is sending a version that is already stale, so it fails again, and again, until the attempts are exhausted - and the change is lost in a failed-jobs table rather than applied.
The job therefore has to store the intention rather than the payload. What it holds is "set this customer's email to this value", not a serialised request body. On each attempt it reads the current record, applies the intention, and sends the result with the version it just read.
That makes the job genuinely safe to run twice, which matters more than usual here because Laravel's queue will retry whether or not anybody designed for it.
The second half of the version question is what to do when the conflict is real - when somebody in the finance team changed the same field. Overwriting is a decision, not a default. For most fields the honest answer is that the accounting system wins and your application records that its change was refused, because a silent overwrite of a bookkeeper's correction is the kind of behaviour that ends an integration's credibility in one meeting.
The connection is a company, and it is not the user
Authorisation is granted for a specific company file, and one login can grant several. So the thing you store is a connection - the company identifier, the tokens, when they were refreshed, and whether it is currently healthy - in its own table.
Not columns on a user. Not a single row in a settings table. The company identifier is what you look up by, because every subsequent request is on behalf of one company and the application has to know which.
The token refresh has the same hazard as every other accounting API, which is two workers refreshing at the same moment and invalidating each other. That failure and its remedy are set out in the Xero piece, and the mechanics are close enough that repeating them here would waste your time. The durations differ - check the current ones in Intuit's documentation rather than hard-coding what a blog post said - and the shape of the problem does not.
One QuickBooks-specific addition is worth knowing: the refresh token expires through disuse as well as through age. An integration for a customer who stopped using your application for a few months comes back disconnected, with nothing having gone wrong. A disconnected state has to be a real state in your schema, with something in the interface that explains it, rather than an exception that surprises somebody.
Matching customers is a table, not a lookup
QuickBooks requires a customer's display name to be unique within a company. That single constraint is responsible for most of the pain in these projects.
Your database has "Acme Ltd". Theirs has "Acme Limited", and also "ACME LTD - DO NOT USE" from 2019, and also "Acme" with a trailing space. Matching on a name means creating a duplicate the first time somebody types the suffix differently, and duplicates in an accounting system are a cleanup job for a bookkeeper rather than a bug you can fix.
So the pairing lives in a mapping table - your identifier, their identifier, the company it belongs to - and it is populated by a matching exercise somebody reviews once rather than by a string comparison running forever.
That exercise is not engineering work and it is most of the elapsed time. It also surfaces the question nobody asked: which system owns a customer's name, and what happens when it changes on the other side.
Decide who numbers the invoices
The last decision is commercial and has to be made before anything is written.
If QuickBooks assigns invoice numbers, your application cannot display a number until the push has succeeded, which changes what a customer sees and what happens when the accounting system is unavailable.
If your application assigns them, somebody has to turn off automatic numbering in QuickBooks, and gaps in the sequence become your responsibility to explain.
Both are workable. What is not workable is both systems numbering, which produces two invoices for one sale and a reconciliation nobody can finish. It is a five-minute conversation in week one and a fortnight of unpicking in month four.
When not to build this
If invoices need to appear in QuickBooks, nothing needs to come back, and the volume is tens per month, a managed connector is the right answer and it works this afternoon.
A built integration earns its place when the mapping is specific to how your business works, when the volume makes manual correction expensive, or when the accounting data has to be inside your own system to be queried. It does not earn its place as a way to avoid a subscription, because the reconciliation rules are where the cost is and they are the same either way.
