# G1/SP2 — реквизиты клиента + ИНН по DaData + гейт первого проекта (backend)

**Дата:** 18.06.2026. **Спринт:** G1 (самозапись клиента), под-спринт SP2.
**Предшественник:** SP1 (самозапись + подтверждение почты), коммит `53fb7b77`.
**Слой:** backend (Laravel 13), проверяемо Pest. Фронт (личный кабинет, форма) — SP3.

## Цель {#goal}

Дать клиенту-тенанту завести минимальные сведения о себе («кто пришёл» + контакт) и
зафиксировать тип лица; для ИП/юрлица — взять ИНН и **мягко** подтянуть наименование по
DaData. До заполнения этого минимума клиент **не может создать первый проект** (гейт).

Хранилище и эндпоинты спроектированы так, чтобы позже, на этапе оплаты/пополнения баланса,
клиент **дозаполнил** полные реквизиты (КПП, ОГРН, юр.адрес, расчётный счёт) в личном
кабинете — те же эндпоинты, те же поля (изначально пустые/nullable).

Решения владельца (18.06.2026), зафиксированные в этой спеке:

- Перед первым проектом нужен **лёгкий минимум**: тип лица + контакт (имя + телефон).
- Для ИП/юрлица — ИНН **сразу**, наименование подтягивается по DaData.
- DaData — **мягко**: молчит/не нашла/нет ключа → пропускаем, клиент введёт сам. Внешний
  сервис **не блокирует** старт.
- Полные «тяжёлые» реквизиты (р/с и т.д.) — дозаполняются позже при оплате; backend под это
  готов уже сейчас (поля заведены nullable).

## Конвенции и границы {#scope}

**Входит в SP2:**

1. Таблица `tenant_requisites` (1:1 с тенантом) — лёгкие + «тяжёлые» поля (последние nullable).
2. Эндпоинты `GET /api/tenant/requisites`, `PUT /api/tenant/requisites`,
   `POST /api/tenant/requisites/lookup-inn`.
3. `DaDataPartyClient` (suggestions `findById/party`) за интерфейсом + Null/Fake-драйвер.
4. Нормализация телефона до `+7XXXXXXXXXX`; валидация ИНН по контрольной цифре.
5. Гейт первого проекта в `ProjectController::store()`.
6. Тесты Pest на всё перечисленное.

**НЕ входит (явно отложено):**

- Фронт (форма реквизитов, личный кабинет, маска ввода в UI) — SP3.
- Реальный ключ DaData party на проде, выкат — ops/позже.
- Логика этапа оплаты (счета, эквайринг, УПД) — отдельный спринт; SP2 лишь готовит хранилище.
- Полная валидация банковских реквизитов против справочника БИК — позже (сейчас только формат).

## Хранилище: таблица `tenant_requisites` {#storage}

Tenant-aware таблица (RLS по `tenant_id`), 1:1 с тенантом (`UNIQUE(tenant_id)`).

| Поле | Тип | Назначение |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `tenant_id` | BIGINT NOT NULL, FK→tenants, UNIQUE | владелец |
| `subject_type` | VARCHAR(20) NOT NULL CHECK in (`individual`,`sole_proprietor`,`legal_entity`) | физ/ИП/юр |
| `contact_name` | VARCHAR(255) NOT NULL | контактное лицо (лёгкий гейт) |
| `contact_phone` | VARCHAR(16) NOT NULL | нормализованный `+7XXXXXXXXXX` (лёгкий гейт) |
| `inn` | VARCHAR(12) NULL | 10 (юр) / 12 (ИП); контрольная цифра |
| `legal_name` | VARCHAR(255) NULL | наименование (DaData или руками) |
| `kpp` | VARCHAR(9) NULL | дозаполняется при оплате |
| `ogrn` | VARCHAR(15) NULL | дозаполняется (DaData отдаёт) |
| `legal_address` | TEXT NULL | дозаполняется (DaData отдаёт) |
| `bank_name` | VARCHAR(255) NULL | дозаполняется при оплате |
| `bank_bik` | VARCHAR(9) NULL | дозаполняется при оплате |
| `bank_account` | VARCHAR(20) NULL | расчётный счёт, при оплате |
| `corr_account` | VARCHAR(20) NULL | корр.счёт, при оплате |
| `dadata_raw` | JSONB NULL | сырой ответ DaData (провенанс) |
| `dadata_synced_at` | TIMESTAMPTZ NULL | когда подтянули |
| `requisites_completed_at` | TIMESTAMPTZ NULL | когда заполнен расчётный счёт (флаг готовности к оплате) |
| `created_at`/`updated_at` | TIMESTAMPTZ | |

**RLS:** политика `tenant_id = current_setting('app.current_tenant_id')::bigint` (как у прочих
tenant-aware таблиц). GRANT на `crm_app_user`, `crm_app_admin`; `crm_supplier_worker` —
BYPASSRLS. Правка `db/schema.sql` + запись в `db/CHANGELOG_schema.md` + миграция. Обязательное
ревью `rls-reviewer`.

## Валидация {#validation}

### Телефон (нормализация)

Снять все нецифры. Затем:

- 11 цифр, начинается с `8` → заменить ведущую `8` на `7`.
- 11 цифр, начинается с `7` → как есть.
- 10 цифр → добавить `7` в начало.
- иначе → `422` (`contact_phone` invalid).

Хранить как `+7` + 10 цифр (т.е. `+7XXXXXXXXXX`, длина 12 символов).

### ИНН (контрольная цифра)

- `legal_entity` → ровно **10** цифр, контроль:
  веса `[2,4,10,3,5,9,4,6,8]`, `c = (Σ wᵢ·dᵢ mod 11) mod 10`, `c == d10`.
- `sole_proprietor` → ровно **12** цифр, два контроля:
  - `n11`: веса `[7,2,4,10,3,5,9,4,6,8]`, `(Σ mod 11) mod 10 == d11`;
  - `n12`: веса `[3,7,2,4,10,3,5,9,4,6,8]`, `(Σ mod 11) mod 10 == d12`.
- `individual` → ИНН не требуется (если прислан — не валидируется как обязательный; для SP2
  физлицу ИНН не нужен).

Невалидный ИНН для ИП/юр → `422`.

### `PUT` по типам

`subject_type` обязателен (enum). `contact_name`, `contact_phone` обязательны всегда.
`inn` обязателен при `subject_type ∈ {legal_entity, sole_proprietor}` (+ контрольная цифра).
`legal_name` — **nullable** (мягкая DaData; может быть пуст на гейте, дозаполняется).
«Тяжёлые» поля nullable; при наличии — проверка формата (`kpp` 9 цифр, `bank_bik` 9 цифр,
`bank_account`/`corr_account` 20 цифр, `ogrn` 13 или 15 цифр). PUT **не** дёргает DaData.

## Эндпоинты {#endpoints}

Группа `Route::middleware(['auth:sanctum','tenant'])->prefix('/api/tenant/requisites')`.

- **`GET /`** → текущие реквизиты тенанта (`RequisitesResource`) либо `data: null`, если ещё
  не заведены (фронт показывает пустую форму).
- **`PUT /`** → upsert (создать или обновить) по `tenant_id` текущего пользователя. Валидация
  `UpdateRequisitesRequest` (см. §validation). Телефон нормализуется. Возвращает
  `RequisitesResource`. Выставляет `requisites_completed_at` = `now()`, когда заполнен
  `bank_account` (расчётный счёт) — однозначный сигнал готовности платить по счёту; иначе NULL.
- **`POST /lookup-inn`** body `{inn}` → мягкая подтяжка через `DaDataPartyClient`:
  - найдено → `{found:true, legal_name, kpp, ogrn, legal_address, subject_type_hint}`;
  - не найдено / DaData молчит / выключено / нет ключа → `{found:false}` (HTTP 200, без ошибки).
  - Эндпоинт **ничего не сохраняет** — только подсказка для автозаполнения формы (SP3).
  - Троттлинг: `throttle` на эндпоинт (защита от перебора ИНН).

## DaData party-клиент {#dadata}

`App\Services\DaData\DaDataPartyClient` — POST на
`https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party`, заголовок
`Authorization: Token <services.dadata.api_key>` (secret для suggestions **не** нужен), тело
`{"query":"<inn>"}`. Парсит первую suggestion → DTO `PartyLookupResult`
(`legalName`, `kpp`, `ogrn`, `address`, `type`, `raw`).

Интерфейс `App\Services\DaData\PartyLookup` с двумя реализациями:

- `DaDataPartyClient` (реальный HTTP);
- `NullPartyLookup` (dev/тесты по умолчанию; всегда «не найдено»).

Биндинг в провайдере по флагу `config('services.dadata.party_enabled')`
(env `DADATA_PARTY_ENABLED`, default `false`). Сетевые ошибки/таймаут/5xx/4xx внутри
клиента → возвращают «не найдено» (мягко, не бросают наружу). Тесты подменяют через `Fake`/
`HttpFactory::fake()` по образцу `DaDataPhoneClientTest`.

Config-добавка в `app/config/services.php` блок `dadata`:
`'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL)`.

## Гейт первого проекта {#gate}

В `ProjectController::store()` **до** preflight-баланса: если у тенанта **0 проектов**
(`Project::where('tenant_id',$tenant->id)->count() === 0`) **и** реквизиты не прошли
лёгкий минимум → `422` с телом `{"error":"requisites_required"}`.

«Лёгкий минимум выполнен» (хелпер `RequisitesService::isLightComplete(Tenant): bool`):

- запись `tenant_requisites` существует;
- `subject_type` задан;
- `contact_name` непуст;
- `contact_phone` непуст;
- если `subject_type ∈ {legal_entity, sole_proprietor}` — `inn` непуст (валидность гарантирована PUT).

`legal_name` для гейта **не** обязателен (DaData мягкая). После создания первого проекта гейт
не срабатывает (`count > 0`). Гейт стоит только на `store` клиентского API; админские/импорт-
пути вне SP2.

## Файлы (карта реализации) {#files}

- `app/database/migrations/2026_06_18_*_create_tenant_requisites.php` — таблица + RLS + GRANT.
- `db/schema.sql` — `CREATE TABLE tenant_requisites` + политика; `db/CHANGELOG_schema.md` — запись (вер. v8.43).
- `app/app/Models/TenantRequisites.php` — модель (fillable, casts, `belongsTo Tenant`); `Tenant::requisites()` hasOne.
- `app/app/Services/DaData/PartyLookup.php` (интерфейс), `DaDataPartyClient.php`, `NullPartyLookup.php`, `Dto/PartyLookupResult.php`.
- `app/app/Services/Requisites/RequisitesService.php` — upsert, нормализация телефона, `isLightComplete`, `requisites_completed_at`.
- `app/app/Support/PhoneNormalizer.php` (или метод в сервисе) + `app/app/Support/InnValidator.php`.
- `app/app/Http/Controllers/Api/TenantRequisitesController.php` — `show`/`update`/`lookupInn`.
- `app/app/Http/Requests/UpdateRequisitesRequest.php`, `LookupInnRequest.php`.
- `app/app/Http/Resources/RequisitesResource.php`.
- `app/app/Http/Controllers/Api/ProjectController.php` — гейт в `store()`.
- `app/routes/web.php` — группа `/api/tenant/requisites` + `throttle` на `lookup-inn`.
- Провайдер (`AppServiceProvider` или профильный) — биндинг `PartyLookup` по флагу.

## Тесты Pest {#tests}

- `RequisitesTest`: GET пусто → `data:null`; PUT физлицо (имя+телефон) ok; PUT ИП без ИНН →
  422; PUT юр с битым ИНН (контр. цифра) → 422; нормализация телефона (`8...`/`+7...`/10 цифр
  → `+7XXXXXXXXXX`); PUT с `bank_account` → `requisites_completed_at` выставлен, без него NULL;
  формат банковских полей.
- `TenantRequisitesLookupTest`: Fake DaData возвращает организацию → `found:true` + поля;
  выключено/молчит → `found:false` HTTP 200.
- `DaDataPartyClientTest`: парсинг suggestions-ответа; сетевой сбой/4xx/5xx → «не найдено».
- `InnValidatorTest`: позитив/негатив для 10 и 12 цифр (известные контрольные примеры).
- `PhoneNormalizerTest`: таблица входов→выходов + невалидные.
- `ProjectGateTest`: 0 проектов без реквизитов → 422 `requisites_required`; с лёгкими
  реквизитами → 201; второй проект (count>0) без проверки реквизитов → проходит preflight как
  обычно.

## verified-context

```verified-context-json
[
  {"id":"D-proj","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/ProjectController.php","anchor":"public function store"},
  {"id":"D-tenants","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"CREATE TABLE tenants"},
  {"id":"D-dadata","kind":"EXTRACTED","ref":"app/app/Services/DaData/DaDataPhoneClient.php","anchor":"class DaDataPhoneClient"}
]
```
