AI Budget Assistant и eKsięgowy AI (Accounting ИИ-агент не просто отвечает: он планирует шаги и сам пользуется инструментами — обращается к системам, читает документы и затем выполняет задачу. Полное определение → на eksiegowyai.pl) — два отдельных продукта. Первый ведёт личные, семейные и бизнес-финансы: расход можно надиктовать голосом, сфотографировать чек или поймать из уведомления банка. Второй — бухгалтерский агент для польских компаний, работающий с данными wFirma и KSeF — это обязательная в Польше государственная система для выставления и получения электронных счетов напрямую через налоговую. Полное определение →. Пользователи, у которых есть оба, просили о том, что звучит просто: пусть расходы фирмы, записанные в AI Budget, доходят до бухгалтерии — а личные остаются личными.

В этой статье рассказываем, как мы это построили: собственный OAuth-сервер в AI Budget, одно подключение сразу к нескольким счетам, бухгалтерскую сторону со сверкой расходов с документами затрат и — подробнее всего — что нашли ревью кода и аудиты безопасности до того, как что-либо попало в продакшен. Все описанные исправления уже развёрнуты.

Зачем публичное API, а не интеграция «по-быстрому»

Самый короткий путь выглядел бы так: два наших сервера, общий секрет, приватный эндпоинт. Мы выбрали длинный. У AI Budget появилось открытое, бесплатное и документированное API, которым может пользоваться любой разработчик: приложение регистрируется самостоятельно, без ручной проверки. eKsięgowy AI — просто его первый клиент: к нему относятся как к собственному приложению (значок проверки, повышенные лимиты), но он проходит через ту же дверь, что и все остальные.

Это решение задало ещё несколько. Пользователь подключает счета кнопкой «Подключить AI Budget» в стандартном OAuth-потоке. Первая фаза — только расходы; доходы, бюджеты и цели будут позже. Вместо вебхуков клиент опрашивает ленту изменений. Подключить счёт могут только его владельцы и редакторы. Документация — на английском, польском и русском.

Но главное требование было продуктовым, а не техническим: бухгалтерия должна видеть расходы компании строго отдельно от личных. Вся остальная архитектура вытекает из него.

Собственный OAuth-сервер в AI Budget

Сервер авторизации работает внутри API — это интерфейс, через который две системы обмениваются данными автоматически, без выгрузок в Excel и копирования руками. Полное определение → AI Budget (NestJS), без стороннего провайдера идентификации. Используется поток authorization code, а PKCE с методом S256 обязателен для каждого клиента — и для публичного, и для конфиденциального, у которого есть секрет. Экран согласия живёт в приложении, по адресу app.ai-budget.pl/authorize, и свёрстан и для телефона, и для широкого окна браузера.

Токены непрозрачные и хранятся только в виде хешей SHA-256 — утечка базы не даёт рабочих Токен — это фрагмент текста, обычно часть слова: ИИ-модели измеряют в токенах и длину запроса, и стоимость, поэтому счёт приходит за токены, а не за вопросы. Полное определение →. У них узнаваемые префиксы: aba_at_ у токена доступа, aba_rt_ у токена обновления, aba_cs_ у секрета клиента и aba_ci_ у идентификатора. По префиксу сканеры секретов и люди сразу понимают, что именно утекло. Токен доступа живёт час, токен обновления — 90 дней и ротируется при каждом использовании. Если кто-то предъявляет уже израсходованный токен обновления, мы считаем это признаком кражи и отзываем всё подключение.

Коды авторизации лежат в Redis и потребляются операцией GETDEL в режиме fail-closed: обменять код можно ровно один раз, даже если два запроса гонятся за ним в одну миллисекунду.

Скоупы работают по принципу deny-by-default: expenses:read, expenses:write, receipts:read и categories:read. Маршрут, не объявивший нужный скоуп, отклоняется — забытый декоратор означает отказ, а не открытую дверь. Кроме того, роль того, кто выдал согласие, проверяется на каждом запросе: если владелец понизит его до наблюдателя, следующий вызов получит 403 grant_inactive.

Всё это стоит за одним флагом, PUBLIC_API_ENABLED, по умолчанию выключенным — тогда любой маршрут отвечает 404. Включили мы его 2 ноября 2026 года, после изменения в nginx: отдельная зона лимитов для /public/ и /oauth/ и заголовки frame-ancestors 'none' и X-Frame-Options DENY на хосте приложения, чтобы экран согласия нельзя было встроить в чужую страницу и выманить клик (clickjacking). Лимиты считаются отдельно на подключение и на приложение: по умолчанию 120 запросов в минуту на подключение и 1200 на приложение, для проверенных приложений — 600 и 10 000.

Лента изменений вместо вебхуков

Синхронизация идёт через /public/v1/expenses/changes: пагинация по ключу (updated_at, id), надгробия (tombstones) для удалённых расходов и 10-секундная страховочная задержка по часам базы данных. Без неё медленно коммитящаяся транзакция могла бы потеряться: метку времени она получает в момент записи, а видимой становится позже — когда курсор клиента уже ушёл дальше. Задержка заставляет ленту отдавать только строки, достаточно старые, чтобы всё до них уже было закоммичено. Отсчёт по часам базы, а не сервера приложения, убирает из уравнения рассинхрон часов.

Вторая деталь: правка дочерней строки — тега, проекта, разбивки по категориям, позиции чека — сдвигает updatedAt родительского расхода, чтобы лента доставила его снова. Иначе клиент так и не узнал бы, что у расхода поменялась разбивка.

Создание расхода принимает заголовок Idempotency-Key с областью действия на клиента (а со второй фазы — ещё и на счёт), так что запрос, повторённый после сетевой ошибки, не создаёт дубликат.

Зашифрованные счета, документация и экраны

В AI Budget есть уровни сквозного шифрования текстов. Счета уровня 2 и выше подключить нельзя вообще. Счета уровня 1 доступны только на чтение, и каждое поле, зашифрованное на этом уровне, возвращается как null. Список таких полей привязан тестом к реестру полей шифрования приложения — если кто-то добавит новое шифруемое поле, тест упадёт раньше, чем API начнёт отдавать его шифротекстом.

Документация OpenAPI (Swagger) — по адресам /public/docs/en, /public/docs/pl и /public/docs/ru, рядом руководство на трёх языках; тесты следят, чтобы во всех версиях был одинаковый набор ключей. В настройках приложения есть экраны «Разработчикам» и «Подключённые приложения», пользователь получает письмо о подключении приложения (не чаще раза в день на пару «приложение — счёт»), а у администратора есть страница API-приложений: проверка, лимиты и блокировка.

Одно подключение — несколько счетов

Первая фаза предполагала одно подключение на один счёт. Бухгалтерии нужно больше: счёт компании и личные одновременно, выбранные один раз, на экране согласия, галочками.

В данных это связующая таблица oauth_grant_accounts (согласие ↔ счёт) и одно активное согласие на пару (клиент, пользователь), которое обеспечивает частичный уникальный индекс. Миграция перенесла существующие строки и слила дублирующиеся согласия. Здесь ревью безопасности поймало тонкую ошибку первой версии: объединение скоупов сливаемых согласий молча расширило бы согласие только на чтение до записи. Поэтому выжившее согласие сохраняет собственные скоупы.

Счёт указывается заголовком X-Account-Id, и правило считает счета, входящие в согласие, а не те, что сейчас доступны:

Почему считаем выданные, а не активные? Если бы один из двух счетов временно стал недоступен, правило «активных» решило бы, что остался один, и начало молча направлять туда запросы без заголовка. Запрос никогда не должен попасть в счёт, который интегратор не назвал.

GET /public/v1/accounts возвращает по каждому счёту: id, название, тип, базовую валюту, доступ (read или read_write), флаг активности и причину неактивности. Именно тип позволяет бухгалтерии автоматически отличить компанию от личного. Название, тип и валюта скрываются, когда пользователь перестаёт быть участником счёта. Ответ на запрос токена всегда содержит account_ids, а account_id — только у подключения с одним счётом. Повторное согласие отмечает уже подключённые счета и заменяет весь набор.

Отдельный случай — совладельцы. Совладелец общего счёта видит подключение только со своими счетами и никогда — с личными счетами того, кто его создал. Его «Отключить» убирает только его счета; отозвать подключение целиком может только автор согласия. Поток с двумя счетами покрыт сквозным тестом на настоящих Postgres и Redis.

Поток подключения: eKsięgowy AI выдаёт адрес авторизации со state и PKCE, пользователь отмечает счета в AI Budget, а код обменивается только после проверки, что state принадлежит вошедшему пользователю.

Бухгалтерская сторона: три метки

На стороне eKsięgowy AI каждый счёт получает одну из трёх меток: компания, другой бизнес или личное. Метка ставится автоматически по типу счёта в AI Budget — единственный бизнес-счёт становится «компанией», при нескольких выбирает пользователь, остальные считаются личными — и её можно поменять вручную. Ровно одну «компанию» на подключение обеспечивает частичный уникальный индекс в базе, а не код приложения. «Другой бизнес» виден в чате, но никогда — в панели компании и в сверке.

Приватность: копируем только то, что и так попадёт в учёт

Владелец задал вопрос, который определил эту часть: «а копия — это не потеря приватности?». Ответ: копия — да, но только там, где она оправдана. В бухгалтерскую базу зеркалируется только счёт компании — он всё равно окажется в учёте. Личные данные и данные «другого бизнеса» читаются из AI Budget вживую и хранятся в Redis, зашифрованные AES-256-GCM, не дольше 300 секунд. Они никогда не пишутся в базу, не логируются и не попадают в долговременную память агента.

Зеркало компании синхронизируется через ленту изменений каждые 15 минут, по запросу и при открытии панели: курсор на каждый счёт, надгробия и еженедельная полная пересинхронизация, потому что AI Budget не сообщает о расходе, перенесённом на другой счёт. Смена метки с «компании» на другую атомарно удаляет зеркало. Токены зашифрованы при хранении, обновляются только под пользовательской блокировкой в Redis, а новая пара сохраняется раньше всего остального.

Сверка: «расходы без документа»

Самая полезная для бухгалтерии функция сравнивает расходы компании с начала прошлого месяца по сегодня с документами затрат из wFirma, которые берутся вживую. Совпадение — это та же валюта, сумма брутто, равная с точностью до 1 гроша (0,01), и дата документа в окне ±7 дней. Сопоставление один к одному — каждый документ подходит максимум к одному расходу. Расходы с меньшим числом кандидатов сопоставляются первыми, чтобы «лёгкий» расход не забрал единственный документ «трудного». Ничьи решает сходство названий после нормализации, с отбрасыванием правовых форм вроде «sp. z o.o.» или «S.A.».

Формулировки выбраны сознательно: «подходящий документ не найден», а не «нет фактуры» — алгоритм не знает, существует ли фактура, он знает только, что не нашёл пары. Позицию можно отклонить как «документ не нужен». Долги и дебиторка по разделённым счетам — не расходы и исключены везде, а суммы по категориям следуют разбивке по категориям.

В панели есть блок компании на главной (этот месяц против прошлого, категории, карточка сверки) и отдельная страница «Личное — вне учёта компании». Каждое неизвестное состояние названо прямо: не подключено, нужно переподключить, идёт синхронизация, синхронизация не удалась, нет wFirma. Никогда не нули и никогда не «всё сопоставлено».

Чат: семь инструментов

Агент получил 7 инструментов: список счетов, получение расходов, сводка расходов, создание, правка и удаление расхода и расходы без документа. Каждая цифра в ответе подписана счётом. Запись требует явно указанного счёта и подтверждения, зашифрованные (только для чтения) счета отказывают в записи с объяснением, а каждое создание несёт свой Idempotency-Key. В разговоре, открытом для организации, инструменты видят только счёт компании; то же в групповых чатах Telegram.

Что нашли ревью и как мы это закрыли

Каждую задачу проверял свежий ревьюер, который не видел, как она писалась. Потом вся ветка прошла ещё одно ревью, а каждый репозиторий — отдельный аудит безопасности. Ниже все серьёзные находки, каждая по схеме: проблема, почему это важно, исправление.

НаходкаЧем грозилоИсправление
IDOR в /sync AI Budget (существовал раньше)Участник одного счёта мог менять связи и видеть позицию чека в чужом счётеКаждая запись идёт через счёт родителя; чужое — «не найдено»
Фишинг согласия: state привязан к тому, кто началТокены жертвы сохранились бы под чужим аккаунтомОбмен кода только на аутентифицированной странице; state обязан принадлежать вошедшему
Markdown-картинки в чате, нет CSPБраузер сам отправил бы личные суммы на чужой серверБез удалённых картинок; очистка всех строк из AI Budget; вывод инструментов — данные
Личные данные в трейсах LangSmithЛичные цифры во внешнем хранилищеПриватность трейса решается до корневого прогона; входы и выходы затираются
Израсходованный токен обновления после сбоя сохраненияAI Budget отозвал бы всё подключениеПовтор под блокировкой, needs_reconnect, 60-секундный маркер, compare-and-swap
Отключение во время синхронизацииУстаревшие расходы навсегда в цифрах компанииСначала локальный отзыв; SELECT … FOR UPDATE на каждой записи в зеркало
Тихая смена компанииЗеркало настоящей компании стёртоНеактивный, но видимый счёт сохраняет метку
«Всё сопоставлено» вместо «неизвестно»Ложное «у каждого расхода есть документ»Различимые состояния; зеркало полное только после полного прогона

1. IDOR в существующем /sync AI Budget

Проблема. Пока добавлялось API, ревью нашло ошибку, существовавшую и раньше: в синхронизации приложения строки связей (расход ↔ тег, проект, разбивка по категориям) и позиции чека искались по идентификатору без проверки счёта вызывающего.

Почему это важно. Участник любого счёта, знающий идентификаторы из чужого счёта, мог удалять или привязывать там связи, а конфликт версий возвращал ему позицию чека из чужого счёта. Публичное API расширяло доступ к данным, поэтому это нужно было закрыть до запуска.

Исправление. Отдельная задача (ABA-708): каждая запись находит строку через счёт родителя, а чужие строки отвечают «не найдено» — так же, как несуществующие.

2. Фишинг согласия и подмена кода авторизации

Проблема. State в OAuth был привязан к пользователю, который начал подключение, а не к браузеру, который его завершает. Можно было начать подключение в своём аккаунте eKsięgowy AI и отправить ссылку авторизации жертве.

Почему это важно. Если бы жертва подтвердила согласие, её токены AI Budget сохранились бы под чужим аккаунтом — доступ к чужим финансам, а со стороны жертвы только один клик «Разрешить».

Исправление. Колбэк API больше не обменивает код. Он передаёт code и state на страницу веб-приложения, которая отправляет их POST-запросом на аутентифицированный эндпоинт. Сервер забирает state через GETDEL и требует, чтобы он принадлежал вошедшему пользователю, иначе state_mismatch и ничего не сохраняется. Страница сразу убирает code и state из адреса (history.replaceState), работает с политикой no-referrer и исключена из аналитики, чтобы код не утёк ни в историю, ни в заголовок Referer, ни в счётчики.

3. Утечка через внедрённый markdown

Проблема. Названия продавцов, описания, названия счетов и категорий приходят из AI Budget, а написать их может участник счёта, чек или уведомление банка. Чат отрисовывал markdown-картинки, а у сайта не было CSP.

Почему это важно. Текст, который уговорил бы модель вставить в ответ картинку с адресом, содержащим личные суммы, заставил бы браузер сам отправить эти цифры на чужой сервер — без единого клика.

Исправление. Три слоя. Чат никогда не загружает удалённые картинки. Каждая строка из AI Budget очищается до того, как попадёт к модели: удаляются markdown-ссылки и картинки, URL и HTML, длина ограничивается. Правило в Промпт — это инструкция, которую вы даёте ИИ-модели: от её точности зависит, будет ответ полезным или общим. Полное определение → говорит, что вывод инструмента — данные третьей стороны, а не инструкции. Повторное ревью нашло ещё одно неочищенное место — названия категорий в сообщении об ошибке инструмента записи — и его тоже исправили.

4. Личные данные в трейсах LangSmith

Проблема. Трейсинг (внешнее хранилище) получал вывод инструментов, а в следующих итерациях ещё и ответы модели, пересказывающие личные цифры, текст для синтеза речи и воспроизводимую историю.

Почему это важно. Редакции по именам инструментов не хватало: флаг «этот прогон использовал личные данные» известен только после выполнения инструмента, а корневой прогон к тому времени уже отправлен.

Исправление. Решение принимается до отправки корневого прогона: трейс считается приватным, если разговор уже был помечен или инструменты AI Budget подключены со скоупом всех счетов. В приватном трейсе входы и выходы каждого прогона затираются. Тесты сделали герметичными — локальный .env разработчика с включённым трейсингом заставил один тест обратиться к LangSmith (тот ответил 403), — а определение трейсинга учитывает все четыре имени переменных окружения.

5. Повторное использование токена обновления после неудачного сохранения

Проблема. Если сохранение в базу падало после успешного обновления, следующий процесс предъявил бы уже израсходованный токен обновления.

Почему это важно. AI Budget совершенно правильно считает это кражей и отзывает всё подключение. Наша собственная защита от кражи отрезала бы честного клиента.

Исправление. Сохранение повторяется под блокировкой, а если и это не удалось, подключение получает статус needs_reconnect. Обновление с неизвестным исходом (таймаут, сетевая ошибка, 5xx) ставит 60-секундный маркер, чтобы никто не повторил попытку с, возможно, израсходованным токеном. Обновляем только по 401 invalid_token, а запись нового токена — compare-and-swap по сохранённому токену обновления.

6. Отключение во время синхронизации

Проблема. Страница, полученная до отключения, могла записаться уже после очистки зеркала — со старым номером поколения синхронизации, который последующая полная пересинхронизация никогда не подметает.

Почему это важно. Устаревшие расходы навсегда остались бы в цифрах компании — в том месте, которое должно попасть в учёт.

Исправление. Подключение сначала локально помечается отозванным. Каждая запись в зеркало проходит через охранную проверку на блокировке строки (SELECT … FOR UPDATE), требующую «метка компании И активное подключение». Очистка зеркала сначала обновляет строку счёта, поэтому ждёт страницу, которая сейчас в пути.

7. Тихая смена компании

Проблема. Когда доступ к счёту компании временно ограничивался, автоматическая разметка повышала другой бизнес-счёт до «компании» и стирала зеркало настоящей компании.

Почему это важно. Временная проблема с правами тихо подменила бы содержимое панели компании данными другого бизнеса.

Исправление. Счёт, который лишь неактивен, но по-прежнему виден, сохраняет метку компании (и исключается из цифр). Метку теряет только счёт, который больше не виден вовсе.

8. «Всё сопоставлено» вместо «неизвестно»

Проблема. Сверка возвращала пустой список, когда wFirma не была подключена или не отвечала, а первая синхронизация показывала частичное зеркало как готовое.

Почему это важно. Инструмент чата прочитал бы пустой список как «у каждого расхода есть документ» — ложное успокоение там, где речь идёт о расходах, уменьшающих налог.

Исправление. Различимые состояния везде, от API до интерфейса и инструментов агента. Зеркало считается полным только после полного прогона.

Как мы работали

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

Первая фаза API — 17 задач, несколько счетов — 10, бухгалтерская интеграция — 18. Раундов исправлений по итогам ревью были десятки; у большинства задач была хотя бы одна находка уровня Important. Итоговое число тестов: API AI Budget — 4970 модульных тестов, мобильное приложение — 2370, API eKsięgowy AI — 781, его веб-приложение — 171, плюс 13 интеграционных тестов на настоящей базе, 12 тестов Playwright и 10 сквозных сценариев публичного API, включая поток с двумя счетами.

Вывод, который мы для себя делаем: ревьюер, который не писал код, находит то, чего автор не увидит, — здесь, среди прочего, фишинг согласия и расширение скоупов при слиянии согласий. Самые дорогие находки были не синтаксическими ошибками, а неверными допущениями о том, кто завершает поток и что значит пустой список.

Что дальше и что ещё открыто

В API появятся доходы, бюджеты и цели; вебхуки — возможно, позже. Пилот работает на собственных счетах владельца.

Три вопроса мы сознательно зафиксировали, а не стали чинить сразу: подтверждение деструктивных инструментов агента на стороне сервера (сейчас оно на уровне промпта и касается устройства всего приложения, а не только этой интеграции), CSP для всего сайта и то, что сообщения из групповых чатов Telegram всё ещё используют личную историю разговора пользователя.

Подробнее о том, как устроены оба продукта, — в статьях Как AI Budget Assistant применяет ИИ и Как работает Accounting AI Agent, а сам продукт — на странице AI Budget Assistant.

Частые вопросы

Кто может пользоваться публичным API AI Budget?
Любой разработчик. API бесплатное и документированное, приложение регистрируется самостоятельно, без ручной проверки. Подключить счёт может только его владелец или редактор. Первая фаза — расходы; доходы, бюджеты и цели появятся позже.
Хранит ли eKsięgowy AI мои личные расходы?
Нет. В бухгалтерской базе есть копия только счёта с меткой «компания». Личные данные читаются из AI Budget вживую и хранятся в Redis, зашифрованные AES-256-GCM, не дольше 300 секунд — без записи в базу, логов и долговременной памяти агента.
Почему лента изменений, а не вебхуки?
Лента изменений с пагинацией по (updated_at, id) и надгробиями позволяет клиенту самому догнать пропущенное от сохранённого курсора. 10-секундная задержка по часам базы не даёт потерять медленно коммитящуюся транзакцию. Вебхуки, возможно, появятся позже.
Как API выбирает счёт, если подключение охватывает несколько?
Никак. При одном счёте в согласии заголовок X-Account-Id необязателен; при нескольких его отсутствие даёт 400 account_required, а идентификатор вне согласия — 403 account_not_granted. Запрос никогда не попадает в счёт, который интегратор не назвал.