Connect Odoo
Let Odoo send its vendor bills' PDFs to Ocost and fill its draft bills from what Ocost read. Odoo keeps the books. Ocost never writes into Odoo: your Odoo asks Ocost and fills its own bills.
The Ocost addon
An Ocost addon for Odoo is coming, for 19.0 first, then 18.0 and 20.0, Community and Enterprise. It will do this:
| Part | What it does |
|---|---|
| Settings › Ocost | Per company: the API key, and when to send (on upload, by button, or off). |
| Sending | PDFs on a draft vendor bill, mailed to a purchase journal or dropped on Vendor Bills. Odoo still reads e-invoices itself. |
| Filling | Partner, number, dates, lines with taxes, due date, and payment terms with Skonto. Only empty fields, and never posted. The bill lists doubts and failed checks. |
| Search | Accounting › Ocost › Search, by what the PDFs say. |
Until then, the module below does the core in plain Odoo code.
What fills what
| Odoo | From ocost.document.v1 |
|---|---|
partner_id | seller, found by its VAT ID and name, or created |
ref | number |
invoice_date | issue_date |
invoice_date_due | due_date |
invoice_origin | order_reference |
invoice_line_ids | lines, each with quantity, price and the purchase tax of its vat_rate |
Without lines read, the module makes one line per VAT rate. It fills drafts and never posts a bill. It notes problems on the bill: sums that don't add up, Ocost's doubts and a possible duplicate. It also notes the Skonto.
Build a small module
Server actions can't import requests. So the calls live in a module, which a server action or a button calls.
ocost_connect/
__init__.py from . import models
__manifest__.py
models/__init__.py from . import account_move
models/account_move.py
data/cron.xmlStore the key in the system parameter ocost.api_key, under Settings › Technical › System Parameters. Only administrators can read it. One Odoo company is one Ocost business. With several companies, use one key each, such as ocost.api_key.<company id>.
__manifest__.py:
{
"name": "Ocost Connect",
"version": "19.0.1.0.0",
"depends": ["account"],
"data": ["data/cron.xml"],
"license": "LGPL-3",
}models/account_move.py:
import base64
import logging
from decimal import Decimal
import requests
from odoo import _, api, fields, models
from odoo.fields import Command
API = "https://api.ocost.io/public/v1"
log = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = "account.move"
ocost_file_id = fields.Char(copy=False, readonly=True)
ocost_state = fields.Selection(
[("sent", "Sent"), ("read", "Read"), ("not_read", "Not read"), ("failed", "Failed")],
copy=False,
readonly=True,
)
def _ocost(self):
key = self.env["ir.config_parameter"].sudo().get_param("ocost.api_key")
session = requests.Session()
session.headers["Authorization"] = f"Bearer {key}"
return session
def action_ocost_send(self):
"""Sends each draft vendor bill's PDF to Ocost (a button, a server
action or the cron). The same PDF twice is the same file: safe."""
session = self._ocost()
bills = self.filtered(
lambda m: m.move_type in ("in_invoice", "in_refund")
and m.state == "draft"
and not m.ocost_file_id
)
for move in bills:
attachment = move.message_main_attachment_id or move.attachment_ids[:1]
if not attachment:
continue
answer = session.post(
f"{API}/files",
files={
"file": (
attachment.name,
base64.b64decode(attachment.datas),
attachment.mimetype,
)
},
data={"external_ref": f"account.move,{move.id}"},
timeout=30,
)
if answer.status_code == 429 or answer.status_code >= 500:
continue # the next run tries again
if answer.status_code == 401:
log.warning("Ocost refused the API key: replace it")
return
if answer.status_code in (413, 415):
move.ocost_state = "failed"
move.message_post(body=answer.json().get("detail"))
continue
answer.raise_for_status()
file = answer.json()
move.write({"ocost_file_id": file["id"], "ocost_state": "sent"})
if file["status"] == "read":
move._ocost_fill(session)
@api.model
def _cron_ocost(self):
"""Every few minutes: send what waits, then fill what Ocost read."""
self.search(
[("move_type", "in", ("in_invoice", "in_refund")), ("state", "=", "draft"),
("ocost_file_id", "=", False), ("message_main_attachment_id", "!=", False)],
limit=20,
).action_ocost_send()
session = self._ocost()
for move in self.search([("ocost_state", "=", "sent")], limit=50):
file = session.get(f"{API}/files/{move.ocost_file_id}", timeout=15).json()
if file["status"] in ("received", "reading"):
continue
if file["status"] == "read":
move._ocost_fill(session)
else:
move.ocost_state = file["status"]
move.message_post(body=_("Ocost didn't read it: %s", file["reason"]))
def _ocost_fill(self, session):
"""Fills the draft bill's empty fields from Ocost's reading."""
self.ensure_one()
doc = session.get(f"{API}/files/{self.ocost_file_id}/document", timeout=15).json()
notes = []
if doc["direction"] == "outgoing" or doc["kind"] not in ("invoice", "credit_note"):
self.ocost_state = "read"
self.message_post(body=_("Ocost read a %s, not a bill to fill.", doc["kind_detail"]))
return
vals = {}
seller = doc["seller"] or {}
contact = seller.get("contact") or {}
vat_sure = doc["sources"].get("seller.vat_id", {}).get("confidence") != "doubt"
if not self.partner_id and seller.get("name"):
partner = self.env["res.partner"]._retrieve_partner(
name=seller["name"],
vat=seller.get("vat_id") if vat_sure else None,
email=contact.get("email"),
company=self.company_id,
)
if not partner:
address = seller.get("address") or {}
country = self.env["res.country"].search(
[("code", "=", address.get("country"))], limit=1
)
partner = self.env["res.partner"].create({
"name": seller["name"],
"is_company": True,
"vat": seller.get("vat_id") if vat_sure else False,
"street": address.get("street"),
"zip": address.get("postal_code"),
"city": address.get("city"),
"country_id": country.id or False,
"email": contact.get("email"),
"phone": contact.get("phone"),
"supplier_rank": 1,
})
vals["partner_id"] = partner.id
if not self.ref and doc["number"]:
vals["ref"] = doc["number"]
if not self.invoice_date and doc["issue_date"]:
vals["invoice_date"] = doc["issue_date"]
if doc["due_date"]:
vals["invoice_date_due"] = doc["due_date"]
if doc["order_reference"] and not self.invoice_origin:
vals["invoice_origin"] = doc["order_reference"]
currency = self.env["res.currency"].search([("name", "=", doc["currency"])], limit=1)
if currency:
vals["currency_id"] = currency.id
if doc["kind"] == "credit_note" and self.move_type == "in_invoice" and not self.invoice_line_ids:
vals["move_type"] = "in_refund"
if not self.invoice_line_ids:
vals["invoice_line_ids"] = [
Command.create(self._ocost_line(line, doc["totals"]["prices_include_vat"], notes))
for line in doc["lines"]
] or [
# No lines read: one line per VAT rate.
Command.create(self._ocost_line(
{"name": doc["number"] or _("Invoice"), "quantity": "1",
"price": group["taxable_amount"], "vat_rate": group["rate"]},
False, notes,
))
for group in doc["vat_breakdown"]
]
self.write(vals)
self.ocost_state = "read"
if not all(c["passed"] for c in doc["checks"] if c["sums"]):
notes.append(_("The sums don't add up as read: check the amounts."))
doubts = [path for path, s in doc["sources"].items() if s["confidence"] == "doubt"]
if doubts:
notes.append(_("Ocost isn't sure of: %s", ", ".join(doubts)))
if doc["possible_duplicate_of"]:
notes.append(_("Ocost read a bill of this number from this seller before."))
if doc["early_payment_discounts"]:
skonto = doc["early_payment_discounts"][0]
notes.append(_("Skonto: %s %% until %s", skonto["percent"], skonto["due_date"]))
self.message_post(body="\n".join([_("Filled from Ocost's reading. Check before posting."), *notes]))
def _ocost_line(self, line, included, notes):
price = Decimal(line.get("price") or line.get("net_amount") or "0")
per = Decimal(line.get("base_quantity") or "1")
values = {
"name": " ".join(filter(None, [
line.get("seller_item_id") and f"[{line['seller_item_id']}]", line["name"],
])),
"quantity": float(Decimal(line.get("quantity") or "1")),
"price_unit": float(price / per),
"discount": float(Decimal(line.get("discount_percent") or "0")),
}
if line.get("vat_rate") is not None:
tax = self.env["account.tax"].search([
("company_id", "=", self.company_id.id),
("type_tax_use", "=", "purchase"),
("amount_type", "=", "percent"),
("amount", "=", float(line["vat_rate"])),
("price_include", "=", included),
], limit=1)
if tax:
values["tax_ids"] = [Command.set(tax.ids)]
else:
notes.append(_("No purchase tax of %s %%: set the line's tax.", line["vat_rate"]))
return valuesdata/cron.xml:
<odoo>
<record id="cron_ocost" model="ir.cron">
<field name="name">Ocost: send and fill</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="state">code</field>
<field name="code">model._cron_ocost()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
</record>
</odoo>Add a server action on vendor bills, Action › Send to Ocost, that runs records.action_ocost_send(). The cron then sends what waits and fills what Ocost read, every five minutes.
Try the module on a copy of your database first. A line's tax_ids may need fiscal_position_id.map_tax. Take reverse charge (AE) and intra-EU (K) taxes from your accountant's table.
Search from Odoo
Add this method to the same model. It finds bills by what their PDFs say.
@api.model
def ocost_search(self, query, kind=None):
"""Bills by what their PDFs say: a list of (bill, page, text)."""
params = {"q": query, "limit": 20}
if kind:
params["kind"] = kind
found = self._ocost().get(f"{API}/search", params=params, timeout=15).json()
out = []
for r in found["results"]:
bill = self.search([("ocost_file_id", "=", r["file_id"])], limit=1)
passage = r["passages"][0] if r["passages"] else {}
out.append((bill, passage.get("page"), passage.get("text")))
return outA result may be a bill that reached Ocost another way. To fetch its PDF, call GET /public/v1/files/{file_id}/content on the Odoo server, so the key never reaches a browser.