Fleet costs in Odoo
This recipe connects Fleet to Odoo Enterprise (17, 18 or 19) in both directions that matter for cost: fleet requests go to Odoo for purchasing and come back with the PO, bill and payment; recorded costs (fuel, receipts, claims, renewals) are posted to Odoo as vendor bills or journal entries. Read Costs and your ERP first for the model.
Scopes: fleet_requests:read, financial_events:write, costs:write, accounting:write, webhooks:write,
vehicles:read, vendors:read.
0. Before you start
Section titled “0. Before you start”API access. Odoo’s external API is available on Odoo.sh and on-premise Enterprise. On Odoo Online it needs the Custom plan; Standard plans have no API access. Create a dedicated integration user in Odoo with only the Purchase and Accounting rights it needs, then an API key for it (Preferences → Account Security → New API Key).
Which Odoo API. Odoo 19 adds the JSON-2 API (POST /json/2/<model>/<method>). XML-RPC and JSON-RPC still work
on 17, 18 and 19 but are removed in Odoo 22 (Odoo Online 21.1). Write your Odoo calls behind one helper, so moving
to JSON-2 is a one-function change. The rest of this recipe calls odoo(model, method, **kwargs):
import os, requests
ODOO = os.environ["ODOO_URL"] # https://inkomoko.odoo.comODOO_KEY = os.environ["ODOO_API_KEY"]
def odoo(model, method, ids=None, **kwargs): body = {**({"ids": ids} if ids else {}), **kwargs, "context": {"lang": "en_US"}} res = requests.post(f"{ODOO}/json/2/{model}/{method}", json=body, timeout=60, headers={"Authorization": f"bearer {ODOO_KEY}", "X-Odoo-Database": os.environ.get("ODOO_DB", "")}) res.raise_for_status() return res.json()
partners = odoo("res.partner", "search_read", domain=[["supplier_rank", ">", 0]], fields=["name", "vat"])import os, xmlrpc.client
ODOO, DB = os.environ["ODOO_URL"], os.environ["ODOO_DB"]USER, KEY = os.environ["ODOO_USER"], os.environ["ODOO_API_KEY"]uid = xmlrpc.client.ServerProxy(f"{ODOO}/xmlrpc/2/common").authenticate(DB, USER, KEY, {})models = xmlrpc.client.ServerProxy(f"{ODOO}/xmlrpc/2/object", allow_none=True)
def odoo(model, method, ids=None, **kwargs): args = [ids] if ids else [] if "domain" in kwargs: args.append(kwargs.pop("domain")) if "vals_list" in kwargs: args.append(kwargs.pop("vals_list")) return models.execute_kw(DB, uid, KEY, model, method, args, kwargs)The Fleet side is a small client:
FLEET = requests.Session()FLEET.headers["Authorization"] = f"Bearer {os.environ['FLEET_API_KEY']}"
class fleet: @staticmethod def get(path, **kw): return FLEET.get(f"https://api.get-fleet.com/v1{path}", timeout=30, **kw) @staticmethod def post(path, **kw): return FLEET.post(f"https://api.get-fleet.com/v1{path}", timeout=30, **kw) @staticmethod def put(path, **kw): return FLEET.put(f"https://api.get-fleet.com/v1{path}", timeout=30, **kw)How Odoo tells Fleet what happened. Your integration needs to notice when a PO is confirmed, a bill is posted or a payment is made, and send it to Fleet. Two ways that work on every Odoo hosting option:
- Poll Odoo (simplest, recommended): every few minutes,
search_readpurchase.orderandaccount.moverecords whosewrite_dateis after your last run and that reference a fleet request, then post the events below. Re-sending an event is harmless. - Odoo automation rules with Send Webhook Notification: Odoo POSTs the record’s fields to a URL when it changes.
The action can’t add an
Authorizationheader, so point it at your integration (a small cloud function), which checks a secret in the URL and calls Fleet with your key. Never put a Fleet API key in an Odoo webhook URL.
Linking Odoo records to fleet requests. Put the request number (FR-000042) somewhere your integration can
read back: the purchase request’s or PO’s Source Document (origin), the bill’s Reference, or a custom field
added with Studio (for example x_studio_fleet_request). Studio is part of Enterprise, so a dedicated field is the
cleanest option.
1. Map Fleet to Odoo once
Section titled “1. Map Fleet to Odoo once”-
Analytic plans become dimensions. For each Odoo analytic plan you book fleet costs against (department, project, donor…), create a Fleet dimension and push its accounts as values. Re-run nightly with
deactivate_missing: true.plans = odoo("account.analytic.account", "search_read",domain=[["plan_id.name", "=", "Department"]], fields=["code", "name", "active"])fleet.put("/accounting_dimensions/department/values", json={"values": [{"code": a["code"], "name": a["name"], "is_active": a["active"]} for a in plans if a["code"]],"deactivate_missing": True,}) -
Link vehicles, vendors and categories with external references, so every Fleet id has its Odoo id. Put the expense account and purchase tax for each cost category in
metadata:fleet.put("/external_references", json={"data": [{"object_type": "cost_category", "object_key": "fuel", "system": "odoo", "external_id": "611000","metadata": {"account_id": 512, "tax_id": 23}},{"object_type": "cost_category", "object_key": "maintenance.parts", "system": "odoo", "external_id": "612100","metadata": {"account_id": 518, "tax_id": 23}},{"object_type": "vendor", "object_key": rubis_fleet_id, "system": "odoo", "external_id": "88","metadata": {"qualified": True}},]}) -
Tell Fleet which ERP handles requests:
PATCH /v1/accounting_settingswith{"primary_erp_system": "odoo"}.
2. Fleet requests → Odoo purchasing
Section titled “2. Fleet requests → Odoo purchasing”A fleet manager approves the operational need in Fleet; Odoo handles budget, sourcing and payment.
-
Subscribe to approvals. Create a webhook endpoint for
fleet_request.approved(andfleet_request.cancelled). As a safety net, also pollGET /v1/fleet_requests?status=approved&financial_status=not_sentevery few minutes. -
Check readiness, then create the Odoo document.
GET /v1/fleet_requests/{id}/readiness?system=odoolists anything Odoo would reject. Ifreadyis false, write anexceptionevent so the fleet team sees why:r = fleet.get(f"/fleet_requests/{rid}/readiness", params={"system": "odoo"}).json()if not r["ready"]:fleet.post(f"/fleet_requests/{rid}/financial_events", json={"system": "odoo", "integration_transaction_id": f"readiness:{rid}:{int(time.time() // 3600)}","type": "exception", "code": r["issues"][0]["code"], "message": r["issues"][0]["message"],})returnOtherwise create a purchase request or RFQ from the request’s lines. Each line’s
cost_categorygives the product or account, and the request’sdimensionsgive theanalytic_distribution. -
Write each outcome back with an
integration_transaction_idthat names the Odoo record and state, so retries are harmless:In Odoo Fleet event Purchase request created / submitted committed(withreferences.purchase_request)PO confirmed po_issued(withreferences.purchase_orderandamounts)Vendor bill posted invoiced(withreferences.bill,amounts.total,amounts.tax,document_date)Credit note posted creditedBill paid paidRefused by finance rejected(withreason)fleet.post(f"/fleet_requests/{rid}/financial_events", json={"system": "odoo","integration_transaction_id": f"account.move:{bill.id}:posted","type": "invoiced","occurred_at": bill.write_date.isoformat() + "Z","references": {"purchase_order": po.name, "bill": bill.name},"amounts": {"total": str(bill.amount_total), "tax": str(bill.amount_tax), "currency": bill.currency_id.name},"document_date": bill.invoice_date.isoformat(),})Find what changed by polling (
write_dateafter your last run) or from an automation rule (see Before you start). Late or repeated events never move the status backwards. -
The bill becomes the cost. Once
invoicedarrives, Fleet records the bill in its cost ledger and marks the linked work order’s costs assuperseded, so the vehicle’s cost is the billed amount, counted once. These ledger entries come from Odoo, so they are never offered back to Odoo’s export queue.
3. Recorded costs → Odoo
Section titled “3. Recorded costs → Odoo”Everything that was not bought through a fleet request (fuel-card transactions, driver receipts, approved claims, licence renewals) comes from the cost ledger.
import base64
def sync_costs(): cursor = None while True: page = fleet.get("/cost_exports", params={"system": "odoo", "limit": 200, **({"cursor": cursor} if cursor else {})}).json() acks = [] for item in page["data"]: entry, status, last = item["cost_entry"], item["export_status"], item["last_export"] if status == "pending_void": reverse(last["external_id"]) # reverse the bill / entry you posted acks.append({"id": entry["id"], "revision": entry["revision"], "external_id": last["external_id"]}) continue if status == "pending_update" and last: reverse(last["external_id"]) # simplest correct approach: reverse and re-post move_id = post(entry) acks.append({"id": entry["id"], "revision": entry["revision"], "external_id": str(move_id)}) if acks: fleet.post("/cost_exports", json={"system": "odoo", "entries": acks}) if not page["has_more"]: break cursor = page["next_cursor"]
def analytic(dimensions): """Fleet dimension codes → Odoo analytic_distribution. One key holds an account from each plan.""" ids = [str(ANALYTIC_IDS[(key, code)]) for key, code in sorted(dimensions.items()) if (key, code) in ANALYTIC_IDS] return {",".join(ids): 100} if ids else {}
def post(e): if existing := odoo("account.move", "search", domain=[["x_studio_fleet_cost_entry", "=", e["id"]], ["state", "!=", "cancel"]]): return existing[0] # posted before a crash: just acknowledge it account = category_account(e["category"], e["subcategory"]) # from the cost_category references line = { "name": f'{e["category"]}: {e.get("description") or e["subcategory"] or ""}'.strip(": "), "quantity": 1, "price_unit": float(e["amount"]), # gross; use a tax-included purchase tax "account_id": account["account_id"], "tax_ids": [(6, 0, [account["tax_id"]])] if e["tax"] else [], "analytic_distribution": analytic(e["dimensions"]), } common = {"x_studio_fleet_cost_entry": e["id"], "currency_id": currency_id(e["currency"])} if e["settlement"] in ("vendor_bill", "fuel_card", "cash"): [move_id] = odoo("account.move", "create", vals_list=[{ **common, "move_type": "in_invoice", "partner_id": partner_for(e), "invoice_date": e["posting_date"], "ref": e["references"].get("tax_invoice_number") or e["references"].get("receipt_number"), "invoice_line_ids": [(0, 0, line)], }]) else: # prepaid_card, employee_reimbursement, stock_issue…: a journal entry against the right balance account [move_id] = odoo("account.move", "create", vals_list=[{ **common, "move_type": "entry", "date": e["posting_date"], "ref": f'Fleet {e["references"].get("document_number") or e["id"][:8]}', "line_ids": [ (0, 0, {"name": line["name"], "account_id": line["account_id"], "debit": float(e["amount"]), "analytic_distribution": line["analytic_distribution"]}), (0, 0, {"name": line["name"], "account_id": settlement_account(e["settlement"]), "credit": float(e["amount"])}), ], }]) attach_receipts(e["id"], move_id) return move_id
def attach_receipts(cost_entry_id, move_id): """Copy Fleet's receipts onto the Odoo document (they also show in the Documents app).""" for f in fleet.get(f"/cost_entries/{cost_entry_id}/attachments").json()["data"]: content = requests.get(f["url"], timeout=60).content odoo("ir.attachment", "create", vals_list=[{ "name": f["name"], "res_model": "account.move", "res_id": move_id, "mimetype": f["mime_type"], "datas": base64.b64encode(content).decode(), }])Run it every few minutes, or when a cost_entry.* webhook arrives.
- Exactly once. Add a text field to journal entries with Studio (
x_studio_fleet_cost_entryabove) and fill it with the Fleet cost entry id. A crash between posting and acknowledging only means the entry is offered again;post()finds the move it already created and acknowledges that. - Analytic plans. Since Odoo 17 one
analytic_distributionkey can name an account from several plans ("12,34": 100).ANALYTIC_IDSmaps each Fleet(dimension, code)to the analytic account id, built from the samesearch_readyou used to push the values. - Vehicles as assets. If you track vehicles with Odoo Enterprise’s Assets (
account.asset) or the Fleet app (fleet.vehicle), keep the link as an external reference (object_type: "vehicle") and add the vehicle’s analytic account to the distribution, so cost per vehicle reads the same in both systems.
4. Close periods from Odoo
Section titled “4. Close periods from Odoo”When you lock a period in Odoo, mirror it: PATCH /v1/accounting_settings with
{"closed_through": "2026-10-31"}. Later changes to October costs arrive with a November posting_date.