Pe scurt
- Înainte să dai unui client nou 30 sau 60 de zile de plată, citește-i ultimele două bilanțuri și starea fiscală: două cereri, câteva secunde.
- Câteva semnale simple (capitaluri proprii negative, datorii mari față de active, pierdere, cifră de afaceri în scădere) folosesc mai mult decât un „scor” pe care nu-l poți explica.
- Programul din articol, în PHP 8.3 cu cURL și fără framework, întoarce verdictul împreună cu motivele, iar pragurile sunt numite și ușor de schimbat.
Un client nou cere 60 de zile de plată la prima comandă. Vânzările vor să spună da, contabilitatea întreabă pe ce bază, iar răspunsul cinstit e de obicei „pare o firmă serioasă”. Situațiile financiare publicate de Ministerul Finanțelor nu sunt un rating, dar sunt cifrele depuse chiar de firmă, și le poți citi automat înainte să semnezi.
Ce spune un bilanț și ce nu spune
Cererea /companies/{cui}/financials întoarce toate situațiile financiare anuale ale firmei, cel mai recent an întâi, cu câmpurile din structura Statement: cifra de afaceri, datoriile, capitalurile, activele, profitul, pierderea, numărul mediu de salariați, toate în lei întregi. Ultimul an al unei firme, filtrat cu jq:
$ curl -s -H "Authorization: Bearer $APIFIRME_KEY" \
"$APIFIRME_BASE/companies/90000073/financials" \
| jq '.financials[0] | {an, tip_raportare, cifra_afaceri, profit_net,
pierdere_neta, datorii, capitaluri_total, cheltuieli_avans}'
{
"an": 2025,
"tip_raportare": "UU",
"cifra_afaceri": 257897,
"profit_net": null,
"pierdere_neta": 46421,
"datorii": 361055,
"capitaluri_total": -90263,
"cheltuieli_avans": null
}
Ieșirea vine de pe un server de test cu firme fictive (date de exemplu); în aplicația ta, APIFIRME_BASE este https://apifirme.dev/rest/v1. Trei lucruri se văd deja aici:
- Profitul și pierderea sunt câmpuri separate. Nu există un singur „rezultat” cu semn. O firmă pe pierdere are
pierdere_netapozitivă, iarprofit_nete de obicei 0 (ministerul le publică pe amândouă), uneori gol, ca în exemplul de aici. Verifici întâi pierderea, apoi profitul. nullnu e zero.cheltuieli_avans: nullînseamnă că ministerul nu a publicat valoarea, nu că firma nu are cheltuieli în avans. Un program care pune?? 0peste tot ajunge să calculeze indicatori din cifre pe care nu le-a văzut nimeni.- Capitalurile pot fi negative. Minus 90.263 de lei înseamnă că pierderile adunate în timp au mâncat capitalul social și încă ceva: firma datorează mai mult decât are.
Și ce nu spune: bilanțul e anual și vine târziu. Situațiile anului N se depun în anul N+1, iar ministerul le publică o dată pe an, cu actualizări, așa că în octombrie te uiți, în cel mai bun caz, la decembrie anul trecut. O firmă înființată anul acesta nu are încă niciun bilanț. Iar tip_raportare: "UU", bilanțul prescurtat pe care îl depun cele mai multe firme mici, are mai puține rânduri decât cel al unei firme mari. Pentru întrebarea ta (o să plătească la 60 de zile?), bilanțul e un indiciu, nu o garanție; un istoric de plăți cu clientul, dacă îl ai, spune mai mult.
Regula, înainte de cod
Înainte să scrii PHP, scrie regula în cuvinte, ca s-o poată citi și cineva din vânzări. Cea de mai jos e un exemplu de politică internă, nu o normă contabilă sau juridică; pragurile sunt rotunde tocmai ca să fie ușor de discutat și de mutat.
| Semnal | Prag (exemplu) | Efect |
|---|---|---|
Starea nu este FUNCTIUNE (insolvență, dizolvare, radiere…) |
– | avans |
| Firma e inactivă fiscal | – | avans |
| Capitaluri proprii negative în ultimul bilanț | sub 0 | avans |
| Datorii raportate la activele imobilizate plus cele circulante | peste 0,80 | termen redus |
| Pierdere netă în ultimul an | peste 0 | termen redus |
| Cifra de afaceri față de anul dinainte | scădere peste 25 % | termen redus |
| Niciun bilanț, sau ultimul e cu mai mult de 2 ani în urmă | – | termen redus |
| Persoană fizică sau verificare imposibilă acum | – | decide un om |
Verdictul este cel mai sever nivel atins: standard (60 de zile), redus (15 zile) sau avans (plata înainte de livrare). Programul întoarce și toate motivele, chiar și pe cele care nu schimbă nimic, ca omul care aprobă o excepție să vadă de ce. Nu există niciun scor de la 0 la 100: un scor adună lucruri care nu se adună (o pierdere mică și o insolvență nu sunt câte „30 de puncte”) și nu-l poți explica clientului care te întreabă de ce i-ai cerut avans.
Programul: PHP 8.3, cURL, fără framework
Două cereri pe firmă: /companies/{cui} pentru stare și /companies/{cui}/financials pentru bilanțuri. Cheia vine din APIFIRME_KEY, adresa din APIFIRME_BASE, iar pragurile stau toate în constanta POLITICA.
<?php
declare(strict_types=1);
// Politica ta de credit: alegeri interne, nu norme contabile. Schimbă-le.
const POLITICA = [
'termen_standard_zile' => 60,
'termen_redus_zile' => 15,
'datorii_pe_active_max' => 0.80,
'scadere_ca_max' => 0.25, // scădere a cifrei de afaceri față de anul dinainte
'vechime_bilant_max' => 2, // ani între anul curent și ultimul bilanț
];
const NIVELURI = ['standard' => 0, 'redus' => 1, 'avans' => 2];
function apifirme(string $path): array
{
$base = getenv('APIFIRME_BASE') ?: 'https://apifirme.dev/rest/v1';
for ($incercare = 1; $incercare <= 3; $incercare++) {
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('APIFIRME_KEY'),
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
return ['status' => 0, 'body' => null, 'error' => $err];
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hsize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$body = json_decode(substr($raw, $hsize), true);
if ($status !== 202) {
return ['status' => $status, 'body' => $body, 'error' => null];
}
// 202: firma e căutată acum la ANAF; nu se numără, așteptăm cât ni se spune
preg_match('/^retry-after:\s*(\d+)/im', substr($raw, 0, $hsize), $m);
sleep(min((int) ($m[1] ?? 2), 5));
}
return ['status' => 202, 'body' => null, 'error' => 'încă în căutare la ANAF'];
}
function lei(?int $v): string
{
return $v === null ? 'nepublicat' : number_format($v, 0, ',', '.') . ' lei';
}
function rezultat(string $cui, ?string $denumire, string $verdict, array $motive): array
{
$termen = ['standard' => POLITICA['termen_standard_zile'],
'redus' => POLITICA['termen_redus_zile']][$verdict] ?? 0;
return ['cui' => $cui, 'denumire' => $denumire, 'verdict' => $verdict,
'termen_zile' => $termen, 'motive' => $motive];
}
function evalueaza(string $cui): array
{
$motive = [];
$adauga = function (string $nivel, string $text) use (&$motive): void {
$motive[] = [$nivel, $text];
};
$r = apifirme('/companies/' . rawurlencode($cui));
if ($r['status'] !== 200) {
$adauga('manual', match (basename((string) ($r['body']['type'] ?? ''))) {
'company-not-available' => 'persoană fizică (PFA, II, IF): nu avem date',
'company-not-found' => 'CUI neînregistrat; verifică ce ți-a dat clientul',
default => 'neverificat: ' . ($r['error'] ?? 'HTTP ' . $r['status']),
});
return rezultat($cui, null, 'manual', $motive);
}
$firma = $r['body'];
if ($firma['stare'] !== 'FUNCTIUNE') {
$adauga('avans', 'stare în registru: ' . $firma['stare']);
}
if ($firma['status_inactiv']) {
$adauga('avans', 'inactivă fiscal din ' . $firma['data_inactivare']);
}
$f = apifirme('/companies/' . rawurlencode($cui) . '/financials');
$bilanturi = $f['status'] === 200 ? $f['body']['financials'] : null;
if ($bilanturi === null) {
$adauga('redus', 'bilanțurile nu s-au putut citi (HTTP ' . $f['status'] . ')');
} elseif ($bilanturi === []) {
$data = $firma['data_inmatriculare'] ?? $firma['data_inregistrare'] ?? '';
$an = (int) substr($data, 0, 4);
$adauga('redus', $an >= (int) date('Y') - 1
? "firmă înființată în $an: nu are încă bilanț publicat"
: "niciun bilanț publicat, deși firma e din $an");
} else {
evalueaza_bilant($bilanturi, $adauga);
}
$verdict = 'standard';
foreach ($motive as [$nivel]) {
if (isset(NIVELURI[$nivel]) && NIVELURI[$nivel] > NIVELURI[$verdict]) {
$verdict = $nivel;
}
}
return rezultat($cui, $firma['denumire'], $verdict, $motive);
}
function evalueaza_bilant(array $bilanturi, callable $adauga): void
{
[$ultim, $an] = [$bilanturi[0], $bilanturi[0]['an']];
$anterior = ($bilanturi[1]['an'] ?? null) === $an - 1 ? $bilanturi[1] : null;
if ((int) date('Y') - $an > POLITICA['vechime_bilant_max']) {
$adauga('redus', "ultimul bilanț publicat e din $an");
}
if ($anterior === null) {
$adauga('info', 'nu există bilanț pentru ' . ($an - 1) . ', nicio comparație');
}
$capitaluri = $ultim['capitaluri_total'];
if ($capitaluri !== null && $capitaluri < 0) {
$adauga('avans', "capitaluri proprii negative în $an: " . lei($capitaluri));
}
$imob = $ultim['active_imobilizate'];
$circ = $ultim['active_circulante'];
if ($imob === null || $circ === null || $imob + $circ === 0
|| $ultim['datorii'] === null) {
$adauga('info', "datorii/active $an: nu se poate calcula (valori nepublicate)");
} else {
$grad = $ultim['datorii'] / ($imob + $circ);
$prag = POLITICA['datorii_pe_active_max'];
$text = sprintf('datorii/active %d: %s (pragul tău: %s)', $an,
number_format($grad, 2, ',', ''), number_format($prag, 2, ',', ''));
$adauga($grad > $prag ? 'redus' : 'info', $text);
}
// profitul și pierderea sunt câmpuri separate; null = nepublicat, nu zero
$pierdere = $ultim['pierdere_neta'];
if ($pierdere !== null && $pierdere > 0) {
$repetat = ($anterior['pierdere_neta'] ?? 0) > 0 ? ', al doilea an la rând' : '';
$adauga('redus', "pierdere netă în $an: " . lei($pierdere) . $repetat);
} elseif ($ultim['profit_net'] !== null) {
$adauga('info', "profit net în $an: " . lei($ultim['profit_net']));
} else {
$adauga('info', "rezultatul net din $an nu e publicat");
}
$ca = $ultim['cifra_afaceri'];
$caAnterior = $anterior['cifra_afaceri'] ?? null;
if ($ca !== null && $caAnterior) {
$variatie = ($ca - $caAnterior) / $caAnterior;
$text = sprintf('cifra de afaceri %d: %s (%+.0f %% față de %d)',
$an, lei($ca), $variatie * 100, $an - 1);
$adauga(-$variatie > POLITICA['scadere_ca_max'] ? 'redus' : 'info', $text);
}
}
foreach (array_slice($argv, 1) as $cui) {
$e = evalueaza($cui);
printf("%s %s\n verdict: %s, termen %d zile\n",
$e['cui'], $e['denumire'] ?? '-', $e['verdict'], $e['termen_zile']);
foreach ($e['motive'] as [$nivel, $text]) {
printf(" - [%s] %s\n", $nivel, $text);
}
}
Câteva alegeri care merită spuse:
- Timeout la fiecare cerere: 3 secunde pentru conectare, 8 în total. Dacă API-ul nu răspunde, verdictul e manual, nu standard. O verificare care nu s-a făcut nu e o verificare trecută.
- Se repetă doar
202. O firmă pe care apifirme nu o are încă e căutată pe loc la ANAF; dacă răspunsul întârzie, vine202cu antetulRetry-After, iar programul așteaptă și întreabă din nou, de cel mult trei ori. Un404nu se repetă: e fie o persoană fizică (company-not-available), fie un CUI neînregistrat (company-not-found), iar câmpultypedin corpul erorii spune care. - Anul anterior se folosește doar dacă e chiar anul anterior. Dacă lipsește 2024, comparația cu 2023 ar acoperi doi ani; programul spune că nu are cu ce compara.
- Un indicator cu o valoare
nullnu se calculează. Firma nu e penalizată pentru o cifră pe care ministerul nu a publicat-o.
Cinci firme, cinci verdicte
Rulat pe serverul de test, cu cinci CUI-uri fictive (date de exemplu):
$ php termen_plata.php 90000057 90000073 90000065 90000014 90000030
90000057 EXEMPLU BRAD MEDICAL SRL
verdict: standard, termen 60 zile
- [info] datorii/active 2025: 0,46 (pragul tău: 0,80)
- [info] profit net în 2025: 31.771 lei
- [info] cifra de afaceri 2025: 831.930 lei (-16 % față de 2024)
90000073 EXEMPLU FAGET CONTA SRL
verdict: avans, termen 0 zile
- [avans] capitaluri proprii negative în 2025: -90.263 lei
- [redus] datorii/active 2025: 2,41 (pragul tău: 0,80)
- [redus] pierdere netă în 2025: 46.421 lei, al doilea an la rând
- [info] cifra de afaceri 2025: 257.897 lei (-13 % față de 2024)
90000065 EXEMPLU MERIDIAN CONTA SRL
verdict: redus, termen 15 zile
- [redus] firmă înființată în 2026: nu are încă bilanț publicat
90000014 EXEMPLU SOLAR CAFE SRL
verdict: avans, termen 0 zile
- [avans] inactivă fiscal din 2026-09-15
- [info] datorii/active 2025: 0,63 (pragul tău: 0,80)
- [info] profit net în 2025: 158.792 lei
- [info] cifra de afaceri 2025: 1.097.352 lei (-14 % față de 2024)
90000030 -
verdict: manual, termen 0 zile
- [manual] persoană fizică (PFA, II, IF): nu avem date
EXEMPLU BRAD MEDICAL are cifra de afaceri în scădere cu 16 %, sub pragul de 25 %, deci doar un motiv informativ. EXEMPLU FAGET CONTA atinge trei semnale, iar capitalurile negative singure ar fi fost de ajuns pentru avans. EXEMPLU MERIDIAN CONTA e din 2026 și nu are de unde să aibă bilanț; primește un termen scurt, nu un refuz, pentru că lipsa bilanțului e lipsă de informație, nu un semn rău. Ultimul CUI e al unei persoane fizice, despre care apifirme nu servește date, așa că decide un om.
Cel mai instructiv caz e EXEMPLU SOLAR CAFE: profit, datorii moderate, cifră de afaceri aproape stabilă, adică un bilanț care arată bine, și totuși firma e inactivă fiscal din septembrie. Dacă te uitai doar la cifre, îi dădeai 60 de zile.
Bilanțul arată firma de acum zece luni. Starea fiscală o arată de săptămâna aceasta. Ai nevoie de amândouă.
Unde pui verificarea în aplicație
- La crearea clientului și când cineva cere un termen mai lung. Salvează verdictul, motivele, anul bilanțului și data verificării, ca peste șase luni să știi pe ce s-a bazat decizia.
- O dată pe an, după publicarea bilanțurilor noi, rulează din nou pentru toți clienții care au termen de plată. Câmpurile
updated_atșisourcesale fiecărei situații spun din ce set de date vine și când a fost preluată (vezi sursele și ritmul de actualizare). - Starea fiscală mai des, de exemplu la fiecare comandă nouă: ea se schimbă oricând în timpul anului.
- Loc pentru excepții. Un om poate trece peste verdict, cu o notă. Regula e acolo ca să nu se uite nimic, nu ca să decidă singură.
Dacă apifirme nu răspunde, poți lua starea și bilanțurile de la openapi.ro, care are companies/{cif}/balances cu alte nume de câmpuri, dar fără un echivalent clar pentru inactivitatea fiscală; cum arată un fallback curat e în articolul despre fallback. Și acolo, ce lipsește rămâne null după normalizare.
De încercat
Ia ultimii zece clienți care ți-au plătit cu mare întârziere sau deloc și rulează programul pe CUI-urile lor. Dacă regula i-ar fi prins pe cei mai mulți, pragurile sunt un punct de plecare bun. Dacă nu, citește motivele întoarse și mută pragurile, la vedere, în POLITICA. Un CUI anume îl poți încerca și fără cont, în consolă.