Odoo Integration

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_include became price_include_override — the tax fetch asks for the newer field set and retries without it when Odoo rejects the column.
  • account.payment.is_reconciled is read for the settled check, rather than matching state == "reconciled", which is Odoo 20’s spelling.
  • ir.attachment is written with raw, 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:

  1. The lock is inclusive. A document dated exactly on the lock date is shifted.
  2. Setting date explicitly does not protect the period. The shift fires at create when date is omitted, and again at action_post even when it was set.
  3. No UserError is 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 in tofu-validator-core/src/odoo/invoice.rs and 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.

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_ids command tuples: [0, 0, {…}] creates a line, [5] clears them all. A write that replaces lines sends a clear followed by creates.
  • Never set name. Odoo’s journal sequence assigns the document number at action_post, and tofu’s number goes to ref, which the read side already maps to invoice_reference(). name is 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 date explicitly on AP, and always send tax_ids explicitly even when empty, as [[6, 0, []]]. Omit it and Odoo applies the account’s default tax silently.
  • Wrap create and post. Several rules pass create and fail action_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.
  • create returns only the integer id, no document body, so the merge-back has to read the move afterwards for the assigned name and state.
  • Merge-back must filter line_section and line_note rows exactly as fetch_invoice_lines does 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.line has none, and account.move.company_price_include is readonly and unstored, mirroring res.company.account_price_include. So tofu’s line_amount_type() cannot be sent at all — publish sends price_unit and tax_ids, and Odoo decides.

    Read account.tax.price_include, never price_include_override: the override is unset on most taxes, meaning inherit the company default, not tax excluded. price_include is 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_price divides the tax out, gross_unit_price adds it back. Both preserve the document’s total exactly, discounts included. TAX_MODE_MISMATCH now 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_unit differs, 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_include silently books a different total in either direction.

  • The tax amount cannot be overridden. price_subtotal is readonly on account.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.discount is a float 0–100 and there is no per-line discount amount field, so DiscountType::Amount is rejected with LINE_DISCOUNT_AMOUNT_UNSUPPORTED.

  • There is no header discount on account.move; a non-zero one is rejected with HEADER_DISCOUNT_UNSUPPORTED.

  • Currency is an integer id, required. The read side maps currency_id to the code, so publish resolves code → id against the cached currency catalog and sends the id. No exchange rate is sent — Odoo derives it from res.currency.rate for the move’s date. A code that is not active in the instance gives CURRENCY_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_distribution is a JSON map of analytic-account id → percentage ({"1": 100}), not an id list. account.move has no analytic_distribution, which is why Odoo does not get .with_have_header_level_tags(). A tag id carrying the plan- prefix is a category rather than a selectable value and is rejected with TAG_NOT_SELECTABLE.

Status #

Two orthogonal axes, both already mapped by map_odoo_invoice_status:

  • state ∈ draft / posted / cancel — readonly, moved by action_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 with EXPENSE_TAGS_UNSUPPORTED, items with EXPENSE_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;
  • Receive is rejected with RECEIVE_UNSUPPORTED — the model supports inbound/customer, but the read side returns nothing for it and hard-codes DirectExpenseType::Spend, so publishing one would create a payment tofu could never read back;
  • amount is always positive;
  • Overpayment / Prepayment spend 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.statement create per group, with the lines nested as line_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.move against the journal’s suspense account, and the customer reconciles in Odoo. Writing the contra account is a follow-up (one write per 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_id is enforced: a second line with the same value fails with odoo.exceptions.ValidationError / “A bank account transactions can be imported only once!”. assign_transaction_ids mints 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 specific ValidationError as “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.
  • amount is 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 no date_from / date_to in 20.0, so the group’s end date goes to date and the per-line dates carry the period. journal_id on the statement is readonly (derived from the lines) and balance_end is computed; only balance_start and balance_end_real are writable.
  • is_complete and is_valid are independent. is_complete is the ending-balance signal and is_valid the chaining signal, and problem_description carries 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_publish returns BANK_VALIDATION_UNAVAILABLE — never Ok(()) 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:

  • raw takes a base64 string on write.
  • read(["raw"]) returns a wrapper object, not a bare string: {"filename": …, "content": "<base64>", "size": 38}. Decode content.
  • name is required; mimetype is readonly and inferred from the filename, so send name with the right extension rather than trying to set mimetype.
  • type is required and defaults to binary.
  • account.move exposes its attachments as attachment_ids; account.payment has no such convenience field, so that listing is a search_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.message per the API user’s lang, 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 read of a missing id returns [] with no error. Only write and unlink raise MissingError. So fetch_* has to synthesize NotFound itself, which is why fetch_single_invoice uses search_read with a limit and returns NotFound on 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 #

  • OdooIntegrationSetting in the OpenAPI spec is behind the code. It carries url, database and base_currency only — not decimal_places, journals or lock_dates. The CreateApiKeyIntegrationRequest description also omits login, which OdooCredentials requires. 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.attachment practical 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_date behaviour 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_draft is 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 AccessError wording 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_id mismatch — between the partner, account, journal and the document — is unprobed and the validator is blind to it.

  • narration is an HTML field. Publishing a description wrapped as <p>…</p> means the next fetch_invoice reads 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.statement per 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.statement has no ir.actions.act_window.path of its own, which is why it goes through an action at all.

    The id has to be the group’s external_id. getInvoiceUrl is handed the bank account’s id, which is what Xero and FreeAgent key their statement views by, so the statement id travels beside it as statementExternalId. Three things cost time here and are worth knowing: the review screen renders the V2 panel (bank-statement-v2-sync-info.tsx), not the legacy bank-statement-sync-info.tsx; the header’s link button reads extraction_data, which carries only bank_statement_data_external_account_id; and reconciliation is a path only under accounting/{journal}, so /odoo/reconciliation answers 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 own ir.actions.act_window.path values, read back from the sandbox, and each action filters on its own move type. An unauthenticated curl returns 303 to the login page for valid and invalid paths alike, so they still have to be clicked while logged in.

  • A lost create response 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, write and unlink); create, action_post, button_draft and button_cancel are sent once, because Odoo raises a UserError for 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_draft carries 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 the write, and one mid-flight queues behind the same row. A failed action_post reads 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 through publish_currency, and statements in plan_bank_statement, where the currency is resolved the way bank_currency_matches resolves 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_*_publish runs 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.move only: it refuses to unlink a posted move but deletes a posted account.payment without 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 a draft read 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.

    draft is the only spelling relied on, being the one that has not moved across versions. The posted spellings vary (Odoo 20 says paid then reconciled and has no posted; older instances say posted or in_process), so PAYMENT_STATES_POSTED accepts all four and only decides whether the id is kept — a missing spelling costs a duplicate, never a deletion.

    What is left is the create case 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 no ref at 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_ODOO and ACCOUNT_CURRENCY_MISMATCH_ODOO exist 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_method is round_per_line or round_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.