Frontend Engineer · Июль 2025 — 2026

UnitBox · Движок финмоделей

TypeScript · React · Nest.js · vitest

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

Инвестор покупает не юнит, а доходность — и именно доходность оставалась последней несведённой частью платформы: каталог, цены и статусы уже жили в одном месте, а аргумент про возврат вложений так и лежал в приватном Excel каждого застройщика. Продуктовый и дизайн-спек этой фичи вёл мой партнёр по стартапу: интервью с агентами, разбор таблиц, макеты V1 и V2, критерии приёмки. Моя часть — вычислительный движок: расчёт выручки, расходов, налогов, IRR, NPV, ROI и сценария выхода, весь каскад наследования параметров и API-контракт, на котором это живёт.

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

Почему это тяжелее, чем кажется

Финмодель здесь — не отчёт, а инструмент убеждения, под которым агент подписывается своим именем. Агент пересчитывает цифры застройщика руками, потому что модель, обещающая 20% там, где рынок даёт 9–10%, стоит ему клиента навсегда. Значит цена ошибки в движке — не косметическая мелочь, а неверный оффер, ушедший инвестору. Поэтому каждое место, где легко ошибиться, у меня закрыто арифметикой и тестами, а не «на глаз».

Разбор чужих Excel

Начали с разбора реальных таблиц застройщиков. Форматирование у всех разное, а структура — одна и та же: цена и план оплаты, аренда (ADR и загрузка, иногда разбитая на сценарии), расходы, рост стоимости, итоговые метрики. Из этого разбора и родился контракт движка: один универсальный набор блоков, к которому сводится модель любого застройщика. Дальше движок реализует именно эти блоки — не «как у застройщика N», а инвариант, общий для всех.

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

Самое рискованное допущение

Всё держалось на одном допущении: одна универсальная структура покрывает модель любого застройщика. Если оно ложно — вся дальнейшая разработка бессмысленна, поэтому его проверили до движка, самым дешёвым способом: рабочий MVP-калькулятор за один день. Не макеты — макеты проверяют, нравится ли экран; только реальные числа, введённые в реальный калькулятор, могли опровергнуть покрытие. Проверочный цикл потом не выключался: V1 и V2 валидировались так же — берём реальный проект застройщика, вводим данные, движок обязан воспроизвести его таблицу. Где не сходилось — менялась структура, не данные. «Воспроведи реальный Excel» так и осталось определением готовности для каждого релиза.

V1: диапазон вместо числа

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

Админка V1: четыре параметра уровня проекта покрывают большинство случаев — сознательный потолок сложности.
Админка V1: четыре параметра уровня проекта покрывают большинство случаев — сознательный потолок сложности.

В основе расчёта — годовая выручка юнита. Базовая формула: Revenue = 365 × ADR × Occupancy × 0.9, где 0.9 — вычет 10% налога. Дальше из выручки последовательно вычитаются расходы и налоги — об этой воронке ниже. Все вводные редактируются в UI, и метрики — ROI, IRR, payback — пересчитываются на лету, без перезагрузки страницы.

Почему V1 сломался

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

V2: каскад наследования

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

Значения текут сверху вниз; переопределил поле — оно отвязывается от родителя, и правки родителя его больше не трогают; сброс возвращает наследование. У юнита это два независимых блока: отдельно наследуется ADR-профиль (ставка, загрузка, доп. расходы), отдельно — сама финмодель (каналы, сезонность, расходы). Так снимается противоречие между гибкостью (любой юнит может отличаться) и простотой (никто не заполняет 200 полей).

Значение резолвится снизу вверх: юнит → конфигурация → тип → корпус → проект. На схеме — три уровня, которые менеджер трогает ежедневно.
Значение резолвится снизу вверх: юнит → конфигурация → тип → корпус → проект. На схеме — три уровня, которые менеджер трогает ежедневно.
Система связывания в движении: переопределяешь поле — блок отвязывается, сброс — снова наследует.

Пример из спека, который движок обязан отработать: проект задаёт агентскую комиссию 5%. Конфигурация переопределяет её на 7% — правки проекта её больше не касаются. Отдельный юнит внутри получает индивидуальные 10%, независимые даже от своей конфигурации. По этому же каскаду наследуется каждый денежный объект — ADR-профили, планы оплат, платные доп. модули — а сами цены можно ставить в график плановых повышений, который оффер показывает как будущий рост.

Движок расходов

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

Самое важное поле — самое незаметное: отсрочка (expense_delay + привязка к покупке или к сдаче). Реальные расходы вроде ремонта стартуют через 12–24 месяца после сдачи. Модель без отсрочки завышает ровно те первые годы, в которые инвестор смотрит внимательнее всего — именно это приукрашивание продукт и существует, чтобы убрать. В движке первое списание считается через один полный период после базовой даты, с коэффициентом неполного первого года, чтобы расход не начислялся задним числом.

Конструктор расходов: любая категория добавляется, удаляется и параметризуется.
Конструктор расходов: любая категория добавляется, удаляется и параметризуется.

Сезонность и split каналов

Когда включена сезонность, годовая выручка считается не одной формулой, а помесячно: базовый месячный доход (ADR × 365 × загрузка ÷ 12) умножается на 12 коэффициентов сезона и суммируется. Плоское среднее превращается в честный профиль года.

Каналы продаж — direct и OTA — со своими комиссиями и своей долей. Здесь важная деталь реализации: комиссия канала начисляется только на долю выручки, прошедшую через этот канал, а не на всю выручку. Ошибиться тут значит завысить расходы на каждой прямой брони — как раз такую ошибку и ловит верификация ниже.

Сезонность и split каналов: 12 помесячных коэффициентов и разбивка direct/OTA превращают плоское среднее в честный прогноз.
Сезонность и split каналов: 12 помесячных коэффициентов и разбивка direct/OTA превращают плоское среднее в честный прогноз.

Планы оплат по фазам

Как инвестор платит, оказалось таким же разнообразным, как то, сколько он зарабатывает. Поэтому планы — тоже конструктор: до пяти на проект, каждый типизирован как полная оплата или рассрочка, со скидкой, которая переоценивает всё ниже по цепочке. План идёт четырьмя фазами (бронь, стройка, передача ключей, после сдачи), каждая настраивается: платить каждые N месяцев, платить до сдачи или произвольными периодами, которые обязаны сойтись в 100%.

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

Планы оплат заданы один раз на уровне проекта — вершина каскада.
Планы оплат заданы один раз на уровне проекта — вершина каскада.

Как считаются IRR и NPV

План оплаты — не косметика поверх цены: он двигает реальный график денежных потоков. Из расписания платежей движок строит датированные оттоки инвестора (buildInvestorOutflows), масштабированные так, что их сумма в точности равна итоговой инвестиции. NPV дисконтирует каждый отток по его году, а не единой суммой в нулевой момент — поэтому рассрочка, отодвигающая деньги во времени, честно поднимает и NPV, и IRR: деньги, отложенные во времени, стоят дешевле. IRR ищется итеративно методом Ньютона — на каждом шаге считаем NPV при текущей ставке и её производную, сдвигаем ставку, ограничивая её сверху и снизу, пока NPV не сойдётся к нулю. Годовой доход внутри при этом распределяется с учётом роста аренды и неполного года сдачи, а не размазывается поровну.

Инверсия: график ведёт таблицу

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

Витрина для инвестора: каждое число прослеживается до вводной, которую агент может изменить.
Витрина для инвестора: каждое число прослеживается до вводной, которую агент может изменить.
Дашборд инвестора V1, размеченный по спеку: логика расчёта важнее конкретного макета.
Дашборд инвестора V1, размеченный по спеку: логика расчёта важнее конкретного макета.

Сценарий выхода

Модель отвечает и на вопрос «а если я продам через год / три / пять?». Сценарий выхода собирает три потока: вход по цене покупки, доход от аренды за годы владения и продажа с учётом роста стоимости, комиссии продажи и переменных издержек. Из ошибок ранней версии здесь важна одна: экзит-сценарий считал только аренду — вошёл за $400K, заработал $200K на аренде, продал за $600K, а прибыль, ROI и payback считались от трети картины. После правки продажа входит в расчёт полноценно.

Сценарий выхода: покупка → аренда → продажа, чтобы модель отвечала «что если продам в третий год».
Сценарий выхода: покупка → аренда → продажа, чтобы модель отвечала «что если продам в третий год».

Проверка против XNPV/XIRR

Инструмент убеждения с неверной математикой хуже таблиц, которые он заменяет: он отмывает ошибку и ставит под ней имя агента. Поэтому до того, как движком начали продавать, реальную инвестицию разложили поквартально и прогнали руками через XNPV и XIRR — ручной эталон дал истинную внутреннюю ставку в коридоре 12.96–14.84% в зависимости от даты входа. Эту ground-truth сверку вёл партнёр; моё дело было привести выход движка в этот коридор.

Сравнение с эталоном вскрыло ошибки, которых демо никогда бы не показала, — и каждую я поправил в движке:

  • комиссии каналов начислялись на всю выручку вместо доли, прошедшей через канал, — завышая расходы на каждой прямой броне
  • расход, заданный «12 раз в год», начислялся 1200 раз
  • включение сезонности обнуляло поля, которых не должно было касаться
  • экзит-сценарий учитывал только доход от аренды, теряя продажу

Отдельно — именование, в продукте, вся ценность которого в честности про числа: строка «расходы с выручки» на деле вычиталась из прибыли и была переименована, а русский лейбл IRR уехал на ставку дисконтирования — два разных числа под одним именем. Правило, которое из этого осталось: писать спецификацию расчёта разобранными примерами. Ставка «раз в год» — это столько-то в месяц и столько-то в год; «12 раз» — столько-то. Три случая, которые человек сверяет с экраном.

Тесты на расчёт

Критичные места движка закрыты unit-тестами на vitest. В том числе — что смена плана оплаты действительно двигает IRR и NPV (рассрочка, отодвигающая деньги, поднимает обе метрики, тогда как ROI и общий доход не меняются — таймингом cash не создаётся), что датированные оттоки при t=0 совпадают с lump-sum ради обратной совместимости, и разбивка по годам вокруг даты сдачи. Здесь ошибка в расчёте — это неверная доходность в оффере клиенту, поэтому такие места без теста не остаются.

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

Проверка всего этого — созвон в Zoom. Агент шарит экран, инвестор спрашивает, что будет при худшем сезоне, агент меняет загрузку у него на глазах и смотрит, как двигается payback. Ровно поэтому модель должна была быть редактируемой, а не просто верной: возражение закрывается на звонке, а не письмом через неделю. Движок выкатили, его подхватили 80% проектов платформы, и один застройщик отправил с приложенной моделью 1000+ офферов.

80% проектов платформыАдопшн
1000+ у одного застройщикаОфферов с моделью
1 день от идеиMVP-проверка
12.96–14.84% (эталон)Коридор IRR