EVERJUST QuickBooks — Agent Skill
Operate the QuickBooks Online accounting connector of an everjust.app tenant as an
agent: connect a tenant's Odoo to its Intuit QBO company via OAuth2, pull the QBO chart
of accounts into Odoo, push a posted Odoo customer invoice into QBO, and reason about
connection health — all through the Odoo MCP / ORM (search, get/read, create,
update/write, call a model method; see [[everjust-agent-mcp]] for opening the
session against the right tenant DB). The everjust-specific addon is
everjust_quickbooks (source:
<ww.everjust.app>/addons/everjust_quickbooks/). It depends
on stock Odoo account.
NOT INSTALLED ANYWHERE YET. As of this writing
everjust_quickbooksis present in the container addons path (/mnt/extra-addons/everjust_quickbooks) but is in stateuninstalledon BOTHconnectdomainandtcstartupweek. Its hard dependencyaccountis alsouninstalledon both — so installing the connector pulls the whole Accounting app in with it. Every recipe below assumes the module has been installed on the target tenant. If a call raises "modelqbo.clientdoes not exist" or "unknown model," the module is simply not installed on that DB — that is expected, not a bug. Do not attempt to install it or installaccountas a side effect of a sync task; that is a deliberate platform-ops decision, see [[everjust-platform]].
When to use this skill
- Connect a tenant to QuickBooks — kick off / finish the OAuth2 authorize flow and confirm the realm is linked. (The actual browser hop is human-driven; see below.)
- Pull the chart of accounts — QBO
Account→ Odooaccount.account, idempotent. - Push a customer invoice — a posted Odoo
out_invoice→ a QBOInvoice(creating the QBOCustomerfirst if needed). - Diagnose a QBO connection — is it connected? does it need reconnect (revoked or near-expiry refresh token)? which Intuit environment is it on?
- Reason about tokens — understand why a sync 401'd, refreshed, or demanded a reconnect — WITHOUT ever hand-editing a token param.
Do NOT use this skill for, and stop if the task is really:
- Installing the module / the Accounting app — that is a platform-ops decision ([[everjust-platform]]), not a sync task. This skill assumes it's installed.
- Editing QBO tokens by hand to "fix" a connection — the tokens rotate under a lock
and commit immediately; a raw
set_paramwill silently break the connection (see Pitfalls). The only supported repair is re-running the OAuth flow. - General Odoo Accounting (reconciliation, journals, taxes) — that's stock
account, not this connector. This module only addsqbo_idmirrors + sync/push methods. - Bulk historical migration — the invoice push is lean v1 (one
SalesItemLineper line, no per-product item mapping, no tax-code mapping); it is not a full ledger sync.
You reach the ORM through the platform's Odoo MCP tools — see [[everjust-agent-mcp]] for
opening an env against the right tenant DB, running a shell, or calling a model method.
Every recipe below is expressed as ORM/method calls you route through that MCP.
Architecture (the model map)
One tenant DB per customer. Everything QBO is per-tenant, and — crucially — all QBO
credentials and connection state live in ir.config_parameter under the
everjust_quickbooks.* namespace (mirroring everjust_phone / everjust_ringover).
DB-per-tenant isolation therefore gives secret isolation for free: there is one shared
EVERJUST Intuit app (client id/secret), but each tenant DB holds its own realm_id
and its own tokens.
| Model | Kind | Role |
|---|---|---|
qbo.client |
AbstractModel (stateless helper) | The whole OAuth2 + REST engine. Reads/writes the everjust_quickbooks.* config params, mints/refreshes/rotates tokens, and exposes query() / post_object() against the Intuit Accounting API. This is the ONLY thing allowed to touch tokens. |
account.account |
inherits stock | + qbo_id (Char, indexed, readonly). Holds the CoA-sync methods; qbo_id makes the pull idempotent. |
account.move |
inherits stock | + qbo_id (Char, indexed, readonly). Holds the invoice-push methods; qbo_id on the move makes re-push an update, not a duplicate. |
res.partner |
inherits stock | + qbo_id (Char, indexed, readonly). Caches the QBO Customer Id so an invoice push doesn't re-create the customer. |
qbo.account.sync.wizard |
TransientModel | The "Pull Chart of Accounts" wizard; thin wrapper over account.account._qbo_sync_chart_of_accounts(). |
res.config.settings |
inherits stock | Settings surface: app creds, environment, redirect URI, and the computed qbo_is_connected / qbo_reconnect_required banners. |
The everjust_quickbooks.* config params (state lives here, NOT on a model)
Written by the OAuth callback and the token helpers; seeded empty by data/config_params.xml.
| Param | Written by | Meaning |
|---|---|---|
client_id, client_secret |
admin (Settings) | The shared EVERJUST Intuit app creds. |
environment |
admin (Settings) | sandbox (default) or production. Selects API base + discovery. |
redirect_uri |
admin (Settings) | Exact HTTPS callback registered in the Intuit app, e.g. https://<tenant>.everjust.app/quickbooks/callback. |
realm_id |
OAuth callback | The tenant's QBO company Id. Also the advisory-lock key seed. |
access_token |
qbo.client._store_tokens |
~1h bearer. Never set by hand. |
refresh_token |
qbo.client._store_tokens |
Long-lived but ROTATES on every refresh. Never set by hand. |
token_expiry |
_store_tokens |
ISO UTC access-token expiry (refreshed 120s early). |
refresh_token_expiry |
_store_tokens |
ISO UTC of the refresh token's own rolling ~100-day expiry — drives the 7-day reconnect warning. |
reconnect_required |
_perform_refresh on invalid_grant |
"1" = the refresh token was revoked; connection is dead until re-OAuth. Cleared on a healthy _store_tokens. |
The OAuth flow (human-in-the-loop; you don't drive the browser)
Two auth='user' controller routes, guarded by an HMAC-over-nonce state:
/quickbooks/connect (builds the Intuit authorize URL, 302s the admin to Intuit) and
/quickbooks/callback (verifies state, exchanges the code, persists realm_id + tokens).
From Settings the button res.config.settings.action_qbo_connect() saves creds then
redirects to /quickbooks/connect. As an agent you can't complete this — it needs a
logged-in human to authorize at Intuit. Your job is to (a) confirm creds/redirect-URI are
set, (b) hand the admin the connect URL, (c) after they return, verify with
qbo.client.is_connected().
The token lifecycle — the ONE thing to get right
qbo.client is the single writer of the token params, and it protects the rotating
refresh token aggressively. Read these before touching anything QBO:
_valid_access_token()— returns a live access token, refreshing transparently if_token_expired(). Everything (query,post_object,get_client,_headers) goes through it._lock_for_refresh()— takes a per-tenant Postgres advisory xact lock (pg_advisory_xact_lock, key =crc32("qbo_refresh:" + realm_id)) so two workers can't both spend the SAME rotating refresh token (Intuit rotation would invalidate it and one side would persist a dead token). Then re-reads past the ORM cache to see a concurrent winner's committed token._store_tokens()— writes the new access + rotated refresh token and commits immediately (self.env.cr.commit(), skipped only in test mode) so a later failure in the same request can never lose the rotated refresh token. It also clearsreconnect_requiredon success._request()— HTTP with a one-shot 401 refresh-and-retry (_force_refresh), because Intuit can invalidate an access token before its local 1h expiry.needs_reconnect()— True ifreconnect_required == "1"OR the refresh token's rolling expiry is within 7 days. This is the health check you watch.cron_refresh_token()— a scheduled ~12h best-effort refresh (ir.cron"QuickBooks: Refresh Access Token", active) so the connection never goes cold.
The takeaway: you never read, write, or "refresh" a token yourself. You call a
high-level method (query, post_object, is_connected, needs_reconnect) and let
qbo.client do the locking + rotation + commit.
Recipes
Route each through the Odoo MCP (open an env on the tenant DB — see
[[everjust-agent-mcp]]). qbo.client is an AbstractModel, so call its @api.model
methods on env["qbo.client"] directly (no record needed).
Check connection health (always do this before a sync/push)
qbo = env["qbo.client"]
qbo.is_connected() # True once realm_id + refresh_token both present (OAuth done)
qbo.needs_reconnect() # True if refresh token revoked OR within 7 days of expiry
qbo._environment() # "sandbox" (default) or "production" — CONFIRM before writing data
# The read-only state, straight from the params (never write these):
icp = env["ir.config_parameter"].sudo()
{k: icp.get_param("everjust_quickbooks.%s" % k) for k in
("realm_id", "environment", "token_expiry", "refresh_token_expiry", "reconnect_required")}
Gate every operation on this: if not is_connected() → the tenant hasn't OAuth'd; if
needs_reconnect() → stop and ask a human to reconnect (a sync will fail or is about
to). Do NOT try to "wake" the connection by editing params. And always confirm
_environment() — a sandbox connection pushing what you think is production data (or vice
versa) is a silent data-integrity trap.
Start / confirm the OAuth connect (agent preps, human authorizes)
qbo = env["qbo.client"]
# 1) Confirm the app creds + redirect URI are set (else the connect route bounces):
for k in ("client_id", "client_secret", "redirect_uri"):
assert qbo._get_param(k), "Missing everjust_quickbooks.%s — set it in Settings" % k
# 2) The admin must click "Connect QuickBooks" in Settings, or open the connect URL,
# which 302s to Intuit for authorization:
connect_url = "https://<tenant-domain>.everjust.app/quickbooks/connect"
# 3) After they return from Intuit, VERIFY it took (callback persisted realm + tokens):
qbo.is_connected() # -> True, and:
qbo._get_param("realm_id") # the linked QBO company
You cannot complete step 2 as an agent (it needs an interactive Intuit login). Never
fabricate realm_id/tokens to "simulate" a connection — an invalid token set will 401
every call and can flip reconnect_required.
Pull the chart of accounts (QBO → Odoo account.account)
# Idempotent on account.account.qbo_id, then falls back to matching by code.
created, updated = env["account.account"]._qbo_sync_chart_of_accounts()
# Or the button/server-action entry point (returns a UI notification dict):
env["account.account"].action_qbo_pull_chart_of_accounts()
# Or drive the wizard the same way a user would:
w = env["qbo.account.sync.wizard"].create({})
w.action_pull(); w.result_message
# Inspect what landed:
env["account.account"].search_read(
[("qbo_id", "!=", False)], ["code", "name", "account_type", "qbo_id"])
The sync paginates QBO (STARTPOSITION/MAXRESULTS 1000) so accounts past the first
1000 aren't dropped, maps QBO AccountType/AccountSubType → Odoo account_type,
sanitizes AcctNum to Odoo's [A-Za-z0-9.] code charset (synthesizing QBO<id> if
empty), and isolates each upsert in a savepoint so one bad record can't abort the
batch. It matches by qbo_id then code — deliberately no name fallback (a name
match would fuse onto an unrelated localization account). This is a one-way QBO→Odoo pull;
it does not push Odoo accounts back.
Push a posted customer invoice (Odoo → QBO Invoice)
inv = env["account.move"].search(
[("move_type", "=", "out_invoice"), ("state", "=", "posted")], limit=1)
inv.action_qbo_push_invoice() # per-move guarded; posts a chatter note with the QBO Id
inv.read(["name", "qbo_id"]) # qbo_id set -> re-push UPDATES (sparse) rather than dups
Guards: it refuses anything that isn't a posted out_invoice (raises UserError).
It first ensures the QBO Customer exists via
inv.partner_id.commercial_partner_id._qbo_ensure_customer() (which reuses a QBO customer
with the same DisplayName, else creates one and caches res.partner.qbo_id), then builds
a lean payload — one SalesItemLine per product line (Amount = price_subtotal), no
per-product ItemRef and no tax mapping (v1 TODOs). If move.qbo_id is already set it
sends sparse=True to update in place. Bulk-push a set the same way:
env["account.move"].browse(ids).action_qbo_push_invoice() (also wired as an invoice-list
cog action). A push that returns no Id posts no chatter and increments nothing — read
move.qbo_id to confirm it actually landed.
Run a raw QBO query / entity write (when you need the API directly)
qbo = env["qbo.client"]
# SQL-like read (paginate yourself past 1000 rows, like the CoA sync does):
qbo.query("SELECT * FROM Account STARTPOSITION 1 MAXRESULTS 1000").get("Account", [])
qbo.query("SELECT * FROM Customer WHERE DisplayName = 'Acme'").get("Customer", [])
# Entity create/update (entity name lowercase; response key is Capitalized):
qbo.post_object("customer", {"DisplayName": "Acme Co"})
qbo.post_object("invoice", {"CustomerRef": {"value": "58"}, "Line": [...]})
Both go through _request → _valid_access_token, so the token is refreshed/rotated and
the 401-retry happens for you. Use these only for things the two shipped syncs don't cover;
prefer the higher-level _qbo_sync_chart_of_accounts / action_qbo_push_invoice when they
fit, so qbo_id bookkeeping stays correct.
Pitfalls
NEVER mutate a QBO token param by hand.
access_token/refresh_token/token_expiry/refresh_token_expiry/reconnect_requiredare owned exclusively byqbo.client._store_tokens. The refresh token rotates on every refresh and is committed the instant Intuit hands over a new one, guarded by a per-tenantpg_advisory_xact_lock. A rawset_param("everjust_quickbooks.refresh_token", ...)bypasses the lock and the rotation bookkeeping — you'll spend a stale token, Intuit invalidates it, and the connection dies. The only supported repair for a broken connection is re-running the OAuth flow.Always check
needs_reconnect()— and honor it. It's True when a prior refresh gotinvalid_grant(token revoked →reconnect_required="1") OR the refresh token's rolling ~100-day expiry is within 7 days. If it's True, syncs are failing or about to; stop and get a human to reconnect rather than retrying. The Settings banner (qbo_reconnect_required) is just this method surfaced.Default environment is
sandbox._environment()falls back tosandboxfor any unrecognized value, and the seed param issandbox. Before pushing invoices or trusting pulled accounts, confirm_environment()— pushing real invoices into a sandbox company (or sandbox test data into production) is a silent, expensive mistake. Sandbox and production also hit different API bases and are different Intuit companies entirely.is_connected()≠ "healthy." It only checksrealm_id+refresh_tokenare present. The refresh token can still be revoked/expired. Pair it withneeds_reconnect(), and remember_requestdoes a one-shot 401 refresh-retry — a single 401 self-heals, a persistent failure means reconnect.This module is UNINSTALLED on every tenant, and so is
account. A missing-model error is the expected state, not a bug. Installing the connector drags the full stock Accounting app in as a dependency — that's a platform-ops call ([[everjust-platform]]), not something to trigger from a sync task.The Python libs are lazy-imported.
intuit-oauth(intuitlib) andpython-quickbooksare imported inside methods, not declared inexternal_dependencies(Odoo 19 would block install on a missing dep). If they're not baked into the image, connect/refresh/sync raise a clearUserError("connector library is not installed … add it to the application image") at call time — that's an image problem, fix it in the Dockerfile, not by editing params.CoA pull is one-way and matches only on
qbo_id/code. It never pushes Odoo accounts to QBO and deliberately has no name-based match — don't expect it to reconcile accounts that differ in bothqbo_idandcode. It synthesizes aQBO<id>code when QBO has no usableAcctNum.Invoice push is intentionally lean (v1). One
SalesItemLineper product line atprice_subtotal; no per-productItemRef, no tax-code mapping, only postedout_invoice. Don't treat it as a faithful full-fidelity export or a two-way ledger sync.DocNumberis truncated to 21 chars.Everything is per-tenant / per-
realm_id. Tokens, realm, and the advisory-lock key are all tenant-scoped. When operating multi-tenant, confirm you're on the right tenant DB before any QBO call (see [[everjust-agent-mcp]]) — there is no cross-tenant QBO state.Provider exceptions embed the raw token body — never echo them.
_perform_refreshdeliberately logs type/status/tid, not the exception string, because Intuit's error string embeds the OAuth token-endpoint response body. If you surface refresh errors, keep that discipline: don't interpolate the raw provider exception into logs or UI.