# ТЗ: модуль верифікації контрагентів

Версія 1.0 від 10 вересня 2026. Напрям A плану робіт. Спирається на Спринт 0 (`001_init.sql`), розширює його міграцією `002_verification.sql`.

Це технічне завдання побудоване навколо одного твердження: **верифікація — не оцінка, а зафіксований факт із джерелом, датою і доказом**. Тому в модулі немає зірочок, немає зведеного балу і немає жодного місця, де недоступність джерела мовчки перетворюється на довіру.

---

## 1. Що саме продається

На цьому ринку платять не за профіль компанії, а за відповідь на три питання перед конкретною угодою:

1. Ця компанія справді існує і справді така, як каже?
2. Чи є підстави не працювати з нею — санкції, банкрутство, борги за перевезення?
3. Чи змінилося щось із учора — рахунок, директор, статус?

Третє питання найдорожче й найменш покрите. Разова довідка старіє за тиждень; підміна банківських реквізитів у листуванні — найпоширеніша схема шахрайства в логістиці. Тому дельта-моніторинг у цьому ТЗ не додаток до перевірки, а її головна частина.

Що модуль **не** робить свідомо: не виставляє оцінок, не радить, з ким працювати, не приховує застарілих даних і не показує «перевірено» там, де реєстр промовчав.

---

## 2. Рівні

| Рівень | Що означає | Що дає на майданчику |
|---|---|---|
| **L0** | Акаунт створено, пошту підтверджено. Жодної перевірки. | Перегляд заявок, публікація з позначкою «не перевірено», ліміт 5 заявок на добу |
| **L1** | Компанія існує в держреєстрі, дані збігаються, санкційних збігів немає | Публікація без ліміту, бейдж «ідентичність підтверджено», доступ до контактів |
| **L2** | L1 + право підпису заявника + судові та ліцензійні перевірки + активний моніторинг реквізитів | Бейдж «перевірений контрагент», пріоритет у стрічці, доступ до заявок із позначкою «тільки для перевірених» |

Рівень **завжди має строк дії**: L1 — 365 днів, L2 — 180 днів. Після закінчення компанія падає на L0 автоматично, а не лишається «перевіреною назавжди». База це підтримує на рівні тригера: рівень вище L0 без справи і без `verification_expires_at` вставити неможливо.

L2 **призупиняється** при критичній зміні реквізитів до підтвердження — компанія не втрачає рівень, але бейдж змінюється на «дані змінилися, перевірка триває».

---

## 3. Каталог перевірок

14 перевірок, машинно-читаний опис у `seed/verification_checks.json`.

| Ідентифікатор | Джерело | Рівень | Рішуча | Юрисдикція | Критерій |
|---|---|---|---|---|---|
| `identity.email_domain` | домен | L1 | ні | усі | Домен старший за 90 днів, має MX; безкоштовна пошта не зараховується |
| `identity.registry_match` | ЄДР (НАІС) | L1 | **так** | UA | Запис знайдено, назва збігається, статус не «припинено», КВЕД містить перевезення |
| `identity.registry_match_pl` | KRS | L1 | **так** | PL | Актуальний витяг отримано, назва і статус збігаються |
| `identity.vat_active` | VIES | L1 | ні | усі | `valid=true`; поза ЄС — не застосовна |
| `identity.signatory` | ЄДР | L2 | **так** | усі | Заявник — керівник у реєстрі або має перевірену довіреність |
| `identity.control_payment` | банк | L2 | ні | усі | Платіж отримано, назва платника збігається з реєстровою |
| `risk.sanctions` | OpenSanctions | L1 | **так** | усі | Немає підтвердженого збігу за назвою, кодом і країною |
| `risk.sanctions_ua` | ДРС РНБО | L1 | **так** | UA | Немає збігу в державному реєстрі санкцій |
| `risk.court_cases` | ЄДРСР | L2 | ні | UA | Немає справ категорій банкрутство, борг за перевезення, податковий спір, втрата вантажу |
| `risk.bankruptcy` | ЄДР | L1 | **так** | усі | Статус не «припинення» і немає відкритої справи про банкрутство |
| `capability.license` | Укртрансбезпека | L2 | ні | UA | Застосовна лише до пасажирських і небезпечних перевезень |
| `capability.eu_licence` | ERRU / документ | L2 | ні | усі | Копія ліцензії Спільноти, звірена оператором |
| `monitor.requisites` | агрегатор | L2 | ні | усі | Підписка активна, останній прогін не старший за подвоєний інтервал |
| `monitor.vat_status` | Biała lista | L2 | ні | PL | Щотижнева перевірка статусу ПДВ і переліку рахунків |

**Важлива юридична деталь**, яка міняє продукт: в Україні звичайні вантажні автоперевезення **не ліцензуються** — ліцензія потрібна лише для пасажирських перевезень і небезпечних вантажів. Тому вимагати ліцензію від звичайного перевізника означало б відсіювати легальний бізнес. Перевірка `capability.license` застосовна вибірково і ніколи не є рішучою.

---

## 4. Джерела та їхній фактичний стан

13 джерел, машинно-читаний каталог у `seed/verification_sources.json`. Стан нижче — **результат живого зонда, виконаного 10 вересня 2026**, а не опис із документації.

| Джерело | Стан | Затримка | Що показав живий запит |
|---|---|---|---|
| [VIES](https://ec.europa.eu/taxation_customs/vies/) | **live** | 528 мс | REST-ендпойнт `check-vat-number` працює без ключа, `valid=true`, повертає назву платника |
| [Biała lista podatników VAT](https://www.podatki.gov.pl/wykaz-podatnikow-vat-wyszukiwarka/) | **live** | 454 мс | `statusVat=Czynny`, **17 офіційних банківських рахунків** у відповіді |
| [KRS Otwarte API](https://prs.ms.gov.pl/krs/openApi) | **live** | 674 мс | Актуальний витяг, `stanZDnia=27.08.2026`, останній запис 26.08.2026 |
| [Державний реєстр санкцій РНБО](https://api-drs.nsdc.gov.ua/swagger/) | **needs_key** | 558 мс | Специфікація 1.8.5 публічна, 13 шляхів, дельта-ендпойнт `new-decrees` є; запит без ключа — 401 Unauthorized |
| [OpenSanctions](https://www.opensanctions.org/api/) | **needs_key** | 123 мс | 401 без ключа; тариф 0,10 EUR за запит або власне розгортання |
| [Ліцензійний реєстр Укртрансбезпеки](https://dsbt.gov.ua/novyny/vidkryto-dani-pro-litsenzii-pereviznykiv-dlia-koho-tse-bude-korysnym) | **stale** | 569 мс | Віддає HTML, машинного інтерфейсу немає; набір на data.gov.ua позначено як такий, що не оновлюється |
| [API ЄДР (ДП НАІС)](https://nais.gov.ua/pages/api) | **needs_key** | 11 661 мс | Доступ за договором; додатковий сервіс у складі АРІ ЄДР — 18 000 грн з ПДВ за календарний місяць незалежно від кількості запитів |
| [ERRU](https://transport.ec.europa.eu/transport-modes/road/rules-governing-access-profession/european-register-road-transport-undertakings-erru_en) | **manual_only** | — | Обмін лише між національними органами; для приватної компанії машинного доступу не існує |
| Контрольний платіж, домен | **manual_only** | — | Машинного інтерфейсу не існує за визначенням |

Підсумок зонда: **4 джерела живі, 3 потребують ключа, 1 деградоване, 3 працюють лише вручну, недоступних немає**.

Три висновки, які прямо міняють план:

1. **Найдешевший старт — польський напрямок.** VIES, Biała lista і KRS працюють без ключа й без договору просто зараз. Biała lista до того ж публікує офіційні банківські рахунки — це готова основа моніторингу підміни реквізитів для маршрутів UA↔PL, які й становлять основу трафіку.
2. **Українські джерела впираються в договори, а не в код.** Найдовший строк у цьому напрямі — юридичний: договір із ДП НАІС і ключ від НСДЦ. Їх треба запускати в перший тиждень, паралельно з розробкою, інакше готовий коннектор чекатиме на папір.
3. **Український ліцензійний реєстр непридатний до автоматизації.** Це не привід його ігнорувати, а привід показувати результат чесно: з датою вивантаження і позначкою «дані застарілі», а не як «перевірено».

Комерційні агрегатори закривають розрив, поки немає прямого доступу: [Opendatabot](https://opendatabot.ua/open/prices) — від 399 грн на місяць за 400 перевірок, корпоративний тариф 18 535 грн з ПДВ на рік; [YouScore від YouControl](https://youcontrol.com.ua/youscore/) — альтернатива з власним ендпойнтом моніторингу. Обидва задекларовані в каталозі як взаємозамінні: контракт коннектора однаковий, вибір комерційний.

---

## 5. Правила ухвалення рішення

Алгоритм навмисно простий, бо його мусить розуміти оператор, а не лише автор коду.

```
для кожної перевірки, обов'язкової для цільового рівня:
    якщо перевірка не застосовна до юрисдикції   -> not_applicable, пропустити
    якщо джерело відповіло і критерій виконано    -> pass
    якщо джерело відповіло і критерій порушено:
        якщо перевірка рішуча                     -> fail, справа відхиляється
        інакше                                    -> attention, у справу, рівень можливий
    якщо джерело не відповіло:
        якщо джерело блокуюче                     -> unavailable, у blocking_unavailable
        інакше                                    -> unavailable, рівень можливий

схвалення можливе, лише якщо:
    жодного fail
    blocking_unavailable порожній
    усі pass мають valid_until у майбутньому
```

Чотири правила, які тримають чесність системи:

- **`unavailable` ніколи не дорівнює `pass`.** У БД це закріплено обмеженням: результат зі статусом `unavailable` не має права нести знахідки — порожній `findings` унеможливлює перетворення мовчання джерела на дані у звіті.
- **Будь-який статус, крім `pass`, зобов'язаний мати код причини.** «Просто не пройшло» не є відповіддю ані для оператора, ані для заявника.
- **Свідоме подолання блокування можливе, але дороге.** Оператор може схвалити справу попри недоступне блокуюче джерело прапорцем `override_blocking`. Це пишеться окремою подією в журнал і **скорочує строк дії рівня вдвічі**. Тобто це не лазівка, а зафіксоване рішення з наслідком.
- **Санкційний збіг за назвою — гіпотеза, а не вирок.** Автоматична відмова за схожістю рядків неприпустима: у Європі десятки перевізників зі схожими назвами. Збіг іде в ручний розгляд і не потрапляє в публічне досьє, поки оператор його не підтвердив.

---

## 6. Моніторинг зміни реквізитів

Центральна частина продукту. Працює планувальником: `monitoring_subscriptions` тримає чергу, кожен прогін порівнює свіжий знімок із чинним у `company_requisites` і породжує подію в `requisite_changes`.

### Матриця важливості

| Що змінилося | Важливість | Наслідок |
|---|---|---|
| Банківський рахунок | **критична** | Рівень призупинено, сповіщення всім, хто стежить, негайно |
| Керівник, бенефіціар | **критична** | Рівень призупинено, потрібне повторне підтвердження права підпису |
| Статус у реєстрі (припинення, банкрутство) | **критична** | Рівень призупинено, заявки знімаються з публікації |
| Назва | attention | Сповіщення, рівень зберігається |
| Юридична адреса, КВЕД | attention | Сповіщення |
| Телефон, сайт, пошта | info | Тільки в історії |

Ця матриця закодована двічі й перевіряється машинно: обмеженням у БД (критична зміна зобов'язана призупиняти рівень) і умовним правилом у JSON Schema (зміна рахунку, керівника чи бенефіціара **завжди** критична). Спроба записати зміну рахунку з важливістю `info` відхиляється і схемою, і базою.

### Частота

- Компанії з активними заявками — щодоби.
- Компанії, за якими є підписка користувача, — щодоби.
- Решта L2 — раз на тиждень.
- Санкційні списки — щодоби для всіх L1+ (дешево і найвища ціна помилки).

Дельта береться з ендпойнтів змін, а не повним переобходом: `new-decrees` у реєстрі санкцій, `dataOstatniegoWpisu` у KRS, підписка на зміни в агрегатора. Це різниця між сотнею запитів на добу і сотнею тисяч.

---

## 7. Модель даних

Повний DDL: `db/migrations/002_verification.sql`. Основні таблиці:

| Таблиця | Призначення |
|---|---|
| `verification_sources` | Каталог джерел зі станом, вартістю, TTL і ознакою блокуючого |
| `verification_checks_catalog` | Каталог перевірок із рівнем, рішучістю і юрисдикціями |
| `company_identifiers` | ЄДРПОУ, NIP, KRS, VAT, LEI з форматними обмеженнями і підтвердженням |
| `verification_cases` | Справа перевірки: одна активна на компанію |
| `verification_check_results` | Результат окремої перевірки зі статусом, причиною, вартістю і затримкою |
| `verification_evidence` | Незмінний доказ: SHA-256, розмір, посилання на WORM-сховище, дата стану джерела |
| `company_requisites` | Знімки реквізитів із періодом дії |
| `requisite_changes` | Виявлені зміни з важливістю і наслідком |
| `monitoring_subscriptions` | Черга моніторингу |
| `sanction_hits`, `court_case_hits` | Збіги, що чекають на людину |
| `verification_level_history` | Партиціонована історія рівнів, append-only |
| `company_public_dossier` | Подання без персональних даних |

Ключове рішення: **історія рівнів пишеться тригером, а не кодом**. Рівень змінюють щонайменше троє — оператор, планувальник моніторингу і процес закінчення строку. Покладатися на те, що всі троє не забудуть записати історію, не можна.

Друге: `verification_evidence` захищено `ON DELETE RESTRICT`. Результат перевірки, до якого прив'язано доказ, видалити неможливо — саме це дає змогу через рік довести, що показував реєстр на момент угоди.

---

## 8. API

Контракт: `api/openapi-verification.yaml`, OpenAPI 3.1, канонічні форми підключено через `$ref` до `schemas/verification.schema.json`.

| Метод і шлях | Призначення |
|---|---|
| `POST /verification/cases` | Подати заявку на рівень |
| `GET /verification/cases/{id}` | Стан справи з усіма результатами |
| `POST /verification/cases/{id}/documents` | Подати документ для ручної перевірки |
| `POST /verification/cases/{id}/decision` | Рішення оператора |
| `GET /verification/checks/{id}/evidence` | Доказ: хеш і тимчасове посилання |
| `GET /companies/{id}/dossier` | Публічне досьє |
| `GET /companies/{id}/changes` | Історія змін реквізитів |
| `GET/POST/DELETE /monitoring/subscriptions` | Підписки на контрагентів |
| `GET /verification/sources` | Каталог джерел і їхній фактичний стан |

Останній ендпойнт публічний навмисно. Користувач має бачити, звідки взято дані і чи джерело зараз працює — це і є різниця між перевіркою і зірочками.

---

## 9. Докази, зберігання, приватність

| Питання | Рішення |
|---|---|
| Що зберігається | Сирий відгук джерела в WORM-бакеті об'єктного сховища; у БД — SHA-256, розмір, тип, посилання, час запиту і **дата стану даних у джерелі** |
| Навіщо окрема дата стану | KRS повідомляє `stanZDnia`, відкриті дані — дату вивантаження. Свіжість визначає вона, а не час нашого запиту |
| Строк зберігання | 5 років від дати перевірки — узгоджено зі строком позовної давності за господарськими договорами; далі автоматичне видалення за `retention_until` |
| Персональні дані | Керівник, бенефіціар, телефон і пошта **не входять** у публічне досьє. Вони доступні лише в межах справи, і кожне розкриття пишеться в `event_log` подією `contact_reveal` |
| Підстава обробки | Легітимний інтерес (GDPR ст. 6(1)(f)) для перевірки контрагента; оцінка впливу (LIA) оформлюється до запуску |
| Обробники | Договори з агрегаторами оформлюються як DPA. Самостійне розгортання санкційного пошуку прибирає передавання даних контрагентів третій стороні і на обсягах дешевше за метровану хмару |
| Право на заперечення | Компанія бачить власне досьє, може подати спростування; спростування додається до досьє, а дані джерела не переписуються |

---

## 10. Інтерфейс

Бейдж у стрічці — три стани і жодного відтінку: «не перевірено», «ідентичність підтверджено», «перевірений контрагент». Плюс окремий стан «дані змінилися» для призупинених.

Досьє показує **перелік перевірок, а не оцінку**: назва перевірки, статус, джерело з посиланням, дата перевірки, дата стану джерела. Застарілі результати позначаються, а не приховуються. Недоступні джерела показуються як недоступні — так само, як це вже роблять вкладки погоди й кордонів у наявному SPA.

Заявник у справі бачить рух: які перевірки пройдено, які тривають, який документ від нього потрібен і чому саме.

---

## 11. Нефункціональні вимоги

| Показник | Ціль |
|---|---|
| Час автоматичної частини справи | p95 ≤ 60 с від подання до `manual_review` або рішення |
| Ручний розгляд | ≤ 1 робочий день |
| Затримка сповіщення про критичну зміну | ≤ 15 хвилин від виявлення |
| Прогін моніторингу | 10 000 компаній щодоби в межах вікна 2 години |
| Кеш відповідей джерел | За TTL із каталогу; повторний запит у межах TTL не витрачає квоту |
| Обробка збоїв | Експоненційна пауза, 3 спроби, потім `unavailable` — не нескінченний повтор |
| Облік вартості | `cost_micro` у кожному результаті; місячний звіт за джерелами |

---

## 12. Фактичні прогони

Виконано в цій сесії на цьому робочому місці.

| Прогін | Що перевіряв | Результат |
|---|---|---|
| `tools/probe_sources.py` | 11 джерел живими запитами | 4 live, 3 needs_key, 1 stale, 3 manual_only, 0 down; розбіжностей із каталогом немає |
| `ci/check_verification_contract.py` | Каталоги, OpenAPI, схема, перерахування DDL, покриття, мутації | **36 із 36 пройдено**, 12 навмисних мутацій виявлено |
| `ci/acceptance_verification_db.py` | Жива PostgreSQL 18.6 + PostGIS 3.6 | **42 із 42 пройдено** |
| ↳ міграції | 001 і 002 послідовно на чистій базі | застосовано |
| ↳ каталоги | 13 джерел і 14 перевірок завантажено без правок | збігаються з файлами |
| ↳ негативні | 24 навмисні порушення інваріантів | усі відхилені базою |
| ↳ історія | Зміна рівня L0→L1 і падіння L1→L0 | обидві записані тригером, без участі коду |
| ↳ права | `verification_level_history` і `verification_evidence` | `DELETE` роллю застосунку заборонено |

**Три речі, які знайшли саме прогони:**

- Реєстр санкцій РНБО має публічну специфікацію, але закритий доступ: `401 Unauthorized` без ключа. Планувати цю перевірку як «безкоштовну і миттєву» було б помилкою — вона впирається в звернення до НСДЦ.
- Ліцензійний реєстр Укртрансбезпеки віддає HTML, а не дані. Каталог позначено `stale`, і перевірка ніколи не блокує рішення мовчки.
- Розбір перерахувань DDL спершу давав хибний результат: апостроф в українських коментарях читався як межа рядкового літерала. Перевірка `check_status` «не збігалася зі схемою» через дефект самої перевірки, а не схеми. Виправлено зрізанням коментарів перед розбором — і це причина, чому перевірки треба запускати, а не лише писати.

---

## 13. Приймальні критерії

1. Компанія з чинним ЄДРПОУ і збігом у реєстрі отримує L1 автоматично, без участі оператора, за менш ніж 60 секунд.
2. Компанія в стані припинення отримує `fail` за `risk.bankruptcy` і не може отримати жодного рівня.
3. При недоступному блокуючому джерелі справа **не схвалюється**, а переходить у ручний розгляд із переліком невиконаних перевірок.
4. Схвалення з `override_blocking` дає рівень із удвічі коротшим строком і породжує окрему подію в журналі.
5. Зміна банківського рахунку в Biała lista виявляється наступним прогоном, призупиняє L2 і породжує сповіщення всім підписникам за 15 хвилин.
6. Спроба записати зміну рахунку з важливістю `info` відхиляється і схемою, і базою.
7. Результат зі статусом `unavailable` не має знахідок — на рівні обмеження БД, а не домовленості.
8. Видалити доказ або запис історії рівнів роллю застосунку неможливо.
9. Досьє показує дату стану джерела окремо від дати перевірки і позначає застарілі результати.
10. `Контракт модуля верифікації` та `Інваріанти верифікації` — обов'язкові status checks у правилі захисту гілки.
11. Негативні PR блокуються: `unavailable` зі знахідками; схвалення з непорожнім `blocking_unavailable`; критична зміна без призупинення; `pass` без строку дії; блокуюче джерело, позначене як суто ручне.

---

## 14. План на п'ять тижнів

| Тиждень | Зміст | Умова завершення |
|---|---|---|
| **1** | Міграція 002, каталоги, коннектори VIES, Biała lista, KRS. Паралельно — **звернення за договором до ДП НАІС і за ключем до НСДЦ** | Польський і європейський напрям працюють наскрізно на живих джерелах |
| **2** | Машина справи: черга перевірок, статуси, докази у WORM-сховищі, правила рішення | Компанія з польським NIP отримує L1 автоматично |
| **3** | Український напрям: ЄДР через договір або агрегатор, санкції, банкрутство, ЄДРСР | Компанія з ЄДРПОУ отримує L1; рішучі перевірки блокують |
| **4** | Моніторинг: планувальник, дельта, матриця важливості, призупинення, сповіщення | Зміна рахунку виявляється і призупиняє рівень у тестовому прогоні |
| **5** | Кабінет оператора, ручний розгляд, документи, досьє й бейджі в SPA, звіт про вартість | Наскрізний сценарій від заявки до бейджа в стрічці |

Критичний шлях — не код, а папери: договір із ДП НАІС і ключ від НСДЦ. Вони запускаються в перший день тижня 1.

---

## 15. Ризики

| Ризик | Наслідок | Що робимо |
|---|---|---|
| Договір із НАІС затягується | Немає первинного українського джерела | Тижні 1–3 працюємо через агрегатор, коннектор пишеться під однаковий контракт і перемикається конфігурацією |
| Ключ від НСДЦ не видають | `risk.sanctions_ua` недоступна | Похідне покриття через консолідований набір, позначене в досьє як похідне джерело |
| Ліміти запитів безкоштовних джерел | Черга моніторингу не встигає | Кеш за TTL, дельта-ендпойнти замість переобходу, пріоритет компаніям з активними заявками |
| Хибні санкційні збіги | Несправедливе блокування | Збіг ніколи не блокує автоматично; рішення ухвалює оператор |
| Вартість платних джерел зростає з обсягом | Економіка модуля ламається | `cost_micro` у кожному результаті з першого дня; при перевищенні порогу — перехід на самостійне розгортання санкційного пошуку |
| Скарга на обробку персональних даних | Юридичний ризик | Мінімізація, журнал розкриттів, LIA до запуску, механізм спростування |

---

## 16. Склад артефактів

```
sprint-0-backend/
  ТЗ-верифікація-контрагентів.md          — цей документ
  db/migrations/002_verification.sql      — 12 таблиць (+партиції), 2 тригери, подання досьє
  schemas/verification.schema.json        — checkResult, case, requisiteChange, dossier
  api/openapi-verification.yaml           — 10 шляхів, 11 операцій, $ref на схему
  seed/verification_sources.json          — 13 джерел зі станом, вартістю і TTL
  seed/verification_checks.json           — 14 перевірок із критеріями
  tools/probe_sources.py                  — живий зонд джерел
  ci/check_verification_contract.py       — 36 перевірок артефактів
  ci/acceptance_verification_db.py        — 42 перевірки на живій БД
```

Конвеєри лежать у спільному `ci/` репозиторію:

```
ci/backend-contract.yml                   — job-и `verification-contract` і `verification-db`
                                            є обов'язковими перевірками правила гілки
ci/source-probe.yml                       — живий зонд джерел за розкладом `17 5 * * *`,
                                            свідомо НЕ обов'язковий: недоступність чужого
                                            реєстру не має блокувати вливання коду
```
