Frontend Engineer · Июль 2025 — 2026

UnitBox · Интеграции и мультивалюта

Nest.js · PostgreSQL · TypeORM · webhooks · cron · JWT · GitLab CI

Что это и что я делал

Платформу и админку UnitBox видно снаружи. Этот кейс — про слой, которого не видно: бэкенд на Nest, на котором всё держится. Мультивалюта, синхронизация с CRM застройщиков, авторизация и сборка-деплой. Это был наш стартап: продукт, сегмент и дизайн вёл партнёр, а вот этот невидимый слой — моя инженерная часть, и она честно fullstack, а не только фронт.

Дальше — четыре системы, каждая по одной схеме: что делает, как собрана, зачем нужна.

Мультивалюта: любая валюта мира

Застройщики Бали продают в разных валютах — кто в долларе, кто в рупии, кто в дирхаме. А платформа показывает и сравнивает всё вместе, в одном каталоге. Поэтому валюта — не фиксированный список из десятка вариантов, а любая валюта мира: застройщик задаёт дефолтную для проекта, и она прокидывается везде, где есть цена — в юниты, в юнит-типы, в планы оплат.

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

Зачем нормализовать в доллар

USD-нормализация нужна ради скорости. Платформа показывает, фильтрует и сортирует в одной валюте. Если бы каждая цена лежала только в своей — на каждый запрос каталога бэку пришлось бы пересчитывать десятки проектов из их валют в валюту витрины, строить min/max по бюджету, а при сортировке пересчитывать заново. Это «много валют в много» на каждый чих.

С нормализацией пересчёт делается один раз — в момент записи цены. Дальше в базе рядом с ценой всегда лежит её USD-версия, и фильтры с сортировкой работают по готовому числу. Быстро и предсказуемо, независимо от того, сколько валют намешано в каталоге.

Каскадный пересчёт при смене валюты

Самое опасное место — смена валюты проекта. Застройщик переключает проект, например, с доллара на рупию, и все цены внутри должны пересчитаться каскадом — по всем юнит-типам и юнитам разом, — не меняя реальную стоимость. Юнит за 200 000 долларов после смены валюты должен показывать эквивалент в рупиях, а не «200 000 рупий».

Здесь легко всё сломать. Перепутать направление пересчёта — и было $100k, стало 100k IDR: в тысячи раз дешевле, и не в одном юните, а по всему каталогу застройщика разом. Ошибка тихая: цифры на месте, просто неправильные, и её замечают, когда оффер уже ушёл инвестору.

Поэтому именно эти расчёты я закрыл unit-тестами — точечно, там, где ошибка недопустима: пересчёт цены и каскадное изменение цен при смене валюты проекта. Не тесты ради галочки на всё подряд, а покрытие того участка бизнес-логики, где баг = сломанные цены везде.

Что даёт нормализация

  • Фильтры и сортировка на платформе считают по готовой USD-цене — быстро, без пересчёта валют на каждый запрос.
  • Смена валюты проекта каскадом пересчитывает все цены внутри, не трогая реальную стоимость юнита.
  • Самый опасный участок — каскадный пересчёт — закрыт unit-тестами, потому что ошибка ломает цены разом по всему каталогу.
Валюта проекта в настройках — не фиксированный список, а любая валюта мира; на витрине всё сравнивается в USD

Синхронизация с CRM

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

Синхронизация двусторонняя, и синхронятся две сущности — юниты и сделки. Продали или перевели юнит в бронь у нас — данные ушли в CRM. Изменили статус сделки в CRM — подтянулось к нам, и юнит на витрине сам меняет цвет. Ту работу, которую агенты весь день делали вручную — «спроси менеджера, свободен ли юнит», — синк просто убрал.

Почему несколько CRM

У застройщиков CRM разные, единого стандарта на рынке нет. Поэтому интеграция не под одну систему, а вариативная: подключаешь ту, с которой уже работаешь — amoCRM, Kommo или GenCRM. Под каждую свой контроллер и свой сервис синка со своей авторизацией (OAuth-токены, их рефреш, очередь запросов под rate-limit конкретной CRM), но наружу для застройщика это одна и та же кнопка «подключить».

Вебхуки и почему только смена статуса

Апдейты из CRM приходят через вебхуки — CRM сама сообщает, когда что-то изменилось, и нам не нужно её опрашивать. Разные CRM отдают события по-разному, поэтому под каждую способ получения свой; но принцип один — реагировать на изменение сделки.

Первая версия подписки чуть не убила интеграцию на боевом клиенте. Мы подписались на любое изменение сделки, и на созвоне у клиента IT-специалист забронировал юнит в Kommo — витрина догнала статус секунд за пятнадцать, а на следующий день вебхук вообще перестал приходить. Причина в подписке: на базе в шесть тысяч контактов занятой менеджер генерит десятки событий в минуту, поток упирается в лимит, и CRM просто отключает хук.

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

Как держать вебхуки живыми

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

Статус юнита — из всех сделок сразу

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

Поэтому статус юнита не берётся из последнего события, а пересобирается из всех сделок на нём. Собираем каждую сделку с этим юнитом, приводим их статусы к нашим трём — свободен, бронь, продан — и берём приоритетный: продан старше брони, бронь старше свободного. Отдельная проверка не даёт понизить статус: продано не откатывается в бронь из-за случайного события. А остальным сделкам на том же юните уходит заметка — «юнит зарезервирован, выберите другой», прямо в их карточки. Каждая смена статуса пишется в историю изменений юнита с автором «amocrm».

Живые статусы юнитов на витрине — приходят из CRM застройщика

Смена юнита в сделке

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

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

Превью перед синком и мэппинг полей

Чтобы CRM понимала, о каком юните речь, в ней заводится кастомное поле «Unit ID» — бэк создаёт группу полей и само поле при подключении, а дальше по нему сшивает сделку с юнитом (у ресейл-юнитов свой префикс, чтобы не путать их с первичкой). Если синк запускается через админку, застройщик сначала видит превью того, что произойдёт после отправки, — с мэппингом полей, а не вслепую.

Виджет в карточке сделки

Самое ценное в интеграции — не сам синк, а виджет внутри CRM: пикер юнита прямо в карточке сделки. Менеджер работает сделку и, не выходя из своей CRM, выбирает юнит из каталога застройщика — карточка юнита открывается тут же, во встроенном фрейме. Как сформулировал интегратор, всё начинает работать «с того момента, когда менеджеры понимают, что к сделке надо привязать юнит»: без этой привязки синку не за что зацепиться. Виджет один, а хост определяется на лету — amoCRM или Kommo, — и под каждый читается своё поле «Unit ID».

Доступ: JWT, guards, роли

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

CI/CD своими руками

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

Что получилось

Невидимый слой, на котором стоит остальной продукт. Любая валюта мира и корректный каскадный пересчёт, закрытый тестами там, где ошибка недопустима. Живые статусы из CRM, которые не врут, потому что собираются из всех сделок на юните разом. И виджет в карточке сделки, из-за которого синком вообще начинают пользоваться.

любая, хранится в USDВалюты
крон раз в 12 часовКурсы
двусторонний, 3 системыСинк с CRM
из всех сделок сразуСтатус юнита