blog.apifirme.dev/formular-care-se-completeaza-singur-din-cui .md

blog / integrare

Formularul care se completează singur din CUI, fără cheie în browser

Câmpul CUI verifică cifra de control, întreabă serverul tău și completează denumirea, adresa și codul de TVA. La 202, 404 sau eroare, omul scrie mai departe.

· 10 min de citit

Cuprins
  1. De ce prin serverul tău
  2. Serverul: Node fără dependențe
  3. Browserul: validare, așteptare, anulare
  4. Ce vede omul
  5. De încercat

Pe scurt

  • Cheia apifirme stă pe serverul tău. Browserul vorbește doar cu un endpoint al tău, care întoarce câmpurile formularului și nimic altceva.
  • Cifra de control se verifică în browser, cererea pleacă după 400 ms de liniște, iar o căutare veche e anulată când omul scrie alt CUI.
  • Formularul nu se blochează niciodată: 202 înseamnă „mai caut”, 404 un mesaj omenesc, orice eroare înseamnă completare de mână.

La pasul de facturare al unui magazin B2B, clientul are de scris denumirea firmei, numărul de la registrul comerțului, codul de TVA și adresa. Le copiază dintr-un PDF, sare o cifră, iar factura pleacă greșit. Toate se pot afla din CUI, cu condiția ca formularul să întrebe la momentul potrivit și să nu stea în calea nimănui când nu primește răspuns.

De ce prin serverul tău

O cheie af_live_… pusă în JavaScript-ul paginii o poate copia oricine din uneltele browserului și o poate folosi pe cererile contului tău. Așa că browserul nu vorbește cu apifirme, ci cu un endpoint al aplicației tale, /api/firma/:cui, care adaugă cheia, întreabă datele firmei și trimite înapoi doar ce intră în formular. Restul răspunsului (surse, date de verificare, CAEN) nu are ce căuta în pagina de checkout.

Un endpoint public care întreabă un API plătit cu cheia ta e, fără alte măsuri, un proxy gratuit pentru oricine. Două măsuri mici ajung pentru început: o limită pe IP (aici 20 de căutări pe minut) și un cache de 10 minute pentru răspunsurile 200 și 404, care se numără, ca omul care tot corectează formularul să nu consume cereri noi. 202 și erorile nu intră în cache. Dacă formularul e după autentificare, pune și endpointul tot după ea.

Serverul: Node fără dependențe

Un singur fișier: pagina cu formularul, scriptul ei și endpointul. Cheia o iei dintr-un cont gratuit și o pui în APIFIRME_KEY.

server.mjsJavaScript
// server.mjs — formularul și /api/firma/:cui. Node 18+, fără dependențe.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const BASE = process.env.APIFIRME_BASE ?? "https://apifirme.dev/rest/v1";
const KEY = process.env.APIFIRME_KEY; // rămâne pe server, nu pleacă spre browser
const cache = new Map(); // cui -> { expira, cod, corp }
const contor = new Map(); // ip -> { minut, n }

const PAGINA = `<!doctype html>
<meta charset="utf-8"><title>Client nou</title>
<form id="firma">
  <label>CUI <input name="cui" inputmode="numeric" autocomplete="off"></label>
  <p id="cui-stare" aria-live="polite"></p>
  <label>Denumire <input name="denumire" required></label>
  <label>Nr. Reg. Com. <input name="nr_reg_com"></label>
  <label>Cod TVA <input name="cod_tva"></label>
  <label>Adresa <input name="adresa"></label>
  <label>Localitate <input name="localitate"></label>
  <label>Județ <input name="judet"></label>
  <label>Cod poștal <input name="cod_postal"></label>
  <button>Salvează</button>
</form>
<script src="/cui.js"></script>`;

function cuiValid(text) {
  const s = String(text).trim().toUpperCase().replace(/^RO\s*/, "");
  if (!/^[0-9]{2,10}$/.test(s)) return null;
  const corp = s.slice(0, -1).padStart(9, "0");
  const suma = [...corp].reduce((acc, c, i) => acc + c * "753217532"[i], 0);
  return ((suma * 10) % 11) % 10 === Number(s.at(-1)) ? Number(s) : null;
}

function preaMulte(ip) {
  const minut = Math.floor(Date.now() / 60000);
  const c = contor.get(ip);
  if (!c || c.minut !== minut) contor.set(ip, { minut, n: 1 });
  else if (++c.n > 20) return true;
  return false;
}

async function cautaFirma(cui) {
  let r;
  try {
    r = await fetch(`${BASE}/companies/${cui}`, {
      headers: { Authorization: `Bearer ${KEY}` },
      signal: AbortSignal.timeout(3000),
    });
  } catch (e) {
    console.error(`apifirme: ${e.name} ${e.cause?.code ?? ""}`);
    return [502, { status: "eroare" }];
  }
  if (r.status === 200) {
    const f = await r.json();
    // browserul primește doar ce intră în formular
    return [200, { status: "ok", firma: {
      denumire: f.denumire, nr_reg_com: f.nr_reg_com, adresa: f.adresa_completa,
      localitate: f.localitate, judet: f.judet, cod_postal: f.cod_postal,
      cod_tva: f.scp_tva ? `RO${f.cui}` : null,
      inactiva: f.status_inactiv, radiata: f.stare === "RADIERE",
    } }];
  }
  if (r.status === 202) {
    const pauza = Number(r.headers.get("retry-after") ?? 2);
    return [202, { status: "pending", retry_after: pauza }];
  }
  if (r.status === 404) {
    const { type = "" } = await r.json();
    const pf = type.endsWith("/company-not-available");
    return [404, { status: pf ? "persoana_fizica" : "negasita" }];
  }
  console.error(`apifirme: HTTP ${r.status}`); // 401, 429, 5xx: omul completează de mână
  return [502, { status: "eroare" }];
}

async function api(cui, ip) {
  const valid = cuiValid(cui);
  if (valid === null) return [400, { status: "invalid" }];
  const salvat = cache.get(valid);
  if (salvat && salvat.expira > Date.now()) return [salvat.cod, salvat.corp];
  if (preaMulte(ip)) return [429, { status: "prea_multe" }];
  const [cod, corp] = await cautaFirma(valid);
  // 200 și 404 se numără; nu le cerem din nou cât timp omul tot corectează formularul
  if (cod === 200 || cod === 404) {
    cache.set(valid, { expira: Date.now() + 600_000, cod, corp });
  }
  return [cod, corp];
}

createServer(async (req, res) => {
  const { pathname } = new URL(req.url, "http://localhost");
  const m = pathname.match(/^\/api\/firma\/([^/]+)$/);
  let cod = 200, tip = "application/json", corp;
  if (m) {
    // în spatele unui proxy, IP-ul vine din X-Forwarded-For
    [cod, corp] = await api(decodeURIComponent(m[1]), req.socket.remoteAddress);
    corp = JSON.stringify(corp);
  } else if (pathname === "/") {
    [tip, corp] = ["text/html; charset=utf-8", PAGINA];
  } else if (pathname === "/cui.js") {
    tip = "text/javascript";
    corp = await readFile(new URL("cui.js", import.meta.url));
  } else {
    [cod, corp] = [404, "{}"];
  }
  res.writeHead(cod, { "Content-Type": tip, "Cache-Control": "no-store" }).end(corp);
}).listen(Number(process.env.PORT ?? 3000));

Cele două feluri de 404 se despart după type din corpul erorii: company-not-available e o persoană fizică (PFA, II, IF), despre care apifirme nu servește date, company-not-found un CUI neînregistrat. Mesajul pentru om e diferit, de aceea contează. 401, 429 și 5xx devin toate eroare: omului îi e indiferent de ce, el trebuie doar să știe că scrie de mână; motivul rămâne în logul serverului.

Pe un server de test care imită apifirme (firmele „EXEMPLU … SRL” sunt date de exemplu):

$ curl -s localhost:$PORT/api/firma/RO90000057 | jq
{
  "status": "ok",
  "firma": {
    "denumire": "EXEMPLU BRAD MEDICAL SRL",
    "nr_reg_com": "J2012019748238",
    "adresa": "Str. Exemplului nr. 70, Voluntari",
    "localitate": "Voluntari",
    "judet": "IF",
    "cod_postal": "511452",
    "cod_tva": "RO90000057",
    "inactiva": false,
    "radiata": false
  }
}
$ for c in 90000030 90000049 90000058 90000090; do
>   curl -s -w ' %{http_code}\n' localhost:$PORT/api/firma/$c
> done
{"status":"persoana_fizica"} 404
{"status":"negasita"} 404
{"status":"invalid"} 400
{"status":"pending","retry_after":2} 202

Și când apifirme răspunde cu 500, browserul primește doar atât, iar serverul notează motivul:

$ curl -s -w ' %{http_code}\n' localhost:$PORT/api/firma/90000065
{"status":"eroare"} 502

Dacă vrei ca formularul să se completeze și când apifirme nu răspunde, cautaFirma e locul în care intră funcția cu rezervă la openapi.ro din articolul despre fallback, cu câteva ajustări de câmpuri.

Browserul: validare, așteptare, anulare

cui.jsJavaScript
// cui.js — completează formularul din CUI. Fără biblioteci, fără cheie.
const form = document.querySelector("#firma");
const stare = document.querySelector("#cui-stare");
const CAMPURI = ["denumire", "nr_reg_com", "cod_tva", "adresa", "localitate", "judet",
  "cod_postal"];
const scriseDeMana = new Set();
let temporizator, cererea; // cererea = AbortController-ul căutării curente

for (const nume of CAMPURI) {
  form.elements[nume].addEventListener("input", () => scriseDeMana.add(nume));
}

function cuiValid(text) {
  const s = text.trim().toUpperCase().replace(/^RO\s*/, "");
  if (!/^[0-9]{2,10}$/.test(s)) return null;
  const corp = s.slice(0, -1).padStart(9, "0");
  const suma = [...corp].reduce((acc, c, i) => acc + c * "753217532"[i], 0);
  return ((suma * 10) % 11) % 10 === Number(s.at(-1)) ? Number(s) : null;
}

const arata = (text) => { stare.textContent = text; };

form.elements.cui.addEventListener("input", () => {
  clearTimeout(temporizator);
  cererea?.abort();
  cererea = null;
  const cui = cuiValid(form.elements.cui.value);
  arata("");
  if (cui !== null) temporizator = setTimeout(() => cauta(cui, 1), 400);
});

// greșeala o spunem abia când omul pleacă din câmp, nu la fiecare cifră
form.elements.cui.addEventListener("change", () => {
  const text = form.elements.cui.value.trim();
  if (text && cuiValid(text) === null) arata("CUI-ul nu e corect. Verifică cifrele.");
});

async function cauta(cui, incercare) {
  const eu = (cererea = new AbortController());
  const ceas = setTimeout(() => eu.abort(), 8000);
  let raspuns;
  if (incercare === 1) arata("Caut firma…");
  try {
    raspuns = await (await fetch(`/api/firma/${cui}`, { signal: eu.signal })).json();
  } catch {
    raspuns = { status: "eroare" };
  } finally {
    clearTimeout(ceas);
  }
  if (eu !== cererea) return; // între timp s-a scris alt CUI

  switch (raspuns.status) {
    case "ok":
      completeaza(raspuns.firma);
      if (raspuns.firma.radiata) arata("Atenție: firma este radiată.");
      else if (raspuns.firma.inactiva) arata("Atenție: firma este inactivă fiscal.");
      else arata("Am completat datele firmei. Verifică-le înainte să salvezi.");
      break;
    case "pending":
      if (incercare < 3) {
        arata("ANAF răspunde mai greu, mai caut…");
        const pauza = raspuns.retry_after * 1000;
        temporizator = setTimeout(() => cauta(cui, incercare + 1), pauza);
      } else {
        arata("Datele nu au sosit încă. Scrie-le de mână sau încearcă peste un minut.");
      }
      break;
    case "persoana_fizica":
      arata("Pentru PFA, II sau IF datele nu se completează automat. Scrie-le de mână.");
      break;
    case "negasita":
      arata("Nu există o firmă înregistrată cu acest CUI. Verifică cifrele.");
      break;
    default:
      arata("Nu putem completa automat acum. Scrie datele de mână.");
  }
}

function completeaza(firma) {
  for (const nume of CAMPURI) {
    const camp = form.elements[nume];
    if (!scriseDeMana.has(nume) || camp.value === "") {
      camp.value = firma[nume] ?? "";
      scriseDeMana.delete(nume);
    }
  }
}

Patru detalii fac diferența între un câmp util și unul enervant:

  • Cifra de control, local. O greșeală de tastare nu pleacă nicăieri. Mesajul de eroare apare abia la change, când omul iese din câmp, nu la fiecare cifră. Dacă nu vrei să ții algoritmul în două locuri, serverul poate întreba /validate/cui, fără cheie și fără să se numere.
  • 400 ms de liniște înainte de căutare. Fără ei, prefixele trec și ele de verificare: în „90000065”, „9000” are deja cifra de control corectă. Tastând „RO 90000065” caracter cu caracter, la 120 ms distanță, a plecat o singură cerere.
  • Răspunsul vechi nu mai contează. Fiecare căutare are propriul AbortController, iar la final se verifică dacă mai e cea curentă. Am scris un CUI, apoi altul cât primul era încă pe drum: în formular au ajuns datele celui de-al doilea. Anularea din browser nu oprește însă cererea serverului către apifirme; ea se numără, dar ajunge în cache.
  • Ce a scris omul rămâne. Un câmp completat de mână nu e suprascris, cât timp nu e gol. Câmpurile completate automat se schimbă când se schimbă CUI-ul.

Câmpurile nu se dezactivează niciodată, iar mesajul stă într-un element cu aria-live, ca să fie citit și de un cititor de ecran. Firma inactivă sau radiată primește un avertisment, nu o blocare: dacă vinzi sau nu unei asemenea firme e o decizie a afacerii, nu a câmpului CUI.

Ce vede omul

Situație Mesaj Câmpuri
firmă găsită „Am completat datele firmei. Verifică-le înainte să salvezi.” completate
inactivă sau radiată „Atenție: firma este inactivă fiscal.” / „… radiată.” completate
202, firma e căutată la ANAF „ANAF răspunde mai greu, mai caut…”; cel mult trei căutări completate la sosire
PFA, II, IF „Pentru PFA, II sau IF datele nu se completează automat.” libere
CUI neînregistrat „Nu există o firmă înregistrată cu acest CUI.” libere
cifră de control greșită „CUI-ul nu e corect. Verifică cifrele.” libere
eroare, timeout, limită „Nu putem completa automat acum. Scrie datele de mână.” libere

Toate rândurile din tabel le-am trecut prin pagină fără un browser adevărat: jsdom încarcă pagina de pe server, un script scrie în câmpul CUI ca un om și citește mesajele și câmpurile, iar serverul de test e comutat pe 202, 500, conexiune închisă, 429 și răspuns lent. Așa am verificat, de exemplu, că un răspuns lent de 4 s se termină curat în „scrie de mână” după timeout-ul de 3 s al serverului, fără ca formularul să aștepte după el.

De încercat

Pornește serverul local, deschide pagina și scrie CUI-ul de pe o factură primită luna trecută, dar cu ultima cifră schimbată, apoi apasă Tab. Cu altă ultimă cifră, cifra de control nu se mai potrivește niciodată: trebuie să vezi mesajul de eroare, iar în fila Network a browserului nicio cerere către /api/firma. Corectează cifra și uită-te cum apar denumirea și Nr. Reg. Com. Dacă vrei întâi să vezi ce întoarce API-ul pentru un CUI, fără cont, ai consola.

etichete: checkout, CUI, formulare, JavaScript, Node

Încearcă datele pe firmele tale

Scrie un CUI în consolă și vezi exact ce răspunde API-ul, fără cont. Contul gratuit se face cu adresa de e-mail și un cod, fără parolă, și îți dă o cheie pentru cod.

creează cont gratuit consola, fără cont documentația prețuri