Skip to content

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.

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.com
ODOO_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"])

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_read purchase.order and account.move records whose write_date is 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 Authorization header, 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. 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,
    })
  2. 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}},
    ]})
  3. Tell Fleet which ERP handles requests: PATCH /v1/accounting_settings with {"primary_erp_system": "odoo"}.

A fleet manager approves the operational need in Fleet; Odoo handles budget, sourcing and payment.

  1. Subscribe to approvals. Create a webhook endpoint for fleet_request.approved (and fleet_request.cancelled). As a safety net, also poll GET /v1/fleet_requests?status=approved&financial_status=not_sent every few minutes.

  2. Check readiness, then create the Odoo document. GET /v1/fleet_requests/{id}/readiness?system=odoo lists anything Odoo would reject. If ready is false, write an exception event 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"],
    })
    return

    Otherwise create a purchase request or RFQ from the request’s lines. Each line’s cost_category gives the product or account, and the request’s dimensions give the analytic_distribution.

  3. Write each outcome back with an integration_transaction_id that names the Odoo record and state, so retries are harmless:

    In Odoo Fleet event
    Purchase request created / submitted committed (with references.purchase_request)
    PO confirmed po_issued (with references.purchase_order and amounts)
    Vendor bill posted invoiced (with references.bill, amounts.total, amounts.tax, document_date)
    Credit note posted credited
    Bill paid paid
    Refused by finance rejected (with reason)
    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_date after your last run) or from an automation rule (see Before you start). Late or repeated events never move the status backwards.

  4. The bill becomes the cost. Once invoiced arrives, Fleet records the bill in its cost ledger and marks the linked work order’s costs as superseded, 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.

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_entry above) 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_distribution key can name an account from several plans ("12,34": 100). ANALYTIC_IDS maps each Fleet (dimension, code) to the analytic account id, built from the same search_read you 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.

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.