artaza / module-odoo-integration
Magento 2 endpoints for the Odoo connector: push a product tax class and show an order's negotiated total. Companion to the Odoo 19 module Magento 2 Connector.
Package info
github.com/martinartaza/magento_odoo_integration
Type:magento2-module
pkg:composer/artaza/module-odoo-integration
Requires
- php: >=8.1
- magento/framework: *
- magento/module-backend: *
- magento/module-catalog: *
- magento/module-integration: *
- magento/module-sales: *
- magento/module-store: *
- magento/module-tax: *
- magento/module-webapi: *
README
Magento 2 endpoints for the Magento 2 Connector for Odoo 19.
Odoo talks to Magento over the native REST API for almost everything it syncs — stock, prices, orders, invoices, shipments, coupons. This module adds the two endpoints Magento Open Source does not provide, so Odoo can finish the loop:
| Endpoint | What it does |
|---|---|
POST /V1/products/tax-class |
Assigns a product tax class to a batch of SKUs |
POST /V1/orders/{orderId}/negotiation |
Stores an order's negotiated total and the reason, for display |
Both are authenticated with the access token of a Magento integration and guarded by their own ACL resources, so you grant exactly these two and nothing else.
How it fits together
The ERP owns the numbers; Magento stores and displays them. Nothing here calls out — this module only ever answers.
flowchart LR
subgraph ERP["ERP (e.g. Odoo)"]
E1["tax rate per product"]
E2["negotiated total"]
end
subgraph MAG["Magento 2"]
direction TB
A["Artaza_OdooIntegration<br/>2 REST endpoints"]
P[("catalog_product_entity<br/>tax_class_id")]
O[("sales_order<br/>adjustment_amount<br/>adjustment_reason<br/>negotiation_total")]
V["storefront + admin<br/>order totals"]
A -->|"updateAttributes"| P
A -->|"saveAttribute"| O
O --> V
end
E1 -->|"POST /V1/products/tax-class<br/>Bearer token"| A
E2 -->|"POST /V1/orders/{id}/negotiation<br/>Bearer token"| A
Loading
The tax-class endpoint, step by step
The order of the checks is the point: the class is validated before anything is written, and SKUs Magento does not know are reported back instead of failing the whole batch silently.
flowchart TD
S["POST /V1/products/tax-class<br/>{taxClassId, skus[]}"] --> E{"skus empty?"}
E -->|yes| X1["InputException<br/>'At least one SKU is required'"]
E -->|no| L["load tax class by id"]
L --> N{"class exists?"}
N -->|no| X2["NoSuchEntityException"]
N -->|yes| T{"is it a PRODUCT<br/>tax class?"}
T -->|"no (customer class)"| X3["InputException<br/>— never write a customer class"]
T -->|yes| C["load product collection<br/>filtered by those SKUs"]
C --> U["updateAttributes() mass-update<br/>writes ONLY tax_class_id"]
U --> R["return the SKUs<br/>that were not found"]
Loading
updateAttributes is the native mass-update path: one query for the whole batch, and nothing
else about the product is rewritten — no full save, no unrelated reindex.
The negotiation endpoint
sequenceDiagram
participant ERP
participant API as Artaza_OdooIntegration
participant DB as sales_order
participant Shopper
ERP->>API: POST /V1/orders/125/negotiation<br/>{adjustmentAmount, adjustmentReason, negotiationTotal}
API->>DB: saveAttribute() — only those 3 columns
Note over DB: grand_total, invoices and<br/>balances are NOT recalculated
Shopper->>DB: opens the order
DB-->>Shopper: original total struck through<br/>+ agreed total + the reason
Loading
Writing with saveAttribute instead of OrderRepository::save is deliberate: a full save would
fire the order's observers and recalculations. The agreed figure is display only — the
document that carries fiscal weight is the one the ERP issues.
Install
composer require artaza/module-odoo-integration bin/magento module:enable Artaza_OdooIntegration bin/magento setup:upgrade bin/magento setup:di:compile bin/magento cache:flush
Grant the ACL
System ▸ Extensions ▸ Integrations ▸ your integration ▸ API, then tick:
Artaza_OdooIntegration::product_tax— under CatalogArtaza_OdooIntegration::negotiation— under Sales
The tax-class endpoint also needs the native Magento_Tax::manage_tax, because the caller
reads taxClasses / taxRules / taxRates to work out which class charges which rate.
⚠️ After adding an ACL resource you must reauthorize the integration, or its calls keep coming back
401with the old token's permissions.
⚠️ This module changes one store-wide setting
Since Magento 2.4.4, an integration's access token cannot be used as a Bearer token on the
REST API unless "Allow OAuth Access Tokens to be used as standalone Bearer tokens" is on — it
defaults to off. Without it, every server-to-server call answers "The consumer isn't
authorized to access %resources" even when the integration has been granted everything.
Because that is exactly how an ERP authenticates, this module ships etc/config.xml turning the
flag on by default:
Stores ▸ Configuration ▸ Services ▸ OAuth ▸ Consumer Settings
Allow OAuth Access Tokens to be used as standalone Bearer tokens = Yes
Know what that means: it is a store-wide default that re-enables the pre-2.4.4 behaviour for
every integration token in the installation, not only this one. It is what makes the module
work out of the box, and it is a decision you should make consciously. If your security policy
forbids it, delete etc/config.xml from the package and set the value yourself, scoped where you
need it.
The endpoints
Product tax class
POST /rest/all/V1/products/tax-class Authorization: Bearer <integration token> { "taxClassId": 4, "skus": ["IPH16-128-BLK", "CASE-MAG-16"] }
Returns the SKUs it did not find, so a partial failure is reported instead of passing as a clean run:
["SKU-THAT-DOES-NOT-EXIST"]
Two deliberate choices:
- It writes through
Product\Action::updateAttributes(Magento's native mass-update), notProductRepository::save. Onlytax_class_idis touched — nothing else about the product is rewritten, and no unrelated indexer is dragged in. - It validates that the class exists and is a product tax class, not a customer one. Assigning a customer class would be accepted by the raw model and silently corrupt the catalogue.
Negotiated total
POST /rest/all/V1/orders/125/negotiation Authorization: Bearer <integration token> { "adjustmentAmount": -2000, "adjustmentReason": "Volume discount agreed by phone", "negotiationTotal": 95000 }
B2B totals get agreed off-site. The ERP owns that number; this endpoint only records it so the
customer can see it on their order. It is written with saveAttribute, so Magento's balances,
totals and invoices are never recalculated — the fiscal document is the one the ERP issues.
The value is rendered on the storefront order view and in the admin order view.
Also inside
Plugin\GuestOrderLastname— makes the guest order lookup tolerant of the surname's spacing and capitalisation, which is a common source of "order not found" for guests.
Notes
- This module does not talk to Odoo. It exposes endpoints and waits; the connector on the Odoo side is what calls them.
- It adds no storefront output of its own beyond the negotiated total on the order view, and creates no CMS content: installing it changes nothing a shopper sees.
Requirements
- Magento 2.4.x (Open Source or Commerce)
- PHP 8.1+
License
OSL-3.0 — the same license as the Magento core.
Sebastian Artaza · artaza.net · martin.artaza@gmail.com