blog.apifirme.dev/bilantul-clientului-inainte-de-termen-de-plata .md

blog / bilanțuri

Bilanțul clientului, citit înainte să-i dai 60 de zile de plată

Două cereri și o regulă pe care o poți explica: bilanțul și starea fiscală ale clientului, înainte de termenul de plată. Cod PHP 8.3 cu cURL, fără framework.

· 11 min de citit

Cuprins
  1. Ce spune un bilanț și ce nu spune
  2. Regula, înainte de cod
  3. Programul: PHP 8.3, cURL, fără framework
  4. Cinci firme, cinci verdicte
  5. Unde pui verificarea în aplicație
  6. De încercat

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_neta pozitivă, iar profit_net e de obicei 0 (ministerul le publică pe amândouă), uneori gol, ca în exemplul de aici. Verifici întâi pierderea, apoi profitul.
  • null nu e zero. cheltuieli_avans: null înseamnă că ministerul nu a publicat valoarea, nu că firma nu are cheltuieli în avans. Un program care pune ?? 0 peste 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.

termen_plata.phpPHP
<?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, vine 202 cu antetul Retry-After, iar programul așteaptă și întreabă din nou, de cel mult trei ori. Un 404 nu se repetă: e fie o persoană fizică (company-not-available), fie un CUI neînregistrat (company-not-found), iar câmpul type din 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 null nu 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 și sources ale 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ă.

etichete: financials, PHP, risc de credit, termene de plată

Î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