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-тестами, потому что ошибка ломает цены разом по всему каталогу.
Синхронизация с 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 понимала, о каком юните речь, в ней заводится кастомное поле «Unit ID» — бэк создаёт группу полей и само поле при подключении, а дальше по нему сшивает сделку с юнитом (у ресейл-юнитов свой префикс, чтобы не путать их с первичкой). Если синк запускается через админку, застройщик сначала видит превью того, что произойдёт после отправки, — с мэппингом полей, а не вслепую.
Виджет в карточке сделки
Самое ценное в интеграции — не сам синк, а виджет внутри CRM: пикер юнита прямо в карточке сделки. Менеджер работает сделку и, не выходя из своей CRM, выбирает юнит из каталога застройщика — карточка юнита открывается тут же, во встроенном фрейме. Как сформулировал интегратор, всё начинает работать «с того момента, когда менеджеры понимают, что к сделке надо привязать юнит»: без этой привязки синку не за что зацепиться. Виджет один, а хост определяется на лету — amoCRM или Kommo, — и под каждый читается своё поле «Unit ID».
Доступ: JWT, guards, роли
Права на бэке закрыты через JWT и guards. Токен ходит в куке, guard проверяет его на каждом защищённом маршруте, а дальше — проверка прав под роли: застройщик, менеджер, агент, клиент. Кто что видит и может — не разбросано по коду if-ами, а собрано в матрицу прав; guard спрашивает у неё, можно ли этому пользователю трогать этот ресурс. Незарегистрированный, зарегистрированный и проверенный посетитель видят разное — и это тоже настройка застройщика, а не хардкод.
CI/CD своими руками
Сборку и деплой приложений я настраивал сам в GitLab CI. Пайплайн проходит по стадиям — сборка образов, прогон миграций базы, деплой; после выката сервис перезапускается на кластере. Четыре приложения — бэкенд, фронт, админка и сервис интеграций — собираются и катятся своими джобами. Это реальный самостоятельный опыт, а не «видел рядом»: до этого деплой был ручным, и я довёл его до кнопки.
Что получилось
Невидимый слой, на котором стоит остальной продукт. Любая валюта мира и корректный каскадный пересчёт, закрытый тестами там, где ошибка недопустима. Живые статусы из CRM, которые не врут, потому что собираются из всех сделок на юните разом. И виджет в карточке сделки, из-за которого синком вообще начинают пользоваться.