Bank Reconciliation — узгодження банківських виписок¶
Імпорт банківської виписки → авто-зіставлення з існуючими
IncomingPayment/OutgoingPayment→ ручне опрацювання решти → проведення виписки. Закриває «Sprint 5» з плану аудиту cash-bank.
1. Навіщо¶
До цього моменту cash-accounting був повністю «вручний»: бухгалтер дивився виписку у банк-клієнті і створював IncomingPayment / OutgoingPayment з нуля. Якщо платіж приходив через канал що не пов'язаний з нашою системою (зовнішній клієнт оплатив рахунок самостійно через свій банк) — у DOP про це не було жодних записів, поки не з'являлась паперова виписка наприкінці тижня.
Bank Reconciliation процес закриває цей gap: - Імпорт виписки одним файлом → DOP сам бачить кожну транзакцію. - Автоматичне співставлення з існуючими платежами за сумою + IBAN/ЄДРПОУ контрагента + дата (±3 дні). - Інтерактивне опрацювання нерозпізнаного: створити чернетку платежу одним кліком (з реквізитами що автоматично підставлені з recurring правил).
2. Документи¶
| Сутність | Тип | Призначення |
|---|---|---|
BankStatement |
TransactionModel | Імпортована виписка (header + lines) |
BankStatementLine |
subtable | Один транзакційний рядок виписки з match_status |
BankMatchingRule |
MasterDataModel | Per-tenant правила розпізнавання recurring платежів |
BankStatement.state lifecycle¶
draft ← імпортовано, ще є нерозпізнані рядки
posted ← всі рядки опрацьовано (matched/created/ignored), нема pending
state='posted' НЕ означає що виникли проводки PostingGroup. Виписка не пише в облікові регістри. Проводки робить пов'язаний платіж — або existing IncomingPayment/OutgoingPayment на який ми зіставили рядок, або щойно спавнений state='draft' платіж з кнопки «Створити платіж». «Posted» тут означає «реконсиліація завершена».
BankStatementLine.match_status lifecycle¶
pending ← стан після імпорту
matched ← користувач прийняв кандидата (auto-match або вручну)
created ← з рядка створено новий IncomingPayment/OutgoingPayment (state='draft')
ignored ← користувач відмітив рядок як такий, що не потребує платежу (наприклад внутрішній перерахунок між власними рахунками — це інший документ)
3. Підтримувані формати імпорту¶
| Формат | Файли | Джерело |
|---|---|---|
| SWIFT MT940 | .sta, .mt940 |
Приватбанк, Райффайзен, ОТП Bank, Укрсиббанк (звичайний європейський стандарт для бізнес-клієнтів) |
| ISO 20022 camt.053 | .xml |
Європейські банки, поступово замінює MT940 |
| PrivatBank Business CSV | .csv |
Експорт з Privat24 Business (UA / RU варіанти заголовків — обидва підтримані) |
| Monobank Business CSV | .csv |
Експорт з Mono Business (Дата i час, Деталі операції, signed amount у валюті картки) |
Idempotency: SHA-256 хеш файла зберігається на BankStatement.raw_file_hash. Повторний завантаж того ж файлу повертає 409 з посиланням на існуючу виписку — захист від випадкового дубля.
Авто-детекція формату: якщо користувач не обрав явно — детектор дивиться на розширення + перші 512 байт. <?xml ... <BkToCstmrStmt> → ISO 20022, :20: + :61: → MT940, csv з mcc у заголовку → Monobank, інакше Privat.
4. Auto-match — як це працює¶
Для кожного BankStatementLine з match_status='pending' сервіс шукає кандидатів серед IncomingPayment (для credit-рядків) або OutgoingPayment (для debit-рядків) на тому ж розрахунковому рахунку, з тією ж сумою, у вікні ±17 днів. Кожен кандидат отримує confidence score:
| Score | Умова |
|---|---|
| 1.00 | Сума + контрагент (IBAN→Client або ЄДРПОУ→Client) + та сама дата |
| 0.85 | Сума + контрагент + дата ±3 дні |
| 0.70 | Сума + контрагент (більший розмив дати) |
| 0.55 | Сума + дата ±3 дні (контрагент не визначений) |
| 0.40 | Сума без додаткових ознак |
Auto-link спрацьовує тільки коли: - топ-кандидат має confidence ≥ 1.0, і - він єдиний з таким score (наступний кандидат строго слабкіший).
Якщо два платежі мають однакові 1.0 (та сама сума, той самий клієнт, та сама дата) — рядок лишається pending зі списком обох кандидатів, користувач обирає сам. Це навмисно: matcher не вгадує, бо silently вибрати неправильний — гірше за просьбу прийняти рішення.
Locked-payment exclusion: платіж, що вже залінкований до іншого BankStatementLine (match_status in {matched, created}), не пропонується. Так той самий IncomingPayment не може випадково матчнутись до двох різних виписок (наприклад якщо ви імпортували Privat CSV і Privat MT940 за один період).
5. BankMatchingRule — recurring patterns¶
Якщо до вас регулярно надходять платежі від одного контрагента (наприклад «оплата за оренду від ТОВ Орендодавець»), створіть правило:
| Поле | Призначення |
|---|---|
direction |
in / out / both |
partner_iban / partner_edrpou |
Точне співпадіння |
description_pattern |
Substring match (case-insensitive), наприклад «оренда» |
min_amount / max_amount |
Вікно сум |
settlement_account |
Обмежити правило одним рахунком (опційно) |
business_operation |
Обов'язкова. Підставляється у створений платіж |
expense_item / client |
Опційні defaults |
priority |
Ascending = вища пріорітетність (10 важливіше за 100) |
auto_create |
Hint для lifecycle: при створенні платежу взяти ці defaults |
При imported виписці apply_matching_rules пробігає по правилах від priority 1 → up і прикріплює перше підходяще до line.applied_rule. Це не створює платіж автоматично — створення гасло через окремий action create_payment_from_line, а правило слугує джерелом дефолтів.
Чому правило не пушує auto-create. Помилкове правило може зашкодити — спавнити сотні платежів з неправильним рахунком. Тримання auto_create як підказки, а не як автоматичного дії, вимагає одного підтверджуючого кліку від людини, але закриває risk silent corruption.
6. Kanban UI¶
Сторінка Узгодження банківських виписок (process bankExchange):
┌─ Імпорт ─────────────────────────────────────────┐
│ [Файл] [Формат] [Розрахунковий рахунок] [Імпорт] │
└──────────────────────────────────────────────────┘
┌─ Останні виписки ────────────────────────────────┐
│ ▸ Виписка №005 · Privat · 2026-05-01..05-31 │
│ Pending: 3 Matched: 12 Created: 1 Ignored:0 │
│ ▸ Виписка №004 · MT940 · 2026-04-01..04-30 [✓] │
└──────────────────────────────────────────────────┘
┌─ Виписка №005 [draft] ───────────────────────────┐
│ Lines: 16 Credits: +88 200 Debits: −12 450 │
│ Reconciled: 13 / 16 [Перерахувати збіги] [Провести]│
│ │
│ ┌─ Pending (3) ─┐ ┌─ Matched (12) ┐ ┌─ Created (1)┐│
│ │ +1 500.00 │ │ +5 000.00 │ │ −250.00 ││
│ │ ACME LLC │ │ Customer Two │ │ Bank fee ││
│ │ Cands: 2 (85%)│ │ ← #IP-42 │ │ ← #OP-7 ││
│ │ [Прийняти] ⋯ │ │ [Відв'язати] │ │ [Відв'язати]││
│ └───────────────┘ └───────────────┘ └─────────────┘│
└──────────────────────────────────────────────────┘
Per-line actions у меню ⋯:
- Прийняти кандидата #N — match_status='matched', link до IncomingPayment/OutgoingPayment
- Створити платіж — modal-підтвердження, спавнить state='draft' платіж з реквізитів рядка + applied_rule defaults; match_status='created'
- Ігнорувати — match_status='ignored'. Внизу сторінки ігноровані лишаються згорнутою таблицею з кнопкою «повернути в Pending»
- Відв'язати (для matched/created) — повертає рядок у pending
Кнопка Провести активна тільки коли pending_count == 0.
7. Endpoints (для інтеграцій)¶
| Endpoint | Метод | Призначення |
|---|---|---|
/api/v1/essentials/bank-statements/import_file/ |
POST multipart | Завантажити файл (file, import_format, settlement_account_id) |
/api/v1/essentials/bank-statements/{id}/propose_matches/ |
POST | Перерахувати кандидатів + застосувати правила |
/api/v1/essentials/bank-statements/{id}/post_document/ |
POST | state='posted' (блокується якщо pending_count>0) |
/api/v1/essentials/bank-statements/{id}/unpost_document/ |
POST | state='draft' |
/api/v1/essentials/bank-statement-lines/{id}/accept_match/ |
POST {payment_type, payment_id} |
Прийняти кандидата вручну |
/api/v1/essentials/bank-statement-lines/{id}/unmatch/ |
POST | Повернути в pending |
/api/v1/essentials/bank-statement-lines/{id}/mark_ignored/ |
POST | Позначити як ignored |
/api/v1/essentials/bank-statement-lines/{id}/create_payment/ |
POST {client_id?, business_operation_id?, expense_item_id?} |
Спавнити draft IncomingPayment/OutgoingPayment |
/api/v1/essentials/bank-matching-rules/ |
CRUD | Per-tenant правила |
8. Обмеження поточного MVP¶
- Без drag-and-drop між колонками Kanban. Прийняття конкретного кандидата вимагає
payment_id— drop'нути карточку без вибору кандидата нема куди. - Без direct bank API integration. Усі чотири imported'и — файлові. Push-стиль (Monobank Open API, Privat Merchant API) — у BACKLOG, trigger: клієнт >100 транзакцій/день.
- Без AI matching by purpose field. Free-text підказка («Оплата за рахунком №42 від 01.05» → invoice 42) deferred — потребує LLM call per line, виправдано лише на >500 нерозпізнаних/місяць.
- Без cheque/promissory-note tracking (B-8 з audit) — у backlog до першого export-клієнта з векселями.
- Без export платіжних доручень (DOP → банк). Поки що односторонньо — імпорт → DOP. Експорт у файл для банк-клієнта — у backlog, trigger: клієнт з >50 outgoing/день.
🔮 Deferred / Ideas¶
Direct bank API integration (push-style)¶
Мотивація: виписки в реальному часі без щоденного імпорту .xml/.csv Чому відкладено: у кожного банку свій REST API + OAuth handshake; 4 файлові формати поки покривають ~95% кейсів Trigger: клієнт з потребою intraday balance (>100 транзакцій/день)
AI matching by purpose field¶
Мотивація: з призначення платежу LLM може вгадати invoice/контрагента Чому відкладено: потрібен достатній обсяг історичних узгоджень для прецизійної рекомендації + per-line LLM-call коштує Trigger: >500 неприв'язаних платежів на місяць у одного tenant'а
Multi-bank consolidated dashboard¶
Мотивація: клієнт з 3+ розрахунковими рахунками у різних банках хоче єдиний view залишків Чому відкладено: залежить від наявних інтеграцій; 1-2 рахунки покривається існуючим Cash Flow звітом Trigger: після підключення 3+ розрахункових рахунків у одного клієнта
Drag-and-drop reconciliation¶
Мотивація: UX-покращення «перетягнути картку зі списку Pending на Matched» Чому відкладено: drop-target вимагає вибору конкретного кандидата (payment_id), а не просто колонки — drag без picker'а нічого не вирішує Trigger: UX-фідбек від користувачів після 3-6 місяців експлуатації
Export платіжних доручень (DOP → банк)¶
Мотивація: генерація пакета платежів з approved OutgoingPayment для завантаження у банк-клієнт
Чому відкладено: немає сучасного UA-формату-стандарту: одні банки приймають .dbf, інші .xml, треті — тільки ручне введення; без типового файла треба адаптер per банк
Trigger: клієнт з >50 outgoing/день, де ручне введення у банк-клієнт стає bottleneck'ом
Cheque / Promissory note tracking (B-8)¶
Мотивація: експортні UA-клієнти приймають векселі від іноземних покупців Чому відкладено: окремий lifecycle (received → cleared / dishonoured), не вкладається у поточну BankStatement модель Trigger: перший експорт-клієнт з вексельними розрахунками
Пов'язане¶
- Платежі — що ми реконсилюємо:
IncomingPayment/OutgoingPayment - Фінансові рахунки —
SettlementAccount(IBAN зберігається тут, використовується для resolve_client at IBAN→Client) - Cash Transfer — переказ між власними рахунками; рядки виписки з internal sweep'ами зазвичай йдуть у
ignored(бо проводки робить CashTransfer) - Bank Exchange інтеграція — оновлений статус і backlog
- Cash & Bank Audit — план з усіма sprints, S5 ✅ shipped
- AI bundle — stack-neutral специфікація для майбутнього портування