Выписка банка - не отчет о прибыли
Как SABTRANS забирает выписки по API банков и превращает движение денег в честную картину - переводы, вклады, возвраты, дубли и сверка.
Клиенты SABTRANS подключают к CRM свои банки, и выписки приходят сами: операция по расчетному счету появляется в реестре через несколько минут после того, как ее провел банк. Это удобно, и это же провоцирует ошибку. Глядя на ленту плюсов и минусов, хочется сложить ее и назвать результат прибылью.
Так нельзя. Выписка честно отвечает на вопрос «куда двигались деньги по этому счету». Вопрос «сколько компания заработала» она даже не ставит. Между первым и вторым стоят переводы между своими счетами, вклады, возвраты, авансы и НДС. Ниже - как мы превращаем сырые операции банка в картину, которой можно верить, и где на этом пути ошибались.
Почему выписка не равна прибыли
Возьмем обычный месяц компании, которая сдает спецтехнику в аренду. В выписке будут:
- Переводы между своими счетами. С расчетного счета в одном банке на расчетный в другом. В одной выписке это минус, в другой плюс, а компания не стала ни богаче, ни беднее.
- Вклады. Свободные деньги кладут на короткие депозиты. Размещение уходит со счета минусом, возврат приходит плюсом. Деньги все время свои, просто лежат в другом месте. Доход здесь один - проценты.
- Возвраты. Поставщик вернул переплату. В выписке это плюс, но не доход, а уменьшение расхода.
- Авансы. Клиент заплатил за смены, которые техника отработает в следующем месяце. Деньги на счете есть, выручки еще нет.
- Пополнения. Оплата топливной карты или рекламного кабинета - минус в выписке, но расхода еще не было. Деньги переехали в другой кошелек и будут потрачены потом, по литрам и по кликам.
- НДС. В платеже поставщику сидит налог, и расход компании - это сумма без него.
Если сложить выписку как есть, месяц с крупным размещением на вклад покажет убыток, а месяц возврата вклада - рекордную прибыль. Один из клиентов держит деньги на множестве коротких депозитов, и до недавнего времени его реестр выглядел именно так: размещения висели расходом, возвраты - приходом ниоткуда, а сколько денег лежит на вкладах прямо сейчас, не было видно вообще.
Поэтому в SABTRANS выручка в отчетах считается по закрытым заказам, а расходы - по разнесенным статьям. Сама выписка в прибыль не складывается. Ее задача - быть полным и точным списком движений, который потом разбирают правила и люди.
Как выписка попадает в систему
Банки отдают выписку по-разному, но у двух основных, с которыми мы работаем, общая черта: выписка запрашивается за один день, по дате исполнения. Диапазона «с первого по тридцатое» нет. Отсюда простая схема: фоновый процесс раз в десять минут проходит по всем подключенным счетам и забирает сегодняшний день целиком. Историю тянем так же, день за днем.
Важное следствие: одна и та же операция приходит к нам десятки раз за день. Значит, загрузка обязана быть идемпотентной, и это не крайний случай, а основной путь. К этому я еще вернусь.
Доступ к API тоже устроен по-разному. Один банк работает по схеме host-to-host с клиентским сертификатом и секретом, который регулярно меняется. Другой - по API-ключу плюс mTLS-сертификат. Из этого мы вывели три правила:
- Ключи вводит владелец сам, в интерфейсе подключения. Не в чате, не в письме и не в задаче для разработчика.
- Боевая интеграция и внутренний инструмент - разные приложения в банке. Если у них общий секрет, ротация секрета ради инструмента молча ломает выписки всем клиентам.
- Ошибка банка - не пустой день. Если банк ответил отказом, а мы отдали наружу «0 операций», сломанное подключение неотличимо от дня без движений. Никто об этом не узнает, пока не начнет сверять остатки. Теперь отказ банка переводит подключение в «ошибку» с повтором через интервал и уведомлением, а ноль означает только настоящий ноль. Это та же мысль, что в заметке про сторожа, который смотрит на результат.
И одна грабля из практики: один банк кладет access-токен прямо в текст ошибки. Этот текст уходил в причину сбоя, оттуда - в уведомление сотруднику и в логи. Теперь секреты в тексте ошибки маскируются до того, как он куда-то уйдет.
Модель данных
Внутри все держится на нескольких понятиях.
Строка выписки - одна операция, как ее прислал банк: свой счет, номер документа, дата, сумма, назначение платежа и две стороны, плательщик и получатель, у каждой ИНН, название и номер счета. Сумма хранится всегда положительной, знак несет отдельное поле - направление, приход или расход. Так знак живет в одном месте, и его невозможно перепутать в двух местах по-разному.
Кошелек - место, где лежат деньги компании. Расчетный счет привязан к своему кошельку. Есть и виртуальные кошельки: топливная карта, рекламный кабинет, вклады. У виртуального кошелька может быть своя единица, например литры, и курс к рублю.
Перевод - пара записей: списание с одного кошелька и зачисление на другой. Это ни доход, ни расход, деньги просто переехали.
Статья операции - что это за деньги: тип, подтип, категория, доход или расход. Ставится поверх любой операции, в том числе прихода.
Разнесение - на что легли деньги: заказ, конкретная единица техники, сотрудник. Одну строку можно разнести на несколько объектов частями.
Шаблон разнесения - правило, которое само ставит статью или привязывает строку к счету клиента, если строка подходит под условия.
Цепочка получается такая: операция банка -> статья -> разнесение по объектам. Отчеты о прибыли берут расходы из статей и разнесения, а не из самой выписки. Поэтому строка и ее статья не складываются дважды: сумма живет в одном месте, смысл - в другом.
Направление: по счетам, а не по ИНН
Казалось бы, направление - самое простое поле. Часть банков присылает его явно, дебет или кредит. Другие не присылают, и CRM угадывала его по ИНН: плательщик - мы, значит расход.
Правило работало годами, пока не встретилось с деньгами между своими счетами. При возврате вклада плательщик - та же компания, просто со своего депозитного счета. При переводе между двумя своими расчетными счетами - тоже. По ИНН все это выглядит как расход. Возвраты вкладов ложились в реестр минусом, и проверка истории нашла около 900 неразнесенных строк, где входящая операция записана исходящей.
Правильный ответ знает сам счет выписки. Получатель - наш счет, значит приход. Плательщик - наш счет, значит расход. ИНН остается запасным правилом на случай, когда номеров счетов нет.
final class StatementDirection
{
/** 1 - деньги ушли от нас, 0 - пришли к нам. */
public static function outgoing(
?bool $bankOutgoing, // направление от банка, если он его прислал
string $ownAccount, // наш счет, по которому пришла выписка
string $payerAccount,
string $payeeAccount,
?string $payerInn,
?string $ownInn,
): int {
if ($bankOutgoing !== null) {
return $bankOutgoing ? 1 : 0;
}
$own = self::key($ownAccount);
$payer = self::key($payerAccount);
$payee = self::key($payeeAccount);
if ($own !== '' && $payer !== $payee) {
if ($payee === $own) return 0;
if ($payer === $own) return 1;
}
return $payerInn === $ownInn ? 1 : 0; // запасное правило
}
/** Двадцать цифр счета: один банк дописывает к номеру БИК через косую черту. */
private static function key(string $account): string
{
return substr(preg_replace('/\D/', '', $account) ?? '', 0, 20);
}
}
Нормализация номера здесь не мелочь: без приведения к двадцати цифрам сравнение молча проваливается в правило по ИНН, и все возвращается на круги своя.
Код и исправление истории - два разных шага. Новое правило выкатилось сразу. Историю чинит отдельная миграция: она переворачивает в приход только нетронутые строки, где получатель - сам счет выписки, а плательщик - другой счет. Разнесенные строки она не трогает, иначе у разнесения поменялся бы смысл. Строки, у которых та же операция уже лежит приходом, тоже не трогает: переворот сделал бы из них дубль. Таких нашлось три, их разбирает человек.
Текст миграции подготовил агент, отбор он проверил чтением на боевой базе. А вот запускать массовую правку истории или нет - решение не агента, а мое. Отката у такой миграции нет, поэтому она пишет в вывод полный список измененных строк, чтобы любую из них потом можно было разобрать.
Идемпотентность и дубли
Раз сегодняшний день загружается каждые десять минут, каждая строка выписки приходит много раз. Первое требование: повторная загрузка не должна ничего менять.
Для этого у строки должен быть отпечаток - набор полей, одинаковый у всех повторов одной операции и разный у разных операций. Мы долго жили с ключом из ИНН сторон, номера документа, суммы, назначения и направления. И как раз направление сыграло злую шутку.
Возьмем перевод между двумя своими счетами, оба подключены к CRM. Банк пришлет две строки: исходящую в выписке первого счета и входящую в выписке второго. ИНН, номер, сумма и назначение у них совпадают, различает их только направление. Пока направление угадывалось по ИНН, обе половины получали «расход», и входящая отбрасывалась как дубль исходящей. Деньги на втором счете просто не появлялись.
Вывод: в отпечаток обязательно входит свой счет, по которому пришла строка, и направление. Без своего счета две половины одного перевода неразличимы.
/**
* Отпечаток строки выписки: сколько бы раз банк ни прислал операцию,
* отпечаток один. Две половины перевода между своими счетами - разные.
*/
function statementFingerprint(string $ownAccount, int $outgoing, array $line): string
{
$acc = static fn (?string $a): string
=> substr(preg_replace('/\D/', '', (string) $a) ?? '', 0, 20);
return hash('sha256', implode('|', [
$acc($ownAccount), // чей это экземпляр строки
$outgoing, // 1 - расход, 0 - приход
trim((string) ($line['number'] ?? '')), // номер документа
$line['date'], // дата исполнения, Y-m-d
(int) round(abs((float) $line['sum']) * 100), // копейки, без шума float
$acc($line['payerAccount'] ?? null),
$acc($line['payeeAccount'] ?? null),
mb_strtolower(trim((string) ($line['comment'] ?? ''))),
]));
}
Если банк отдает собственный уникальный идентификатор операции, все проще: он и есть ключ. Но так делают не все, и полагаться на это при нескольких банках нельзя.
Второй вывод: уникальность должен держать индекс в базе, а не код. Проверка «поищем такую строку, и если ее нет - вставим» работает, пока загрузка одна. Стоит фоновому процессу и ручной кнопке «Обновить данные» сработать одновременно, и обе вставки проходят мимо проверки. Окно узкое, дубль виден и правится, но лечится это только уникальным индексом. У строк выписки эта гонка пока записана как известное ограничение, а новые таблицы мы сразу делаем правильно: движение по вкладу держит уникальный ключ на строку выписки, и повторная обработка той же строки упирается в базу и тихо считается «уже было».
Третий вывод - про окно между кодом и данными. Новое правило направления выкатилось раньше, чем исправилась история. Если в этом окне тот же день загрузится снова, рядом со старой строкой «расход» появится новая «приход», и деньги задвоятся. Поэтому загрузка умеет лечить: если та же операция уже лежит с противоположным направлением и ее никто не трогал руками, старая строка переворачивается на месте, а вторая не заводится. Разнесенную строку загрузка не меняет, но и дубль рядом не создает.
Вклады: свои деньги в другом месте
Теперь можно вернуться к клиенту с депозитами. Модель получилась такая.
У кошелька расчетного счета появляется виртуальный кошелек «Вклады». Он заводится сам с первым вкладом и виден тем же людям, что и сам счет: деньги те же, просто лежат в другом месте.
- Размещение - перевод со счета на «Вклады». Та же пара записей, что при пополнении топливной карты: списание с кошелька счета несет строку выписки, зачисление ложится на «Вклады».
- Возврат - обратный перевод, зеркальная пара на входящей строке.
- Проценты - доход. Они отмечаются в сделке, но кошелек «Вклады» не двигают.
Тонкости начинаются, когда жизнь отходит от схемы «положили - вернули».
Пары «туда-обратно» мы не ищем вообще. Все строки складываются по номеру сделки, который банк пишет в назначении платежа. Два размещения и одно частичное снятие - остаток сделки посчитается сам. Если банк вернул тело вместе с процентами одной суммой, с «Вкладов» снимается только остаток сделки, а излишек остается на строке неразнесенным: его видно, и его разносят доходом. Отмена заявки на вклад приходит со своим номером документа, но номер исходной сделки стоит в хвосте назначения, и отмена сводится с той же сделкой.
Если у сделки хоть одна строка лежит с неверным направлением, сделка ждет целиком. Провести размещение без возврата значило бы показать на вкладе деньги, которые давно вернулись. Лучше честно не показать ничего, чем показать неправду.
Вклады разбираются при загрузке выписки, до шаблонов разнесения. Порядок важен: иначе шаблон, подошедший по словам в назначении, успел бы разнести размещение вклада обычной тратой. Для истории есть кнопка «Найти вклады в выписках» на карточке кошелька. Разнесенные руками строки она не трогает, повторный запуск ничего не задваивает.
В итоге остаток кошелька «Вклады» - это ответ на вопрос «сколько у нас сейчас на депозитах». И это число можно сверить с суммой остатков депозитных счетов в банке.
Проценты на остаток расчетного счета - другое дело. Сделки у них нет, это просто доход, и его разносит обычный шаблон.
Шаблоны разнесения
Все, что не перевод и не вклад, разбирают шаблоны. Шаблон - это условия и действие.
Условия бывают двух видов. Ключевые слова в назначении платежа объединяются по «ИЛИ»: строка подходит, если в ней есть хотя бы одно. Остальные признаки объединяются по «И»: плательщик, получатель, ИНН любой из сторон, расчетный счет, направление, вилка суммы. Так описывается «все приходы от этого контрагента» или «расходы на этот счет в таких-то пределах».
Действие - статья операции или оплата счета клиента.
Несколько правил оказались важнее самих условий:
- Ручное не перезаписывается. Если к строке уже что-то привязано руками, шаблоны ее не трогают вовсе, ни дополнить, ни поправить.
- Шаблон без условий не берет ничего. Пустые ключевые слова и пустые условия означали бы «разнести все подряд» одним основанием. Это молчаливая порча всей выписки, а защита от нее стоит одной строки кода.
- Порядок явный. Шаблоны проверяются по приоритету, строку забирает первый подошедший.
- Подошедший шаблон траты решает сам. Если он не смог разнести строку, например не нашел кошелек, строка остается человеку, а не уходит следующему, более общему шаблону, который разнес бы ее не туда.
- Оплата счета - только попытка. Такой шаблон ищет счет клиента по ИНН, сумме и номеру из назначения. Подходящий счет ровно один - привязывает. Ни одного или несколько - выбирать за человека нельзя, и строку пробует следующий шаблон.
- Приход - доход или возврат затраты - выбирает человек. Одна и та же по форме приходная строка бывает и тем, и другим: проценты банка - доход, вернувшийся от поставщика аванс - возврат.
НДС шаблон выделяет сам: ставку или сумму налога он находит в назначении платежа («в т.ч. НДС 20%») и пишет статью суммой без налога, а ставку хранит рядом. С виртуальными кошельками похожая история: при пополнении топливной карты можно зачислить сумму без НДС. Заплатили 120 с НДС 20 % - на карту легло 100.
Вот как в итоге учитываются основные типы операций:
| Операция в выписке | Что это на самом деле | Как учитывать |
|---|---|---|
| Оплата клиента | Аванс или выручка | Привязка к счету клиента, выручка - по закрытому заказу |
| Оплата поставщику | Расход или аванс поставщику | Статья операции, разнесение на заказ или машину |
| Возврат от поставщика | Уменьшение расхода | Возврат затраты, не доход |
| Пополнение топливной карты или кабинета | Перевод в свой кошелек | «Пополнить кошелек», расход - по факту списания |
| Размещение вклада | Свои деньги в другом месте | Перевод на кошелек «Вклады» |
| Возврат вклада | Возврат своих денег | Обратный перевод, излишек сверх тела - проценты |
| Проценты по вкладу или на остаток | Доход | Статья дохода по шаблону |
| Перевод между своими счетами | Ничего не произошло | Пара записей, ни дохода, ни расхода |
| Зарплата | Расход на людей | Разнесение в зарплату сотрудника |
Что решает человек
Автоматика хороша там, где ответ однозначен. Направление по номерам счетов, отпечаток строки, сделка по номеру в назначении, единственный подходящий счет клиента - тут машина работает лучше человека и не устает.
Человеку остается то, где нужен смысл или где ошибка стоит дорого:
- Завести шаблон. Решить, что все платежи этому поставщику - запчасти для конкретной машины, может только тот, кто знает бизнес.
- Разобрать неоднозначное. Несколько подходящих счетов, неопознанный плательщик, строка, под которую не нашлось шаблона.
- Массово править историю. Даже идеально проверенная правка сотен строк на бою - отдельное решение.
- Подогнать систему к банку. Об этом ниже.
И главное: автоматика никогда не переразносит то, что разнес человек. Если строка тронута руками, система может только сказать, что с ней что-то не так.
Сверка с банком
Самый честный тест всей конструкции - сравнить, сколько денег на счете по данным банка и сколько выходит по нашим данным.
Первый соблазн - сравнить остаток в банке с балансом кошелька. Это ошибка. Баланс кошелька смешивает в одну цифру наличные, залоги и незакрытые счета, и на боевых данных разница с банком выходила на порядки. Сравнивать надо величины из одного мира: остаток в банке и сальдо по выпискам того же счета, то есть приходы минус расходы.
Но и они совпадут не всегда, и это не ошибка. Выписки есть только с момента подключения счета, а что лежало на нем до того, система не знает. Поэтому рядом с сальдо мы показываем дату первой выписки. У счета, подключенного с нуля, цифры сходятся до копейки. У подключенного позже разница равна остатку на момент подключения. Расхождение сверх этого - уже работа: либо выписка не загрузилась, либо движение завели руками, а в банке его нет.
Бывает, что сравнивать нечего: по счету приходят выписки, но не приходит остаток, потому что так настроено подключение. Тогда сверка помечается как неполная, и разница не показывается вовсе. Нарисованное по неполным данным расхождение выглядит как потерянные деньги и пугает зря.
Для истории, накопившейся до подключения, есть разовое выравнивание: запись на разницу между банком и системой с фиксированным комментарием, видимая в реестре наравне со всем остальным. Сделать ее может только владелец аккаунта. Если дать такую кнопку бухгалтерии, несходящиеся цифры можно будет молча подогнать, и сигнал о расхождении потеряет смысл.
Счет при этом из базы не удаляется, даже если подключение банка удалили: на него ссылается вся история операций. Подключение помечается удаленным, а строки выписок остаются. Это то же правило, что в заметке не удалять, а помечать.
Что я вынес
- Выписка - журнал движений, а не отчет. Прибыль считается из заказов и разнесенных статей.
- Свои деньги в другом месте - это перевод. Вклад, топливная карта, второй расчетный счет - кошельки, а не расходы.
- Направление определяет счет, а не ИНН. ИНН одинаков на обоих концах перевода между своими счетами, а номер счета - нет.
- Отпечаток включает свой счет и направление, а уникальность держит база. Иначе дубли или потери - вопрос времени.
- Ошибка банка - не пустой день. Ноль операций должен означать только ноль операций.
- Ручное неприкосновенно. Автоматика дополняет человека, но не переписывает его.
- Код и данные выкатываются порознь. Код должен пережить окно, когда история еще старая, а массовую правку истории запускает человек.
- Сверять надо величины из одного мира. Остаток банка - с сальдо выписок, а не с балансом кошелька, и с поправкой на дату подключения.
По отдельности ничего из этого не сложно. Сложно помнить, что за каждой строкой выписки стоит вопрос «что это было на самом деле», и не давать машине отвечать на него наугад.