Invoice OCR on GX10: Nemotron, EIK and VIES
We read invoices with OCR on a local AI server of the NVIDIA GB10 class and check them like a careful accountant: are the required fields there, does the EIK pass its checksum, do the amounts add up with 20% VAT, and does the VAT number exist in VIES. Anything doubtful goes to a person — nothing is booked on OCR output alone.
nemotron-ocr:1.0.0 and sent a field language: ["bg","en"] — there is no such field in the documentation, and the first model version (v1) is English-only. Now: Nemotron OCR v2, image nemotron-ocr-v2:2.0, the current request format (/v1/ocr with an input array) and a response with text_detections. We fixed the EIK check: the 13-digit form was always returned as “valid” — now it is checked. Amounts are in euro with exact decimals (Decimal), not in leva with floating-point numbers. The VAT number is checked in VIES with the real address and the real response. We added: a check of the required invoice particulars under article 114 of the VAT Act (quoting the law), the VIES terms, and proper handling of “VIES does not answer”. We removed: unconfirmed figures (the error rate of manual entry), n8n flows and schedules that were not verified, and the overlap with 04-131.
01What you will learn
- Which NVIDIA model reads documents, what it says about its languages and licence, and where the verified part ends.
- How to start the OCR service (NIM) on a GB10-class machine and send it an invoice.
- What article 114 of the Bulgarian VAT Act requires an invoice to contain — and how to check that automatically.
- How the EIK checksum works and why it does not prove the company exists.
- How to ask VIES about a VAT number — and what the terms of the service are.
- How to decide what passes automatically and what goes to a person.
02Before you start
- A machine of the NVIDIA GB10 class (for example ASUS Ascent GX10 or DGX Spark) with Docker and the NVIDIA Container Toolkit — the preparation is in the lesson n8n on GX10.
- A free NVIDIA NGC account and an API key from it — Docker uses it to pull the image. The key is a secret: never put it in a file you upload somewhere.
- The container licence accepted on NGC (the
nemotron-ocr-v2page asks for “Accept Terms”). - Python 3 with the
requestspackage (pip install requests). - A few of your own invoices to try — or the made-up one from this lesson. Do not upload other people's invoices unless you have the right to.
100000019 and “Obrazets” Ltd with EIK 100000040. We generated them with the checksum algorithm so they pass the check — that does not mean they cannot coincide with a real number by chance, so do not use them anywhere but here.03Steps
-
The task
An example scenario (not a measurement): an accounting firm receives 200 invoices a month and types them in by hand, about 4 minutes each — 800 minutes, i.e. about 13 hours. The idea is for the computer to read the invoice and for a person to look only at those where something does not add up. That is why the lesson is about the checks as much as about the reading itself: OCR makes mistakes, and a wrong amount in the books costs more than lost time.
-
Which model, and what is verified about it
NVIDIA has a Nemotron OCR family. The facts from the model cards (Hugging Face and build.nvidia.com) and the NIM documentation as of 01.10.2026:
What Value Name nvidia/nemotron-ocr-v2(the oldernemotron-ocr-v1is English-only per its card)What it does Finds text regions, recognises them and works out the reading order (detector + recogniser + relational model); returns text, boxes and a confidence from 0 to 1 Variants in v2 v2_englishandv2_multilingual(the default in the NIM)Languages (multilingual) English, Chinese (Simplified and Traditional), Japanese, Korean, Russian (the card text). The card's tags also list Spanish, French, German, Italian, Dutch and Portuguese. Bulgarian is not listed anywhere. Licence build.nvidia.com and the Hugging Face card: NVIDIA Open Model License Agreement (post-processing scripts — Apache 2.0). The NIM documentation names other texts for the container (see the note below) Hardware for the NIM The NIM v2 support table includes NVIDIA GB10 (such as DGX Spark), FP16 Released in v2 15.04.2026 (build.nvidia.com) ⚠️Bulgarian: unverifiedCyrillic is not excluded — Russian is on the list and the model's character set is large — but NVIDIA promises nothing for Bulgarian. We claim neither “it works” nor “it does not”: that is why the “Check” section includes a step to measure on your own invoices. Be especially careful with digits, EIK and amounts — one wrong character spoils everything.✅The licence: read it before commercial useThe sources do not agree. The model card says “NVIDIA Open Model License Agreement” and “ready for commercial use”. The NIM documentation's terms page names, for the container, the NVIDIA Software License Agreement and Product Specific Terms for AI Product, and for the model the AI Foundation Models Community License Agreement. We do not interpret this for you: open the current texts (links in “Sources”) and give them to a lawyer if you will sell a service. -
Start the OCR service (NIM)
A NIM is a ready-made container: it downloads the model and opens a small web interface (an API). Follow the official documentation; here is the essence. The key goes into an environment variable, not into the command:
bash · on the machineexport NGC_API_KEY=<your-NGC-key> echo "$NGC_API_KEY" | docker login nvcr.io --username '$oauthtoken' --password-stdinThe word
$oauthtokenis literally the user name (that is how NVIDIA's documentation has it — it means “I log in with a key”). Then:bash · on the machineexport LOCAL_NIM_CACHE=~/.cache/nim mkdir -p "$LOCAL_NIM_CACHE/cache" "$LOCAL_NIM_CACHE/weights" docker run -it --rm --name nemotron-ocr-v2 \ --runtime=nvidia \ --gpus '"device=0"' \ --shm-size=16GB \ -e NGC_API_KEY \ -e NIM_ENGINE_MODEL_DOWNLOAD_PROVIDER=ngc \ -v "$LOCAL_NIM_CACHE/cache:/opt/cache" \ -v "$LOCAL_NIM_CACHE/weights:/model" \ -u $(id -u) \ -p 8000:8000 \ nvcr.io/nim/nvidia/nemotron-ocr-v2:2.0In another terminal, check that it is ready (the first download takes a while):
bashcurl http://localhost:8000/v1/health/readyYou expect
{"object":"health.response","message":"ready","ready":true}. The variant is chosen with-e NIM_ENGINE_MODEL_VARIANT=english(the default ismultilingual). Port8000is from the documentation; keep it reachable only on the machine itself if the service will not be called from outside.💡Why the container, not pipThe model card on Hugging Face describes an installation for Linux amd64 and data-centre GPUs. On a GB10 (Arm) the supported path is the NIM container — it is explicitly in the support table. ⚠️ What exactlynvidia-smiwill show and how it runs on your machine we have not measured. -
Send an invoice and get text
The request format is from the NIM v2 documentation: an
inputarray with images as a base64data:address and a merge level for the text (paragraphis the default). For each piece of text the response gives a confidence — we will use it.python · invoice_check.py (1/3)import base64, re from decimal import Decimal, ROUND_HALF_UP from datetime import datetime NIM = "http://localhost:8000" def ocr_page(path: str): """Returns (text, mean confidence) for one JPEG or PNG page.""" import requests mime = "image/png" if path.lower().endswith(".png") else "image/jpeg" with open(path, "rb") as f: b64 = base64.b64encode(f.read()).decode() body = {"input": [{"type": "image_url", "url": f"data:{mime};base64,{b64}"}], "merge_levels": ["paragraph"]} r = requests.post(f"{NIM}/v1/ocr", json=body, timeout=60) r.raise_for_status() det = r.json()["data"][0]["text_detections"] lines = [d["text_prediction"]["text"] for d in det] confs = [d["text_prediction"]["confidence"] for d in det] return "\n".join(lines), (sum(confs) / len(confs) if confs else 0.0)Notes: a PDF is first turned into images (one per page); for faster reading the documentation advises JPEG. ⚠️ The order of the pieces is whatever the service returns — check on your own invoices that the reading order is sensible.
-
What an invoice must contain (article 114, VAT Act)
Before extracting, we know what we are looking for. The quotation is from the consolidated text of the Bulgarian Value Added Tax Act (article 114(1) — in force from 01.01.2026), as published on lex.bg (our translation of the Bulgarian text):
No. The invoice must contain Can OCR “catch” it? 1 name of the document yes (“Invoice”) 2 a sequential ten-digit number containing only Arabic digits, … which identifies the invoice uniquely yes — 10 digits 3 date of issue yes 4 name and address of the supplier partly (free text) 5 the supplier's identification number under art. 94(2) … or the number under art. 84 of the Tax and Social Security Procedure Code, where the supplier is not VAT-registered yes — VAT number or EIK 7, 8 name and address of the recipient; the recipient's identification number (rules vary by case) partly 9 quantity and kind of the goods, kind of the service free text 10 the date of the taxable event, or of the payment received often not shown separately 11 the unit price excluding tax and the taxable base of the supply, and any discounts granted … yes — taxable base 12 the tax rate (for a zero rate — the basis; the basis for not charging tax) yes 13 the amount of the tax yes 14 the amount payable, if it differs from the taxable base plus the tax yes We left out item 6 (repealed) and item 15 (new vehicles). Two important points from the same article: under paragraph 7 the invoice may omit items 12, 14 and 15 when the person is in a special small-business scheme, or when the sum of the taxable base and the tax does not exceed 100 euro (except supplies located in another member state, intra-community supplies and distance sales). Under paragraph 5 amounts may be in any currency provided the taxable base and the tax are shown in euro. The law is long and changes — this is not legal advice.
💡Why this matters for the programA missing “required” particular does not necessarily mean OCR erred — the invoice may genuinely be incomplete. In both cases a person must see it. That is why the program below does not “fix” anything but returns reasons for review. -
From text to fields
This is the most fragile part: invoices differ. The regular expressions below are for the made-up invoice in the lesson — your invoices will need their own. Start simple and add rules only when you see an error on a real invoice.
python · invoice_check.py (2/3)def money(txt: str) -> Decimal: """'1 234,56' -> Decimal('1234.56').""" return Decimal(txt.replace(" ", "").replace(" ", "").replace(",", ".")) def iso_date(txt: str): for fmt in ("%d.%m.%Y", "%Y-%m-%d"): try: return datetime.strptime(txt, fmt).date().isoformat() except ValueError: pass return None PATTERNS = { "number": r"(?:Фактура|ФАКТУРА)\s*(?:№|No\.?)?\s*(\d{10})\b", "date": r"Дата(?: на издаване)?\s*:?\s*(\d{2}\.\d{2}\.\d{4})", "supplier_eik": r"Доставчик[\s\S]{0,200}?ЕИК\s*:?\s*(\d{9}(?:\d{4})?)", "buyer_eik": r"Получател[\s\S]{0,200}?ЕИК\s*:?\s*(\d{9}(?:\d{4})?)", "supplier_vat": r"Доставчик[\s\S]{0,300}?ИН по ЗДДС\s*:?\s*(BG\d{9,10})", "net": r"Данъчна основа\s*:?\s*([\d\s,]+)\s*EUR", "vat": r"ДДС\s*20\s*%\s*:?\s*([\d\s,]+)\s*EUR", "total": r"(?:Сума за плащане|Общо)\s*:?\s*([\d\s,]+)\s*EUR", } def parse(text: str) -> dict: out = {} for k, p in PATTERNS.items(): m = re.search(p, text) out[k] = m.group(1).strip() if m else None return outThe patterns match Bulgarian words on purpose — they read a Bulgarian invoice (“Доставчик” = supplier, “Получател” = recipient, “Данъчна основа” = taxable base, “ИН по ЗДДС” = VAT identification number). The number of digits in the VAT number (
9or10) is our choice: the law (art. 94(2)) says only that the number begins with “BG”, not how many digits it has. ⚠️ A ten-digit number is often the personal ID of an individual — see “Personal data” below. -
EIK: the checksum
An EIK has a last digit calculated from the preceding ones. If OCR misreads a digit, the checksum will almost surely fail — so we catch a wrong reading without the internet. The algorithm for 9 digits: digits 1 to 8 are multiplied by 1, 2, … 8 and summed; the remainder on division by 11 is the check digit. If the remainder is 10, repeat with weights 3, 4, … 10; if it is 10 again, the check digit is 0.
13 digits. The official text — Ordinance No 1 of 14.02.2007 on the commercial register, art. 114(3) and (4) (published on the Registry Agency website) — says only that the EIK has 9 digits and that the EIK of a branch is a 4-digit code appended to the 9-digit one. It gives no check-digit rule for the last 4 digits. So for 13 digits the program checks only the first 9 and sends the invoice to a person. The rule with weights 2, 7, 3, 5 (on remainder 10 — 4, 9, 5, 7) found in various programs is shown for reference only — it is not confirmed by an official source and plays no part in the decision.
python · invoice_check.py (3/3)def eik_check_digit9(first8: str) -> int: s = sum(int(d) * w for d, w in zip(first8, range(1, 9))) % 11 if s == 10: s = sum(int(d) * w for d, w in zip(first8, range(3, 11))) % 11 return 0 if s == 10 else s # For reference only: UNOFFICIAL rule for a 13-digit EIK (branch). # Not in Ordinance No 1/2007 and not used in validate_eik(). def eik_check_digit13(first12: str) -> int: last4 = first12[8:12] s = sum(int(d) * w for d, w in zip(last4, (2, 7, 3, 5))) % 11 if s == 10: s = sum(int(d) * w for d, w in zip(last4, (4, 9, 5, 7))) % 11 return 0 if s == 10 else s def validate_eik(eik: str) -> bool: """9 digits: full check. 13 digits (branch): only the first 9 are checked — there is no official rule for the last 4.""" if not (eik.isascii() and eik.isdigit()) or len(eik) not in (9, 13): return False return int(eik[8]) == eik_check_digit9(eik[:8])✅Tested in a sandbox · 01.10.2026We ran the code against: three public EIKs that VIES confirmed as valid (all passed), three numbers VIES rejected (two of them a valid one with the last digit changed; all failed), and 20,000 random 9-digit numbers — the result matched the Python librarypython-stdnumin all 20,000 (including the second-weights case). The made-up EIKs100000019and100000040pass;100000086goes through the second set of weights. The 13-digit form (review 01.10.2026): the official Ordinance No 1/2007 (art. 114(3)–(4)) has no check digit for the branch code, andpython-stdnum(version 2.2) checks only 9- and 10-digit numbers. So the code above checks only the first 9 digits of a 13-digit EIK, andreview_reasonssends the invoice to a person.⚠️What the checksum does NOT proveAn EIK that passes its checksum is only well-formed — it does not mean the company exists or is VAT-registered. And the other way round: “invalid” in VIES does not mean “fake”, most often it means “not VAT-registered”. For example, our made-up numbers are “invalid” in VIES yet pass the checksum. -
Amounts: base, 20% VAT and total in euro
The standard rate is 20% (art. 66(1) of the VAT Act). The check is simple but works only for invoices with one rate — with mixed rates or a zero rate we skip it and send the invoice to a person. We use
Decimal, notfloat: with ordinary floating-point numbers money is rounded inaccurately.python · additiondef check_amounts(net: Decimal, vat: Decimal, total: Decimal): cent = Decimal("0.01") exp_vat = (net * Decimal("0.20")).quantize(cent, ROUND_HALF_UP) if abs(vat - exp_vat) > cent: return False, f"ДДС {vat} != 20% от {net} = {exp_vat}" if abs(total - (net + vat)) > cent: return False, f"Общо {total} != {net} + {vat}" return True, "OK"The tolerance of one euro cent is our choice for line rounding. Invoices in another currency (art. 114(5)) need the base and tax in euro — the program sends them to a person until you add a rule. The same goes for reverse charge: under art. 114(4), when the tax is payable by the recipient, the invoice shows neither the tax amount nor the rate, and states "reverse charge" with the legal basis — the 20% check will send such an invoice for review, which is correct. (The messages in the code are in Bulgarian because the invoices are.)
-
The VAT number in VIES
VIES is the European Commission's service for confirming VAT numbers. There are two ways; we checked both on 01.10.2026:
Way Address What it returns REST (simpler) GET https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{country}/vat/{number}JSON: isValid,userError(e.g.VALID/INVALID),name,addressWhich countries answer GET …/vies/rest-api/check-statusfor each country “Available” or “Unavailable” SOAP description: …/vies/checkVatService.wsdloperations checkVatandcheckVatApprox(with name and address matching and a confirmation number)For Bulgaria the country is
BGand the number is given without the prefix. Example:…/ms/BG/vat/100000019.python · additiondef vies_check(country: str, number: str): """True / False, or None if VIES cannot answer.""" import requests url = f"https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{country}/vat/{number}" try: r = requests.get(url, timeout=10) r.raise_for_status() return r.json().get("isValid") except Exception: return None # we do not know -> a person, not "invalid"The terms of the service are in the description of the official SOAP file, and this is what they say (in substance): the purpose is for persons making intra-community supplies to confirm the validity of a VAT number; any other use, extraction of data and retransmission are forbidden; the Commission accepts no responsibility for the data, which comes from the member states' databases; a “valid” answer does not in itself give a right to exempt a supply from VAT; under load the service returns errors such as
MS_UNAVAILABLE,MS_MAX_CONCURRENT_REQ,GLOBAL_MAX_CONCURRENT_REQorTIMEOUT— try again later. So: ask only when you need to, remember the answer, do not send bulk requests.🌐The data leaves your machineUnlike OCR, here the VAT number goes to a European Commission server, and the response carries the company's name and address. That is normal for companies, but for individuals (a personal ID as the number) it is personal data — do not query it and do not keep it without a basis. ⚠️ We have not asked a lawyer; do so if you will run the service for clients. The REST interface was checked with a live request; VIES's technical-terms page is dynamic and we did not read it, so the terms above are from the official WSDL. -
Decision: pass, review, reject
We tie everything together: for each invoice we collect reasons for review. An empty list does not mean “book it” — it means “may be ready for a person to approve”.
python · additiondef review_reasons(f: dict, mean_conf: float, min_conf: float = 0.90): why = [] for k in ("number", "date", "supplier_eik", "net", "vat", "total"): if not f.get(k): why.append(f"липсва: {k}") if f.get("supplier_eik") and not validate_eik(f["supplier_eik"]): why.append("ЕИК с грешна контролна сума") if f.get("supplier_eik") and len(f["supplier_eik"]) == 13: why.append("ЕИК на клон: последните 4 цифри не са проверени") if f.get("date") and not iso_date(f["date"]): why.append("невалидна дата") if f.get("net") and f.get("vat") and f.get("total"): ok, msg = check_amounts(money(f["net"]), money(f["vat"]), money(f["total"])) if not ok: why.append(msg) if mean_conf < min_conf: why.append(f"ниска увереност {mean_conf:.2f}") return whyThe threshold
0.90is a starting value for learning, not a proven limit: tune it to the errors you see on your own invoices. VIES is called separately (it needs the internet) and only for a supplier with a VAT number.Try it with the made-up invoice — the text OCR should return:
python · trialtext = """ФАКТУРА № 0000000042 Дата на издаване: 15.09.2026 Доставчик: Пример ООД (фиктивна фирма) ЕИК 100000019 ИН по ЗДДС: BG100000019 гр. Варна, ул. Примерна 1 Получател: Образец ЕООД (фиктивна фирма) ЕИК 100000040 Описание: Консултантски услуги, 10 часа Данъчна основа: 1 000,00 EUR ДДС 20 %: 200,00 EUR Сума за плащане: 1 200,00 EUR """ f = parse(text) print(f) print(review_reasons(f, 0.97)) # [] print(review_reasons(parse(text.replace("200,00", "210,00", 1)), 0.97)) print(review_reasons(parse(text.replace("ЕИК 100000019", "ЕИК 100000018")), 0.97))You expect: the fields recognised; an empty list;
['ДДС 210.00 != 20% от 1000.00 = 200.00'];['ЕИК с грешна контролна сума']. That is exactly what we got in the sandbox test. -
Storage and next steps (briefly)
An invoice fit for review is stored in a database — amounts as
NUMERIC, currency defaulting to euro, plus the review reason and the whole OCR response for later audit. A minimal example (⚠️ not run in Postgres):sql · PostgreSQLCREATE TABLE invoices ( id BIGSERIAL PRIMARY KEY, received_at TIMESTAMPTZ DEFAULT NOW(), invoice_number VARCHAR(10), invoice_date DATE, supplier_eik VARCHAR(13), buyer_eik VARCHAR(13), amount_net NUMERIC(12,2), vat_amount NUMERIC(12,2), amount_total NUMERIC(12,2), currency CHAR(3) DEFAULT 'EUR', ocr_confidence NUMERIC(4,3), status VARCHAR(16) DEFAULT 'pending', -- pending | approved | rejected review_reason TEXT, raw_ocr_json JSONB, UNIQUE (supplier_eik, invoice_number, invoice_date) );The automation stack (n8n, Postgres) is in the lesson n8n on GX10. Expense types and categories are a separate topic — we do not touch them here; see the lesson Chart of Accounts Expenses. If you are unsure how to record an accounting event, ask your accountant: the lesson does not replace accounting or legal advice.
-
Personal data and security — the minimum
- Invoices contain names, addresses, sometimes personal ID numbers and bank accounts. The OCR runs locally — that is the advantage over a cloud OCR.
- The NGC key lives in an environment variable, not in a file in Git.
- Keep copies of invoices and OCR results in a protected place; share only what is needed.
- Only companies' VAT numbers go to VIES, and only when needed.
- A person approves every booking.
04Check
curl http://localhost:8000/v1/health/readyreturnsready.- The made-up invoice goes through
ocr_pageand returns text (if you use an image), andparse+review_reasonsgive an empty list. - A changed digit in the EIK and a changed amount — both are caught.
- Measuring Bulgarian: take at least 20 of your own invoices, run them through OCR and note for each field whether it was read correctly. Collect the kinds of errors (digits, dates, Cyrillic, stamps).
- VIES returns
True/False, and when unavailable —None, which leads to review. - Nothing is booked without a person.
Quiz
1. What does the model card say about the languages of Nemotron OCR v1?
2. What does a valid EIK checksum prove?
3. VIES returns “invalid” for a VAT number. What is the most likely meaning?
4. According to article 114(1) of the VAT Act, what form does an invoice number have?
05What next
Related lessons: Chart of Accounts Expenses · Bank Reconciliation: Movements to Invoices · Annual Accounts from Postgres to Excel · for reading the text itself — Tesseract, EasyOCR, PaddleOCR. See also: lesson 04-131 in the series, which covers the same topic — invoice OCR — with another approach (TrOCR and a language model).
06Sources
- Nemotron OCR v2: model card · v1 🔒 local — languages, licence, hardware.
- build.nvidia.com: nemotron-ocr-v1 — terms of use.
- NIM for OCR: support matrix · get started · API · release notes · terms — GB10, image, request format.
- VIES of the European Commission · REST: availability · SOAP description with the terms 🌐 global.
- Value Added Tax Act (lex.bg, in Bulgarian) — articles 66, 94, 114 (consolidated text, read on 01.10.2026).
- python-stdnum — the independent library we used to cross-check the EIK check.
- Ordinance No 1 of 14.02.2007 (Registry Agency, in Bulgarian) — art. 114(3)–(4): the EIK has 9 digits, a branch EIK appends a 4-digit code (read on 01.10.2026).