Перейти до змісту

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 у меню : - Прийняти кандидата #Nmatch_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 специфікація для майбутнього портування