Odoo Integration #
Classification: Internal-Only
Odoo is an open-source ERP whose accounting app tofu connects to over JSON-RPC with a per-instance API key. There is no OAuth flow, no consent screen and no scope list.
The connector reads reference data and documents, and publishes AP bills, AR invoices, AP and AR credit notes, direct expenses and bank statements. Everything below is what the code does; where Odoo’s own documentation or the research brief disagrees, this page follows what the live 20.0 instance was observed to do.
Connector module: libs/rust/bonsai-integration/src/accounting/odoo/.
**Publish has landed (ENG-9225).** AP bill, AR invoice, AP/AR credit note, direct expense and bank statement all write to Odoo, the tofu validator runs Odoo's rules live in the review form, and the journal override is in settings. See [Implementation status](#implementation-status) for what each piece is, and [Known gaps](#known-gaps) for what is still open.
Support matrix #
| Type | Create | Republish | Delete / void | Paid documents | Attachments |
|---|---|---|---|---|---|
| AP bill | account.move create with move_type: "in_invoice", then action_post |
update in place — button_draft → write → action_post |
button_draft → unlink (hard delete) |
refused before any call | ir.attachment on account.move |
| AR invoice | account.move create with move_type: "out_invoice", then action_post |
update in place — same sequence | button_cancel → Voided (the number stays consumed) |
refused before any call | ir.attachment on account.move |
| AP credit note | account.move create with move_type: "in_refund", then action_post |
update in place — same sequence | button_draft → unlink (hard delete) |
refused before any call | ir.attachment on account.move |
| AR credit note | account.move create with move_type: "out_refund", then action_post |
update in place — same sequence | button_cancel → Voided (the number stays consumed) |
refused before any call | ir.attachment on account.move |
| Direct expense | account.payment create, then action_post |
write in place — a posted payment needs no reset |
unlink — works on a posted payment |
refused before any call | ir.attachment on account.payment |
| Bank statement | one atomic account.bank.statement create with nested line_ids |
none — there is no republish path for bank statements in bonsapi | n/a | n/a | n/a |
Odoo is deliberately out of EntityIntegration::should_delete_before_publish(). A posted move
has to be reset to draft before it can be deleted anyway, so delete-then-create has the same
preconditions as update-in-place and strictly worse consequences: a new id, a new document number,
every ir.attachment orphaned and re-uploaded, and a gap in the journal sequence. button_draft
keeps the name — a posted BILL/2026/10/0001 reset to draft reads back with that same name — so
update-in-place renumbers nothing.
Odoo is also out of can_delete_after_paid_status(). That predicate has no production caller;
the protection for a paid document is the connector’s own refusal, below.
How tofu connects #
One endpoint, (model, method) pairs #
“Endpoint” for Odoo means a (model, method) pair. Every call — read and write — is the same HTTP POST:
POST {base_url}/jsonrpc
Content-Type: application/json
{"jsonrpc": "2.0", "method": "call", "id": 1,
"params": {"service": "object", "method": "execute_kw",
"args": [<database>, <uid>, <api_key>, "<model>", "<method>", [<args>], {<kwargs>}]}}
OdooIntegration::execute_kw owns that envelope. Publish adds no new transport, only new
(model, method) calls.
base_url() strips any path from the stored Server URL, so a customer who pastes
https://acme.odoo.com/odoo is stored and called as https://acme.odoo.com. That matters twice:
Odoo’s /web routes are CSRF-protected and would reject an RPC posted to them, and the webapp’s
deep links are built from the stored URL plus a web path, never from the RPC path.
Credentials #
| Piece | Where it lives |
|---|---|
| Server URL, database, login (the user’s email) | EntityIntegrationMisc::Odoo(OdooMisc) — url(), database(), login() |
| API key | private metadata access token — OdooIntegration::extract_api_key |
| uid | common.authenticate(database, login, api_key, {}), cached 8 hours under odoo_uid |
| URL, database, base currency, decimal places, journals, journal options, lock dates | OdooPublicMetadata |
The uid is not always 1 — it is the id of the user who owns the API key, and execute_kw
requires it on every call. authenticate_uid caches it and evicts it whenever an access error
comes back, so the next call re-authenticates instead of looping with a stale uid.
A customer generates the API key in Odoo under Preferences → Account Security → New API Key. It is bound to that user, which is what makes access rights the thing to check when a write fails.
No OAuth, no scopes, no reconnect #
Publish required no new scopes and no customer reconnect. Nothing about the stored credential changed when publish shipped, so unlike every prior publish provider there is no reconnect migration to write and no customer to chase. Existing Odoo connections keep working unchanged.
Re-entering the credentials for an existing (deactivated or unauthorized) Odoo integration is
this provider’s reconnect: create_odoo updates the row in place rather than creating a second
one, and rejects a reconnect that points at a different provider. The cached uid is evicted before
the credential check so the new key is really exercised.
Versions are met by probing fields, not by branching #
The connector carried a cached server_major_version() for a while. Nothing ever called it: every
version difference publish actually meets is handled by asking the instance instead, which is
right for a connector that meets Odoo Online and self-hosted instances at different versions.
account.tax.price_includebecameprice_include_override— the tax fetch asks for the newer field set and retries without it when Odoo rejects the column.account.payment.is_reconciledis read for the settled check, rather than matchingstate == "reconciled", which is Odoo 20’s spelling.ir.attachmentis written withraw, which both versions accept.
So there is no version gate, and connecting an older instance is not refused. If a future
difference cannot be probed, read common.version at the point that needs it.
The connector targets Odoo 20.0. The Phase-1 read-only work targeted Odoo Online 19; that is stale. Every behaviour on this page was probed against a 20.0 Enterprise instance.
Implementation status #
| Piece | State |
|---|---|
.with_publish() on the capabilities arm |
landed |
should_disable_auto_publish_on_first_connect() includes Odoo |
landed, and create_odoo calls the reset |
classify_rpc_error — data.name × operation → IntegrationError |
landed |
publish_error_key constants |
landed, generated into tofu-rust-i18n from config/i18n/en/error.json |
account.journal fetch, journal resolution, settings dropdowns, public metadata |
landed |
res.company lock dates (including the user_* twins) in public metadata |
landed |
ODOO_MAX_ATTACHMENT_SIZE_BYTES + the max_attachment_bytes arm |
landed |
| AP bill / AR invoice write paths and attachments | landed — model/invoice_write.rs |
| AP / AR credit note write path and attachments | landed — model/credit_note_write.rs, .with_credit_note_publish() on both directions |
| Direct expense write path and attachments | landed — model/expense_write.rs, an account.payment |
Bank statement write path, and can_publish_bank_statement |
landed — model/bank_statement_write.rs |
| tofu validator rules for Odoo | landed — tofu-validator-core/src/odoo/, run live in the review form through WASM |
| Journal override in settings, deep links, removing the Early badge | landed |
Access rights replace scopes #
Odoo has no scopes. Write permission is whether the API key’s user holds the right Odoo
access rights (groups): Accounting / Billing (account.group_account_invoice) at minimum, or
Accounting / Accountant for the lock-date-adjacent work. A key whose user lacks them fails on the
first write with:
{"error": {"data": {"name": "odoo.exceptions.AccessError", "message": "..."}}}
which classify_rpc_error maps to BadRequest(errorMessage.odoo.ACCESS_DENIED) — terminal,
shown to the user, naming the Odoo group to add. It is deliberately not Disconnected: the
credential is valid and reads keep working, so marking the integration Unauthorized would break
the read side over a permissions problem on the write side.
This cannot be checked in advance. Both pre-flights are gone in 20.0:
account.move.check_access_rights→ HTTP 200,builtins.AttributeError, “The method ‘account.move.check_access_rights’ does not exist”.account.move.check_access→ HTTP 200,odoo.exceptions.AccessError, “Private methods (such as ‘account.move.check_access’) cannot be called remotely.”
So validate_invoice_publish must not try. A permissions failure surfaces on the real write, and
that is the only place it can.
Auto-publish starts off on first connect #
EntityIntegration::should_disable_auto_publish_on_first_connect() returns true for Odoo, so a
customer who connects it should get auto-publish off for AP bills, AR invoices and direct expenses
until they opt in. Nothing is written to a customer’s books before they ask for it.
create_odoo calls reset_auto_publish_if_required after validate_credentials and inside the
same transaction, which is the shared helper create_bukku uses too. Adding a provider to the
predicate without that call does nothing and the predicate’s own unit test still passes, so a
source-reading test in create_api_key_integration.rs holds the two together.
The reset must not go in activate_odoo: activation also runs on reconnect, so a reset there
would silently switch off a customer’s auto-publish every time their credentials are renewed.
Journals #
Publish always names a journal. Left unset, Odoo picks one from the move_type — and a journal
carrying its own currency_id silently rewrites the document’s currency to match, with no
conversion and no error. The books then hold an amount tofu never sent, and nothing in the
read-back shows it. That single behaviour is why account.journal is fetched at all; the
read-only connector referenced the model nowhere.
fetch_all_journals reads id, name, code, type, currency_id, default_account_id for journals of
type purchase, sale, bank and cash. resolve_journal_selection then picks one purchase and
one sale journal: lowest id wins, but a journal that forces its own currency is passed over in
favour of one that does not, because posting to it would rewrite the currency rather than fail.
The result is stored in OdooPublicMetadata::journals() at activation.
Only purchase and sale are a stored setting. A direct expense and a bank statement name an account on the document, and the journal follows from that account, so a global bank/cash choice would misrepresent where the decision is made.
Activation merges rather than overwrites: OdooJournalSelection::merged_with keeps any id already
chosen and fills only the gaps, so a re-activation never discards a journal the customer picked.
The settings dropdowns #
Nothing exposes Odoo’s journal list to the browser, so the same fetch that resolves the selection
also stores the list: journal_options(&journals) maps the purchase and sale rows onto
OdooJournalOption { id, name, journal_type, code, currency } in
OdooPublicMetadata::journal_options(), surfaced as OdooIntegrationSetting.journal_options. Bank
and cash are left out, for the reason above.
Unlike the selection, the list is replaced on every successful read — it is Odoo’s reference
data, not a customer’s choice — but a failed read keeps what was stored rather than clearing it.
OdooPublicMetadata::enrichment_required() is true while the list is empty, which sends a record
stored before this shipped through EnrichIntegrationMetadataService the next time the settings
page reads the integration. That is also what makes a journal added in Odoo appear without a
reconnect. Until a record has been enriched the webapp renders no dropdowns at all, and the
resolved default keeps publishing.
The paid-from account is a different id space #
account.payment.journal_id wants an account.journal id. tofu’s direct_expense_bank
compatibility flag is set on account.account ids whose category is Bank or CreditCard. These
are different id spaces, and not every flagged account backs a journal: on the sandbox, 5 accounts
carried direct_expense_bank = true and only 2 of them had a journal (Bank → “Deposits”,
Cash → “Cash”). The other three were offered to the user in review with no journal behind them.
The fix is at the source: narrow direct_expense_bank and bank_statement_bank in
fetch_all_accounts to accounts that actually back a bank or cash journal, by intersecting on
default_account_id. That is the difference between an unavoidable publish failure and a review
form that only offers valid choices. An account with no journal is otherwise rejected at publish
with errorMessage.odoo.PAID_FROM_NO_JOURNAL.
The lock date moves the document instead of refusing it #
This is the quirk to read before anything else.
Posting into a locked period does not fail. Odoo silently moves the accounting date to the
first day after the lock and posts the document there. invoice_date is never touched, so tofu’s
own read-back looks completely correct and the divergence is invisible unless the connector also
reads date.
Probed with fiscalyear_lock_date = 2026-10-31, one AP bill per row:
| Sent | date after create |
date after action_post |
Posted as |
|---|---|---|---|
invoice_date 2026-10-01, date omitted |
2026-11-01 (shifted at create) |
2026-11-01 |
BILL/2026/11/0001 |
invoice_date 2026-10-01, date 2026-10-01 explicit |
2026-10-01 (kept) |
2026-11-01 (shifted at post) |
BILL/2026/11/0002 |
invoice_date 2026-10-31, date 2026-10-31 — on the lock date |
2026-10-31 |
2026-11-01 |
BILL/2026/11/0003 |
invoice_date 2026-11-01, date 2026-11-01 — day after |
2026-11-01 |
2026-11-01 |
BILL/2026/11/0004 |
Three consequences:
- The lock is inclusive. A document dated exactly on the lock date is shifted.
- Setting
dateexplicitly does not protect the period. The shift fires atcreatewhendateis omitted, and again ataction_posteven when it was set. - No
UserErroris raised at any point. There is nothing for the connector to catch, which is why this is a tofu validator rule (errorMessage.invoiceValidation.DATE_IN_LOCKED_PERIOD_ODOO) and not an error mapping. It lives intofu-validator-core/src/odoo/invoice.rsand runs live in the review form.
0001-01-01 on a user_* twin means “no lock binds this user”, and that is not the same as “no
lock is set”. The first research pass concluded the opposite, because the sandbox had no
account.lock_exception rows at the time: with a lock set and no exception the twin simply mirrors
the company column, which looks like the sentinel only ever meaning an unlocked company.
Probed again on 2026-10-28 with an exception in place. fiscalyear_lock_date was 2001-12-31,
account.lock_exception carried one row for the API user, and user_fiscalyear_lock_date read
0001-01-01 — and a bill dated 2001-06-30 posted with its accounting date untouched. So the
twin wins outright wherever it is readable, sentinel included, which is what lock_date() in
model/journal.rs does. Falling back to the company column on a sentinel twin blocks documents
Odoo accepts.
Note the exception is granted per user through an account.lock_exception record (Odoo 18+), not
through a group, and the model is create-only over the API — it cannot be edited or deleted
remotely.
Read the user_* twins. They are the effective values Odoo compares a date against, and they
win over the raw field wherever both are set — which is what OdooCompanyLockDates → OdooLockDates
does. OdooLockDates::effective_for_purchase() and effective_for_sale() take the latest of
hard, fiscalyear and the per-type lock, as YYYY-MM-DD strings that sort correctly.
Editing an existing posted entry in a locked period is a hard refusal, not a silent shift:
button_draft and unlink on a posted move dated in the lock both fail with
odoo.exceptions.UserError / “You cannot add/modify entries prior to and inclusive of: Global Lock
Date (10/31/2026).” That is the republish and unpublish path, mapped to InvoiceLocked for write
and unlink, and to BadRequest(PERIOD_LOCKED) otherwise.
Lock dates do not bind account.payment in Odoo 20. With the same lock set, a payment dated
inside the locked period posted with date unchanged and state: paid — because the payment
creates no journal entry at all. The direct-expense validator therefore needs no lock-date rule.
hard_lock_date is unprobed on purpose: Odoo refuses to clear or lower it, so the probe is
irreversible on a shared instance. Expect a hard refusal where a soft lock shifts silently; if so,
the validator rule has to distinguish the two lock kinds.
Paid documents are refused by tofu, not by Odoo #
Neither Odoo nor tofu’s shared pipeline protects a paid document. The connector must.
Odoo 20 has no hard block: an AR invoice at payment_state: in_payment accepted button_draft
and then unlink, silently leaving the account.payment orphaned (it survived with
move_id: false). Odoo does not block sequence gaps on these journals either — a draft whose
number was not the last in the chain was deleted while a later one existed, because
restrict_mode_hash_table is off on Sales, Purchases, Bank and Cash by default.
accounting-sync skips updating a document whose stored status is_locked() — Paid,
PartiallyPaid, Voided or Deleted. That covers the common case but not a document paid in Odoo since
tofu last read it, and it does not apply to delete_invoice at all.
So all three queued types refuse a paid or partially-paid document before any call, with
errorMessage.odoo.PAID_DOCUMENT_LOCKED:
| Stored status | Republish | Delete |
|---|---|---|
Paid / PartiallyPaid |
BadRequest(PAID_DOCUMENT_LOCKED), no call |
BadRequest(PAID_DOCUMENT_LOCKED), no call |
Voided / Deleted |
return the status, no call | return the status, no call |
Draft |
write the header and lines, stay draft |
unlink → Deleted |
Authorized (Odoo posted) |
button_draft → write → action_post |
AP: button_draft → unlink; AR: button_cancel → Voided |
Do not call button_draft blindly — on an already-draft move it fails with “Only posted/cancelled
journal entries can be reset to draft.” Branch on the stored status.
What a posted move accepts and refuses, for reference:
| Call on a posted move | Result |
|---|---|
write({"ref": …}) |
succeeds — ref stays editable while posted |
write({"invoice_date": …}) |
UserError “You cannot modify the following readonly fields on the posted move …: Invoice/Bill Date” |
write({"invoice_line_ids": …}) |
UserError “…readonly fields on the posted move…: Invoice lines” |
unlink |
UserError “You can’t delete a posted journal item… reset the journal entry to draft before deleting it.” |
AP bill and AR invoice #
Both are account.move, differing only in move_type (in_invoice / out_invoice) and the
journal. There is no separate approve or release call — action_post is both.
A few rules the write builder has to get right. (None of this is implemented yet.)
- Lines go through
invoice_line_idscommand tuples:[0, 0, {…}]creates a line,[5]clears them all. Awritethat replaces lines sends a clear followed by creates. - Never set
name. Odoo’s journal sequence assigns the document number ataction_post, and tofu’s number goes toref, which the read side already maps toinvoice_reference().nameis writable, but numbers are unique and a collision fails at post, not at create, leaving an orphan draft behind. Not sending it removes the failure mode entirely. - Always send
dateexplicitly on AP, and always sendtax_idsexplicitly even when empty, as[[6, 0, []]]. Omit it and Odoo applies the account’s default tax silently. - Wrap create and post. Several rules pass
createand failaction_post, and Odoo keeps the draft, so a publish path removes it before returning the error — but only when Odoo itself refused the post (HTTP 200 carrying a JSON-RPC error). A gateway error may mean the post is still running, and an unlink then queues behind the row and lands after it commits. That costs an orphan draft tofu does not know about, which is the cheaper of the two. createreturns only the integer id, no document body, so the merge-back has toreadthe move afterwards for the assignednameandstate.- Merge-back must filter
line_sectionandline_noterows exactly asfetch_invoice_linesdoes before assigning line ids, or every id after a note row is off by one.
Amounts, tax and discounts #
-
Tax inclusiveness is a property of the tax record, not the document. There is no per-document or per-line field:
account.move.linehas none, andaccount.move.company_price_includeis readonly and unstored, mirroringres.company.account_price_include. So tofu’sline_amount_type()cannot be sent at all — publish sendsprice_unitandtax_ids, and Odoo decides.Read
account.tax.price_include, neverprice_include_override: the override is unset on most taxes, meaning inherit the company default, not tax excluded.price_includeis the computed resolution of the two, so a company flipped to Tax Included flips every tax still on “Default” with it (probed 2026-11-02).A mismatch between the tax and the document produces no error from Odoo, just a different total, so tofu converts the price to whichever basis the tax wants:
net_unit_pricedivides the tax out,gross_unit_priceadds it back. Both preserve the document’s total exactly, discounts included.TAX_MODE_MISMATCHnow only fires where the line has no tax amount and no rate to derive one from, or where the instance does not report the flag.The conversion is not a compromise. Posting a converted net price against an exclusive tax and posting the gross price against an inclusive one produce byte-identical journal entries — same untaxed, same tax, same total, same debits and credits (probed on Odoo 20 with two JPY invoices at 11%: untaxed 9,009, tax 991, total 10,000 either way). Only the stored
price_unitdiffers, and the Tax Excl. / Tax Incl. buttons on the form are a display toggle over the same values. A customer asking why Odoo shows a different unit price is looking at presentation, not at the books.Because the conversion decides which arithmetic runs, the tax catalog is read fresh at publish rather than from its hour-long cache. A stale contact or account fails loudly on the write; a stale
price_includesilently books a different total in either direction. -
The tax amount cannot be overridden.
price_subtotalis readonly onaccount.move.line; writing it is silently ignored. A user-edited tax amount that disagrees with Odoo’s rate is lost. -
Line discounts are a percentage only.
account.move.line.discountis a float 0–100 and there is no per-line discount amount field, soDiscountType::Amountis rejected withLINE_DISCOUNT_AMOUNT_UNSUPPORTED. -
There is no header discount on
account.move; a non-zero one is rejected withHEADER_DISCOUNT_UNSUPPORTED. -
Currency is an integer id, required. The read side maps
currency_idto the code, so publish resolves code → id against the cached currency catalog and sends the id. No exchange rate is sent — Odoo derives it fromres.currency.ratefor the move’s date. A code that is not active in the instance givesCURRENCY_NOT_FOUND. -
Rounding follows
res.currency.decimal_places, which is stored in public metadata and is the same number the webapp calculator config uses. -
Tags are line-level only, and the value is a map.
account.move.line.analytic_distributionis a JSON map of analytic-account id → percentage ({"1": 100}), not an id list.account.movehas noanalytic_distribution, which is why Odoo does not get.with_have_header_level_tags(). A tag id carrying theplan-prefix is a category rather than a selectable value and is rejected withTAG_NOT_SELECTABLE.
Status #
Two orthogonal axes, both already mapped by map_odoo_invoice_status:
state∈draft/posted/cancel— readonly, moved byaction_post,button_draft,button_cancel.payment_state∈not_paid/in_payment/paid/partial/reversed/blocked/invoicing_legacy.
AP and AR credit notes #
Odoo has no credit-note object. A credit note is the same account.move, in the same
journal, with a refund move_type — so the connector reuses the invoice builder
(OdooMoveHeader / OdooLineInput in model/invoice_write.rs) and only the header differs.
| tofu | move_type |
Journal | Sequence |
|---|---|---|---|
AP credit note (ACCPAYCREDIT) |
in_refund |
the purchase journal bills use | RBILL/2026/10/0001 |
AR credit note (ACCRECCREDIT) |
out_refund |
the sales journal invoices use | RINV/2026/00001 |
Odoo keeps a separate refund sequence inside the document journal, which is why there is no credit-note journal to choose and nothing in the journal settings changes.
Amounts go up positive — no sign is inverted. Odoo refuses a move whose total is negative at
action_post (You cannot validate an invoice with a negative total amount.) on in_refund and
out_refund exactly as on in_invoice, so an inverted credit note would fail after the create,
stranding a draft. Extraction keeps whatever sign the document printed, and
bonsai-integration’s shared credit_note::normalized flips a document-signed note positive on
the way to the builder.
A credit note carries no due date and no line discounts, so invoice_date_due and
account.move.line.discount are simply not sent. There is no field for the invoice being
credited or the reason, so credit_note::combined_notes folds the original invoice number, the
reason and the free-text notes into narration.
Validator rules #
tofu-validator-core/src/odoo/credit_note.rs reuses the invoice header and line rules — every
one of them was re-run against both refund types on the sandbox (2026-10-27) and behaved
identically, including the lock date’s AP-shifts / AR-refuses asymmetry and the
The Bill/Refund date is required to validate this document. refusal on an undated in_refund.
The one rule that does not carry over is C02 (negative total). Odoo refuses it on a refund too, but a credit note printed with negative amounts is the document-signed form publish corrects, so reporting it at Ready would block a note tofu would have published fine. The total ceiling (E09 / C07) is checked on the magnitude instead, so a document-signed note of the same size is still caught.
Direct expense: a payment with no journal entry #
A direct expense publishes as an account.payment — and this is the paragraph to read before
debugging one.
In Odoo 20 a posted account.payment creates no journal entry at all. move_id is false
after action_post. Verified three ways: the read-back itself; counting account.move.line rows
on both the destination expense account and the cash account before and after the post (3 → 3,
zero new ledger lines); and account.move.search_read([("origin_payment_id", "!=", false)])
returning [] across the whole database. The same holds for a payment registered through Odoo’s
own wizard against an invoice.
This is not a bug and not something to work around. It is Odoo 19/20 decoupling payments from
journal entries: the expense reaches the P&L when the customer matches the payment to a bank
line in Odoo, which is that product’s own workflow. The expense account chosen in review is
stored in destination_account_id and is what drives the matching.
It follows that a direct expense carries one account, no tax, no tags and no products.
account.payment has amount, destination_account_id, memo, partner_id, date,
journal_id and currency_id, and nothing else — no lines, no tax. So:
- lines must all agree on one account, or
EXPENSE_MULTIPLE_ACCOUNTS; - a non-zero tax is rejected with
EXPENSE_TAX_UNSUPPORTED, tags withEXPENSE_TAGS_UNSUPPORTED, items withEXPENSE_ITEMS_UNSUPPORTED. The tofu validator carries the tax rule too (EXPENSE_TAX_UNSUPPORTED_ODOO), so the review form shows it before Publish rather than after; Receiveis rejected withRECEIVE_UNSUPPORTED— the model supportsinbound/customer, but the read side returns nothing for it and hard-codesDirectExpenseType::Spend, so publishing one would create a payment tofu could never read back;amountis always positive;Overpayment/Prepaymentspend types have no Odoo object here —SPEND_TYPE_UNSUPPORTED.
The alternatives all lose. An in_receipt account.move has full lines, taxes and analytic
distribution, but it must live in a purchase journal and posts against Accounts Payable — so it
shows up as an unpaid bill, which the direct-expense contract forbids, and it reads back as
neither an invoice nor an expense. An account.bank.statement.line with a contra account has the
right semantics and does hit the ledger, but the read side has no notion of it, so every merge-back
and pre-delete read-back would break.
action_post leaves state: paid, which maps to DirectExpenseStatus::Authorized — what
accounting-sync forces on upload anyway, so there is no extra step. Republish is a plain write:
Odoo applies none of the posted-move protections to payments, so no reset is needed. unlink works
on a posted payment too.
The external id is the bare account.payment id as a string, with no type prefix, because
fetch_direct_expense only ever reads account.payment.
Bank statement #
.with_bank_statement_publish() is on the capabilities arm, so
supports_publish_for_type(BankStatement) is true. The webapp reads the capability straight off
the integration (supportsPublishForType in apps/webapp/src/shared/utils/integration.ts) rather
than keeping its own provider list, so the two cannot disagree.
Bank statement publish runs synchronously in bonsapi, not in accounting-sync. The shared flow is described in Bank statement publishing.
The shape:
- One atomic
account.bank.statementcreate per group, with the lines nested asline_ids: [[0, 0, {…}], …]. Odoo rolls the whole nested create back on any error, so there is no multi-object rollback to write — only per-group error isolation. - Lines land uncategorized. Each line auto-creates a posted
account.moveagainst the journal’s suspense account, and the customer reconciles in Odoo. Writing the contra account is a follow-up (onewriteper categorized line, post-create only — passing it inside the create fails with “The entry is not balanced”, because Odoo adds its own bank row on top). - Contra tax and tags are impossible on a posted journal item and are rejected rather than
dropped (
BANK_LINE_TAX_UNSUPPORTED,BANK_LINE_TAGS_UNSUPPORTED). Do not attempt them. - Dedup is real and server-side.
account.bank.statement.line.unique_import_idis enforced: a second line with the same value fails withodoo.exceptions.ValidationError/ “A bank account transactions can be imported only once!”.assign_transaction_idsmints a UUID per line and bonsapi persists them before the upload, so a retry after a lost response sends the same ids and Odoo refuses the duplicates instead of double-importing. The upload must therefore treat that specificValidationErroras “already imported” for the line, or a crash between commit and response makes the group permanently unpublishable. - Skip split children (
parent_transaction_id().is_some()) — they are accounting allocations of a parent movement, and sending them books it twice. amountis signed. Odoo takes the sign directly, negative for money out; there is no debit/credit split to build, unlike most providers.- The statement has one
date, not a period. There is nodate_from/date_toin 20.0, so the group’s end date goes todateand the per-line dates carry the period.journal_idon the statement is readonly (derived from the lines) andbalance_endis computed; onlybalance_startandbalance_end_realare writable. is_completeandis_validare independent.is_completeis the ending-balance signal andis_validthe chaining signal, andproblem_descriptioncarries only the chaining one. With zero lines the balance check is skipped entirely.- Validation fails closed. If the account or journal fetch errors,
validate_bank_statement_for_publishreturnsBANK_VALIDATION_UNAVAILABLE— neverOk(())and never “account not valid”.
find_or_create_bank_account is not supported, and .with_create_bank_account() must not be
added: AccountingAccount.number is hard-coded None for Odoo and account.account has no
account-number field to populate it from, so find-or-create would duplicate the account every time.
The natural source would be res.partner.bank.acc_number joined through the journal’s
bank_account_id.
Attachments: the field is raw, not datas
#
ir.attachment with res_model (account.move or account.payment) and res_id.
| Operation | Call |
|---|---|
| upload | ("ir.attachment", "create") → the new integer id |
| list | ("ir.attachment", "search_read") on [("res_model", "=", …), ("res_id", "=", id)] |
| download | ("ir.attachment", "read") with field raw |
| delete | ("ir.attachment", "unlink") — a hard delete |
**`ir.attachment.datas` does not exist in Odoo 20, and writing it silently stores a zero-byte file.** `fields_get(["datas", "raw", "db_datas"])` returns only `raw` and `db_datas`, and `read([[id], ["datas"]])` fails with `builtins.ValueError: Invalid field 'datas' on 'ir.attachment'` — but **`create` with `datas` does not fail**. The probe created an attachment and read it back as `{"file_size": 0, "checksum": false}`: a valid-looking id with no file behind it. The same payload sent as `raw` gave `{"file_size": 38, "checksum": "0a43ee…"}`. So the upload must **assert `file_size > 0` after every create**. That is the only way to catch a silently dropped payload, and `errorMessage.odoo.ATTACHMENT_EMPTY` exists for it.
Other field rules:
rawtakes a base64 string on write.read(["raw"])returns a wrapper object, not a bare string:{"filename": …, "content": "<base64>", "size": 38}. Decodecontent.nameis required;mimetypeis readonly and inferred from the filename, so sendnamewith the right extension rather than trying to setmimetype.typeis required and defaults tobinary.account.moveexposes its attachments asattachment_ids;account.paymenthas no such convenience field, so that listing is asearch_read.
Size: ODOO_MAX_ATTACHMENT_SIZE_BYTES = 9_000_000, with the matching arm in
max_attachment_bytes. Odoo publishes no ir.attachment size limit; the only configured ceiling
found on a live instance was document.max_fileupload_size (≈63.9 MiB), which belongs to the
Documents app rather than the attachment model, and base64 inflates the JSON-RPC body by a further
4/3. The decimal figure is deliberate: it matches IntegrationProvider::max_attachment_size()’s
_ => 9_000_000 default exactly, so the two agree, where a MiB figure would not. A test pins them
equal. The real ceiling is still to be measured.
accounting-sync passes that value to bonsai-doc-convert, which splits a collected PDF to fit. There is no MIME allow-list — Odoo stores whatever it is given — and no per-document file count limit was found.
Errors arrive as HTTP 200 #
Every ORM failure comes back as HTTP 200 with an error object, so the HTTP status says
nothing about it:
{"error": {"code": 200, "message": "Odoo Server Error",
"data": {"name": "odoo.exceptions.UserError",
"message": "You cannot modify the following readonly fields …",
"debug": "Traceback …"}}}
data.name is the Python exception class and is the only reliable discriminator;
data.message is translated into the API user’s language. classify_rpc_error(error, operation)
in model/error.rs maps them:
data.name |
Maps to | Why |
|---|---|---|
odoo.exceptions.MissingError |
NotFound(message) |
Odoo’s 404 — delete treats it as already gone |
odoo.exceptions.AccessError / AccessDenied |
BadRequest(ACCESS_DENIED) |
the key is valid, the user lacks a group — not Disconnected |
builtins.ValueError / AttributeError / TypeError |
terminal BadRequest with the raw text |
tofu sent something malformed; a retry only repeats it |
| lock-date message (any class) | InvoiceLocked on write / unlink, else BadRequest(PERIOD_LOCKED) |
the republish/unpublish path versus the create path |
| “Another entry with the same name already exists” | BadRequest(DOCUMENT_NUMBER_TAKEN) |
|
odoo.exceptions.UserError / ValidationError |
BadRequest(message) |
business rule the user can fix |
| anything else | Unexpected |
retried up to 10 times |
The operation argument matters: UserError alone covers fourteen unrelated rules, and the ORM
method is the only free, language-independent way to tell them apart. OdooOperation::from_method
derives it from the method name, so every existing call site keeps working unchanged.
Two traps:
- Message-substring matching is locale-fragile. Odoo translates
data.messageper the API user’slang, so the lock-date and duplicate-name matches are a last resort. A false negative degrades to a generic message, never to a wrong action — keep it that way. - A
readof a missing id returns[]with no error. OnlywriteandunlinkraiseMissingError. Sofetch_*has to synthesizeNotFounditself, which is whyfetch_single_invoiceusessearch_readwith alimitand returnsNotFoundon an empty result rather than trusting an error to arrive.
Odoo also accumulates the checks that run at action_post into one UserError whose message is
several messages joined by newlines. OdooRpcError::messages() splits on \n.
handle_response still maps real HTTP statuses: 429 → TooManyRequests with the response’s
Retry-After, 401/403 → Disconnected, 5xx → ExternalServerError.
Write concurrency stays at the default of 1 (concurrent_limits_for_provider). Odoo serialises
writes to one account.move chain anyway, and parallel action_post calls race on the journal
sequence. No rate limit is documented for Odoo Online and none was hit across ~70 write calls in a
probe session.
Odoo 19 versus 20 #
The connector targets 20.0 and meets the differences below by probing fields rather than by branching on a version (see Versions):
| Change | 19 (and earlier) | 20 |
|---|---|---|
ir.attachment.datas |
present | gone — use raw; a create with datas stores a zero-byte file and does not fail |
account.payment journal entry |
a posted payment produces one | none — move_id is false; the entry appears when the payment is matched to a bank line |
| Tax inclusiveness field | account.tax.price_include (boolean) |
price_include_override (false / tax_included / tax_excluded) |
| Permission pre-flight | check_access_rights |
gone; check_access is private and refused remotely — no pre-flight at all |
account.payment.state values |
included posted |
draft / paid / reconciled / canceled / rejected — no posted |
| Company lock dates | period_lock_date |
split into purchase_lock_date + sale_lock_date, plus the new hard_lock_date; each has a user_* twin |
Two more 20.0 renames bite the read side and are already handled by field probes:
account.account.deprecated is gone (use active), and res.users.groups_id is gone.
is_unknown_field_error exists so those probes can ask by field name — classify_rpc_error turns
an unknown field into a terminal BadRequest, which is right for a genuinely malformed request and
wrong for a deliberate probe.
Known gaps #
-
OdooIntegrationSettingin the OpenAPI spec is behind the code. It carriesurl,databaseandbase_currencyonly — notdecimal_places,journalsorlock_dates. TheCreateApiKeyIntegrationRequestdescription also omitslogin, whichOdooCredentialsrequires. Both need fixing with the journal-override work. -
Enrichment writes the whole integration row. The journals and lock dates refresh on a 15-minute TTL, and each refresh saves a record read before the two Odoo calls — so a deactivate landing inside that window can be replayed over. The TTL makes it rare rather than impossible; closing it needs a targeted metadata update.
-
The
ir.attachmentpractical size ceiling over JSON-RPC is unmeasured. 9,000,000 bytes is a deliberate under-estimate. Upload 1 / 8 / 16 / 32 MiB files and record the first failure. -
hard_lock_datebehaviour is unprobed, deliberately — Odoo refuses to clear or lower it. Expect a hard refusal where a soft lock shifts silently. -
restrict_mode_hash_table(secure posted entries) ×button_draftis unprobed, also deliberately: enabling it retroactively hashes every move back to the last hashed entry and Odoo will not unhash them. If it refuses, the whole unpublish path gains a second hard block. -
The under-privileged
AccessErrorwording is unconfirmed. The probe key is an Accounting Administrator. Confirming it needs a second Odoo user with Accounting / Billing and without Administrator, and its own API key. -
Multi-company
company_idmismatch — between the partner, account, journal and the document — is unprobed and the validator is blind to it. -
narrationis an HTML field. Publishing a description wrapped as<p>…</p>means the nextfetch_invoicereads the wrapper back, so a republish would wrap it again. Decide whether the write side strips a single wrapping<p>or the read side sanitizes, and test a publish → read → republish cycle. -
A bank statement links per account group, not per extraction. Publish writes one Odoo
account.bank.statementper group, so the link lives on the group’s row in the V2 sync panel and opens/odoo/action-account.action_bank_statement_tree/{id}— clicked and confirmed (2026-11-02). The XML id is what travels: the numeric action id is assigned per database, and Odoo redirects the xmlid form to it.account.bank.statementhas noir.actions.act_window.pathof its own, which is why it goes through an action at all.The id has to be the group’s
external_id.getInvoiceUrlis handed the bank account’s id, which is what Xero and FreeAgent key their statement views by, so the statement id travels beside it asstatementExternalId. Three things cost time here and are worth knowing: the review screen renders the V2 panel (bank-statement-v2-sync-info.tsx), not the legacybank-statement-sync-info.tsx; the header’s link button readsextraction_data, which carries onlybank_statement_data_external_account_id; andreconciliationis a path only underaccounting/{journal}, so/odoo/reconciliationanswers Odoo’s error page.The record is a good destination: it shows “The final calculated balance doesn’t match the specified ending balance” on a statement that does not add up, which is the warning the precision check exists to prevent.
-
Deep-link URLs are unclicked.
/odoo/bills/{id},/odoo/customer-invoices/{id},/odoo/vendor-refunds/{id},/odoo/customer-credit-notes/{id}and/odoo/vendor-payments/{id}are the instance’s ownir.actions.act_window.pathvalues, read back from the sandbox, and each action filters on its own move type. An unauthenticatedcurlreturns 303 to the login page for valid and invalid paths alike, so they still have to be clicked while logged in. -
A lost
createresponse still duplicates the record. Odoo answers ORM failures with HTTP 200, so a 5xx is the gateway in front of it and the write has often already committed. Only the repeatable methods are re-sent (RETRYABLE_ORM_METHODS: the reads,writeandunlink);create,action_post,button_draftandbutton_cancelare sent once, because Odoo raises aUserErrorfor a move already in the state the call moves it to.Sending once is only half of it, since the call may still have committed. Reading the record back does not settle it either: on a gateway error the call may still be running, so the read is pre-commit and a state Odoo is about to change looks like the answer. Only Odoo’s own refusal, which it answers with HTTP 200 carrying a JSON-RPC error, proves a call rolled back (
odoo_rejected_the_call), and that is what every irreversible follow-up is gated on.So a failed
button_draftcarries the rewrite on rather than returning, because carrying on is safe whichever way it went — a committed reset proceeds, one that never ran is refused at thewrite, and one mid-flight queues behind the same row. A failedaction_postreads the record back only to keep the id when Odoo says it posted, since losing it costs a duplicate on the next publish; that read never drives a delete.Precision and convertibility are the document currency’s, never the company’s. A USD expense of 50.25 on a yen company is valid, and measuring it against the base currency’s zero decimals refuses it. All three paths check both: moves through
resolve_publish_currency, direct expenses throughpublish_currency, and statements inplan_bank_statement, where the currency is resolved the waybank_currency_matchesresolves it — the journal’s own, then the company’s, never an invented default. A code the catalog does not hold is refused rather than guessed at.A rate table that could not be read says nothing at all, so every currency stays publishable rather than every one being refused. The catalog is cached on the currencies TTL, because
validate_*_publishruns on every draft save; a rate added in Odoo is picked up by refreshing accounting settings, as with the other catalogs.Odoo keeps its posted-record protections for
account.moveonly: it refuses to unlink a posted move but deletes a postedaccount.paymentwithout a word (probed on Odoo 20). So the payment delete is gated on Odoo having answered with a rejection — an HTTP 200 carrying a JSON-RPC error, which is the only proof the post rolled back. A gateway error proves nothing: the post may still be running, so adraftread is pre-commit and the unlink would queue behind the row and delete the payment Odoo is about to post.On a gateway error the payment is therefore left alone and the state is read only to decide whether to keep the id, since losing it costs a duplicate on the next publish. What is left behind may be a draft to remove or a payment that posted after the timeout, so the error names the payment id rather than claiming either.
draftis the only spelling relied on, being the one that has not moved across versions. The posted spellings vary (Odoo 20 sayspaidthenreconciledand has noposted; older instances saypostedorin_process), soPAYMENT_STATES_POSTEDaccepts all four and only decides whether the id is kept — a missing spelling costs a duplicate, never a deletion.What is left is the
createcase alone, where there is no id to read back: the record exists in Odoo, tofu never saw its id, and a republish by hand creates a second one. Odoo has no client idempotency key, so closing it means searching on the error path —(move_type, partner_id, journal_id, ref)created in the last minute — and adopting what comes back. That is not implemented. It would also not cover a document with norefat all, which is the common case now that Odoo’s own sequence name is no longer written back as the number.The re-read is itself a call that can fail. If Odoo is unreachable for both the action and the re-read, the outcome is unknown again and the publish fails — the window is narrowed, not closed.
-
Foreign-currency publish is unprobed. Every manual test so far has been JPY on a JPY-base company. tofu sends no exchange rate — there is no rate field anywhere in the connector — so Odoo converts to the company currency from its own rate table for the document’s date. A currency with no rate at all is refused before publish, but a currency whose table has no row for that date is still booked on whatever Odoo has.
CURRENCY_INACTIVE_ODOOandACCOUNT_CURRENCY_MISMATCH_ODOOexist in the validator but cannot fire on a single-currency setup, so neither has been exercised. One foreign-currency document would test all of this at once. -
Odoo’s tax rounding method is not read.
res.company.tax_calculation_rounding_methodisround_per_lineorround_globally, and tofu never reads it while rounding by its own calculator config. Three JPY lines of 333 at 11% come to 1,110 per line and 1,109 globally, so a multi-line document can differ from tofu’s total by the smallest unit. Not yet measured against a real document. -
No user-facing Odoo guide exists under
docs/external/content/docs/accounting/, and there is no help-center article. Both are outstanding content work.
Related documentation #
- Integrations overview
- Bank statement publishing — the shared bank statement flow
- AutoCount Cloud — the other API-key connector that publishes
- Bukku — API-key connect flow precedent
- Integration APIs