Batumi Travelплан v1
Главное

План разработки — на утверждение

Агрегатор туристических услуг в Батуми. Версия плана v4, 25.09.2026. Это верхнеуровневый документ. Под каждым пунктом есть подробный — ссылки по ходу текста и в README.


1. Что мы строим

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

Референс — Viator. Дизайн — дженерик в стиле Airbnb, с возможностью позже поменять целиком.

2. Три решения, которые я принял за тебя

Каждое можно оспорить, но каждое обосновано в документах.

Решение 1. Платежи в скоупе, но не в первом релизе. Оба ТЗ говорят «платежи не в MVP», твой конспект встречи — «ключевые деньги проекта, обязательно». Победил конспект. Но подключать эквайринг раньше, чем есть что продавать, бессмысленно, а договор с банком оформляется долго. Поэтому: денежную модель проектируем с первого дня, боевой эквайринг включаем в спринте S6, а заявку в банк подаём немедленно, параллельно с кодом. → contradictions §1

Решение 2. Три релиза вместо одного большого MVP. Заказчик описывает MVP как всю цепочку целиком — это значит строить всё и показать один раз в конце. Режу так:

  • R1 «Витрина и заявка» (S1–S5): сайт, каталог, поиск, карточки, отзывы, партнёрские QR, заявка через оператора. Деньги пока офлайн. Это уже продаваемый продукт — можно лить трафик и проверять спрос.
  • R2 «Деньги» (S6–S7): предоплата картой, комиссии, промокоды, возвраты, леджер, CRM оператора.
  • R3 «Самообслуживание» (S9): личные кабинеты гида и поставщика, B2B-бронирование, Google Calendar.

→ scope §1

Решение 3. Гид и поставщик в первой итерации ведутся оператором вручную. В конспекте встречи «первая итерация» стоит у клиентов, партнёров и админов, но не у гидов. При этом без гида бронировать нечего. Компромисс: сущности есть, публично гид виден и выбираем, но своего логина у него нет — им управляет оператор. На старте это 5–20 человек, ручное ведение надёжнее сырого кабинета. → contradictions §2

3. Стек

Слой Выбор Одной фразой почему
Язык TypeScript везде твоя экспертиза, общие типы фронта и бэка
Сайт и кабинеты Next.js 15 + Tailwind + shadcn/ui SEO обязателен, дизайн легко перешатать
Админка / CRM Refine SPA CRM — это не автогенерируемый CRUD
Бэкенд NestJS навязанная структура заменяет недостающий опыт
БД PostgreSQL 17 + Prisma транзакции, блокировки, лучшие миграции
Очереди Redis + BullMQ напоминания, ретраи уведомлений, сверка
Аутентификация Better Auth своё писать нельзя, это утечка данных
Файлы и видео Cloudflare R2 + CDN нулевая плата за исходящий трафик
Эквайринг BOG iPay (+ TBC/Flitt вторым рельсом) ~60% рынка Грузии, умеет сплит и холд
Уведомления WhatsApp Cloud API, Resend, Telegram WhatsApp — главный канал по встрече
Хостинг Hetzner + Coolify, Postgres managed ~$70–300/мес

Главное, что тут стоит отметить. Я выбрал NestJS, а не «всё в Next.js». Он многословнее и медленнее на старте. Но он заставляет держать бизнес-логику в отдельном слое, а не в React-компонентах, — и именно это защищает проект, где ты сам пишешь, что в бэкенде «скорее не шаришь». Цена ошибки в расчёте суммы заказа в компоненте — реальные деньги.

→ полный ресёрч с альтернативами, 28 решений списком

4. Как обеспечивается надёжность

Ты просил «во всех местах уметь фоллбекаться и бэкапиться». Конкретно:

Деньги. Двойная запись (леджер) с первого дня — не поле «баланс», а бухгалтерские проводки, которые невозможно незаметно разбалансировать. Все суммы — целые числа в копейках, ни одного float (правило линтера). Идемпотентность на всех операциях: двойной клик не создаёт вторую бронь. Источник правды по платежу — вебхук банка, плюс сверочная задача каждые 15 минут на случай потерянного вебхука. Комиссия зафиксирована в момент брони — изменение процента в админке не переписывает прошлое. → payments-and-money.md

Овербукинг. Два рубежа: транзакция с блокировкой строки слота и CHECK-констрейнт в самой БД.

Фоллбеки. WhatsApp упал → через 15 мин email → через 30 мин задача оператору. Эквайер упал → бронь живёт, ссылка на оплату действует сутки. Redis лёг → бронирование продолжает работать. Google Maps исчерпал квоту → статичная картинка. Полный список — flows §7.

Бэкапы. PITR непрерывно (потеря максимум 5 минут), ежедневный шифрованный дамп в другое облако, 12 месячных копий. И главное: раз в месяц реальный тестовый рестор на чистый сервер по календарю. Бэкап, который ни разу не восстанавливали, — это не бэкап. → infrastructure.md

Окружения. staging.<домен> — как ты и предлагал. С обезличенными данными: скрипт анонимизации обязателен, боевых телефонов и email на staging не будет. Плюс preview-окружение на каждый pull request.

Перс. данные. Закон Грузии с 2024 года сближен с GDPR; туристы из ЕС подводят нас и под сам GDPR. Телефоны и email шифруются на уровне колонок, логи чистятся от PII автотестом, просмотр карточки туриста оператором пишется в аудит-лог, удаление — псевдонимизация (леджер нельзя ломать). Юридические документы должен согласовать грузинский юрист — это не то, что решается кодом. → security-and-pii.md

5. Риск №1: «принимать российские карты»

Это требование в текущем виде технически невыполнимо, и я обязан сказать это до начала работ, а не в четвёртой вехе.

Карты Visa/Mastercard, выпущенные в РФ, не работают за пределами России с 2022 года — это отключение на уровне международных платёжных систем, его не обходит ни один эквайер. «Мир» в Грузии не принимается.

Реальные варианты:

  1. Карты россиян, выпущенные в третьих странах (Казахстан, Армения, Киргизия, ОАЭ) — работают штатно, ничего делать не нужно. Скорее всего, заказчики имели в виду именно это.
  2. Отдельный российский платёжный канал (ЮKassa и подобные) — технически возможен, но вывод на зарубежный счёт, валютный контроль и санкционные риски — вопрос к юристу, не ко мне.
  3. Ручной канал переводов (Korona Pay и аналоги) — реализуем как «ручной рельс» с подтверждением оператором, но без мгновенного подтверждения.
  4. Стейблкоины — нишево.

Что делаю я: архитектура с первого дня поддерживает несколько платёжных рельсов одновременно с маршрутизацией по стране карты. Включить дополнительный канал — это конфиг, а не переписывание. Что нужно от заказчика: ответ на вопрос Q3 и, при варианте 2, консультация юриста.

→ payments §3

6. Порядок работ

Оценок времени в плане нет — они здесь ничего не описывают. Код пишется непрерывно, а дата запуска определяется не объёмом кода, а чужими очередями на согласование: договор эквайринга с банком, верификация Meta Business, ответы заказчиков, готовность контента. Ни одну из них я ускорить не могу.

Значение имеет порядок и зависимости:

Спринт Что получаем Что блокирует
S1 Фундамент каркас, авторизация, CI, staging —
S2 Каталог экскурсии, расписание, страницы, поиск —
S3 Бронирование заявки, оператор, отмены Q6, Q10
S4 Уведомления email, telegram, напоминания, отзывы WhatsApp ждёт Meta
S5 Сайт и партнёры R1 — продаваемая витрина контент и фото
S6 Деньги эквайринг, комиссии, возвраты, леджер sandbox банка, Q4, Q5
S7 CRM и B2B R2 — принимаем оплату —
S8 Закалка ЗАПУСК боевой эквайринг, юрист
S9 Кабинеты гида R3 — самообслуживание —

Практический вывод один: очереди на согласование надо занимать параллельно с разработкой, а не после неё (§7).

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

→ порядок работ и блокеры, бэклог и что резать при нехватке времени

7. Что нужно начать на этой неделе, параллельно с кодом

Это не разработка, но без этого проект встанет:

  1. 🔴 Юрлицо в Грузии и счёт в банке — без него нет ни эквайринга, ни WhatsApp Business, ни легальной обработки перс. данных.
  2. 🔴 Заявка на интернет-эквайринг в Bank of Georgia — самый длинный срок в проекте, недели.
  3. 🔴 Заявка на верификацию Meta Business для WhatsApp — тоже недели, плюс модерация каждого шаблона сообщения.
  4. 🔴 Ответы на блокирующие вопросы (ниже).
  5. 🟡 Сбор контента: тексты, фото, видео. Типичная причина срыва запуска — код готов, страницы пустые.
  6. 🟡 Консультация юриста по перс. данным и по приёму платежей из РФ.

8. Что мне нужно от тебя и от заказчиков

Полный список — open-questions.md, 22 вопроса с приоритетами. Самые блокирующие:

Заказчикам:

  • Q3 — что именно значит «российские карты» (см. §5)
  • Q4 — кто владелец цены, размер комиссии и предоплаты
  • Q5 — из чьих денег возврат при отмене и чарджбэке
  • Q6 — точный текст условий отмены (без него не собирается страница)
  • Q10 — сколько операторов, какой SLA подтверждения заявки
  • ✅ Q12 закрыт — экскурсия принадлежит платформе, её ведут разные гиды. Порождает Q12a: net-rate одинаковый для всех гидов на экскурсии или индивидуальный, и знают ли гиды, что программу смогут вести другие

Тебе (частично закрыто):

  • ✅ Q18 — работаю непрерывно я, план пересчитан в спринтах
  • ✅ Q22 — NestJS согласован
  • ✅ Q12 — экскурсия принадлежит платформе, зафиксировано
  • 🟡 Q19 — есть ли дизайнер и контентщик
  • 🟡 Q20 — бюджет инфраструктуры (~$70–300/мес) и кто платит

9. Чего в плане сознательно нет

Мобильное приложение · SMS · вывод баллов партнёра в деньги · динамическое ценообразование · подарочные сертификаты · чат внутри платформы (общение идёт в WhatsApp) · реально работающие другие страны (модель заложена, наполняем только Аджарией) · интеграция с Viator и GetYourGuide как каналами дистрибуции (это отдельный проект).


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

Контекст

Свод требований (структурированный)

Источники: два ТЗ от заказчиков (сгенерированы ИИ, частично дублируют друг друга), дополнение от второй заказчицы (партнёры), рукописный конспект встречи, наброски страниц (главная, карточка экскурсии, карточка категории, карточка направления), ссылки на референсы.

Приоритет источников при конфликте: конспект встречи > дополнение заказчицы > второе ТЗ (более полное) > первое ТЗ.


1. Продукт

Онлайн-агрегатор туристических услуг. Стартовый регион — Батуми и Аджария, с заложенным масштабированием: Аджария → Грузия → другие направления.

Две стороны:

  • B2C — турист находит и бронирует экскурсию, выбирает гида.
  • B2B — гид бронирует у локальных поставщиков услуги, из которых собирает программу.

Цепочка: Партнёр → Турист → (Оператор) → Гид → Поставщик. Поставщик никогда не взаимодействует с туристом напрямую через платформу.

Главный референс — Viator (заказчики опирались на него на созвоне). Дополнительно: GetYourGuide, Klook, Tripster, GuideGO, ToursByLocals, Withlocals (последний — как пример видео на главной).

2. Роли (5 уровней ЛК из конспекта встречи + админ)

Роль В первой итерации Суть
Турист (клиент) ✅ да ищет, бронирует, платит, оставляет отзыв
Партнёр ✅ да приводит трафик по ссылке/QR, получает баллы
Оператор / куратор ✅ да принимает заявки, связывает клиента и гида, сопровождает
Администратор (мы) ✅ да полный доступ, «шатает всё»: цены, проценты, промокоды
Гид вторая итерация* создаёт экскурсии, бронирует услуги поставщиков
Поставщик вторая итерация* размещает услуги, подтверждает заявки гидов

* В конспекте встречи гиды и поставщики не отмечены как «первая итерация». Но без гида бронировать нечего. См. contradictions.md §2 — решение: в первой итерации гид/поставщик заводятся администратором вручную, самостоятельная регистрация и полноценный ЛК — во второй.

Отдельно из встречи: гиды бывают «на локациях» (например, на винодельне) — то есть один человек может быть одновременно гидом и сотрудником поставщика. Роль ≠ пользователь, нужна модель «пользователь ↔ несколько ролей в организациях».

Функционал по ролям

Турист: поиск экскурсий и программ · карточки экскурсий · просмотр и сравнение гидов (профиль, язык, опыт, рейтинг, отзывы) · выбор даты, времени, количества участников · бронирование · предоплата · история заказов · отзывы после экскурсии.

Партнёр: ЛК · персональная ссылка и/или QR-код · фиксация источника перехода · промокод, который вводит турист · статистика переходов, туристов, бронирований · начисление баллов на счёт партнёра.

Оператор/куратор: входящие заявки · назначение гида · подтверждение/отклонение · связь с клиентом · сопровождение брони до завершения · ведение в CRM.

Гид: профиль (опыт, языки, специализация) · создание экскурсий и программ · календарь и доступность · приём броней от туристов · поиск поставщиков · бронирование услуг поставщиков для своей программы · история · работа с отзывами.

Поставщик: профиль · размещение услуг (описание, фото, цены, расписание, доступность) · приём заявок от гидов · подтверждение/отмена · календарь · история.

Админ: управление всеми участниками и контентом · модерация пользователей, экскурсий, услуг, отзывов · управление бронированиями · изменение цен, процентов комиссии, промокодов · аналитика · CRM для операторов.

3. Каталог

Категории (объединённый список из обоих ТЗ): экскурсии · индивидуальные программы · групповые программы · трансферы · винные туры · дегустации · гастрономические активности · мастер-классы · фермы и производства · активный отдых · прочие туристические услуги.

Категории верхнего уровня из наброска главной: 🍷 Гастрономия · 🏛 Культура · 🥾 Природа · ⛵ Море · 👨‍👩‍👧 Семья · ❤️ Романтика.

Направления: Батуми, Тбилиси, Кахетия, … (расширяемо).

Типы поставщиков: винодельни · рестораны и кафе · дегустации · мастерские · фермы · производства · активности · прочие локальные объекты.

4. Бронирование

Выбор даты · выбор времени · количество участников · проверка доступности · создание заявки · подтверждение · отмена · история броней.

Два независимых потока броней:

  • B2C: турист → экскурсия → гид (через оператора).
  • B2B: гид → услуга поставщика.

5. Платежи

⚠️ Оба ТЗ пишут «платежи не входят в MVP». Конспект встречи — прямо наоборот: «КЛЮЧЕВЫЕ ДЕНЬГИ ПРОЕКТА, ДОЛЖНО БЫТЬ ОБЯЗАТЕЛЬНО». Побеждает конспект.

Требования из встречи:

  • на сайте только предоплата (не полная оплата);
  • принимать российские карты И зарубежные карты;
  • зачисление на зарубежный счёт;
  • «может, Корона что-то такое предоставляет» — надо смотреть;
  • на первой итерации допустимо подключение тестовой (sandbox) API эквайринга;
  • в админке — управление процентами комиссии и промокодами.

Из ТЗ (как отложенное, но проектируемое заранее): комиссионная модель, взаиморасчёты с гидами и поставщиками, партнёрское вознаграждение.

6. Уведомления

Приоритет из встречи:

  1. WhatsApp — главный формат взаимодействия. При наличии технической и юридической возможности.
  2. Telegram — упомянут на встрече.
  3. Email — фоллбек; в первой итерации можно не реализовывать, но задизайнить.
  4. SMS — «сложно и того не стоит», не делаем.

События: новое бронирование · отмена · напоминание · новый отзыв · (добавляю от себя) подтверждение оплаты, возврат, запрос отзыва после экскурсии.

7. Отзывы и рейтинги

Отзывы туристов о гидах · рейтинг гидов · возможность оставить отзыв только после завершения экскурсии · модерация отзывов администратором. Отзывы также агрегируются на уровне категории и направления (из набросков страниц).

8. Интеграции (из встречи)

  • WhatsApp (в первую очередь), email, telegram, возможно SMS — уведомления.
  • Google Calendar: «записался — получил уведомления во все важные моменты».
  • Google Maps: локации с турами; отдельно — карта с точками партнёров.
  • CRM для админов — «накидать».

9. Формат и дизайн

  • web/mobile-first, адаптив под смартфон. Мобильного приложения на первом этапе нет.
  • Referenсe по вёрстке — Viator (и на мобиле, и на десктопе).
  • Нет названия, логотипа, дизайна и даже выделенной ЦА. → делаем дженерик в стиле Airbnb.
  • Требование: возможность полностью перешатать внешний вид или выкинуть дизайн и поставить новый с нуля. → дизайн-токены + API-first, ноль бизнес-логики во фронте.

10. Структура публичных страниц (из набросков)

Главная: шапка (Направления · Категории · Войти · Меню) → hero-видео («Открой Грузию глазами местных») → поиск → фильтры (язык, время дня, продолжительность, цена, рейтинг, категория, направление, спец. условия) → категории → направления → популярные экскурсии (фото/видео, название, ★рейтинг, цена, иконка «кому подходит») → идеи/подборки → «почему мы» → отзывы → FAQ.

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

Карточка категории: большое фото/видео → название → описание (2–3 предложения) → популярные направления → подкатегории/форматы → популярные экскурсии → все экскурсии + фильтры → подборки → отзывы о категории → FAQ → CTA.

Карточка направления: видео города → название → описание → категории внутри направления → популярные впечатления → популярные места → подборки → отзывы о направлении → FAQ → CTA.

Дополнительно: карта с точками партнёров.

11. Архитектурный принцип от заказчика

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

12. Главная задача MVP (формулировка заказчика)

Партнёр привёл туриста → турист нашёл экскурсию → выбрал гида → гид подобрал поставщиков → забронировал услуги → провёл экскурсию → получил отзыв.

Контекст

Противоречия во входных данных и принятые решения

Входные материалы собирались в разное время и разными людьми (часть — генерация ИИ, которую заказчики, вероятно, не вычитали). Ниже — каждое расхождение, моё решение и цена ошибки, если решение неверное.


1. Платежи: «не в MVP» vs «ключевые деньги проекта»

  • Оба ТЗ: «Платежи, комиссии и финансовые взаиморасчёты на первом этапе не входят в MVP».
  • Конспект встречи: «На сайте только предоплата… КЛЮЧЕВЫЕ ДЕНЬГИ ПРОЕКТА, ДОЛЖНО БЫТЬ ОБЯЗАТЕЛЬНО».

Решение: платежи в скоупе. Но разносим по времени: доменное ядро и леджер проектируем с первого дня, боевой эквайринг подключаем в вехе M4, до этого — sandbox. Причина не в приоритете, а в сроках: договор с банком-эквайером подаётся в неделю 1 и рассматривается неделями.

Цена ошибки: если платежи реально не нужны — потеряли ~3 недели на леджер. Если нужны, а не заложили — переписывать модель бронирования целиком. Асимметрия очевидна → закладываем.

2. Гиды и поставщики: есть ли у них ЛК в первой итерации

  • Конспект встречи: «первая итерация» помечена у клиентов, партнёров и админов. У гидов и поставщиков — нет.
  • Оба ТЗ: ЛК гида и поставщика в первом этапе есть.

Решение: компромисс, который снимает противоречие без потери сценария.

  • Итерация 1: сущности гида и поставщика есть в БД и в админке, их заводит и редактирует оператор/админ. Публично гид виден и выбираем. Своего логина у гида нет — он получает уведомления в WhatsApp и подтверждает через оператора.
  • Итерация 2: самостоятельная регистрация, ЛК гида, календарь, B2B-бронирование услуг поставщиков гидом напрямую.

Обоснование: на старте гидов будет 5–20 человек, ручное ведение оператором дешевле и надёжнее, чем сырой ЛК. Это же снимает проблему «гид не заходит в систему».

3. Оператор/куратор — роль, которой нет ни в одном ТЗ

В ТЗ цепочка Турист → Гид. В конспекте встречи появляется операторы = кураторы (приём заявок) — связующее звено между клиентом и гидами.

Решение: оператор — первоклассная роль, и это меняет модель брони. Бронь не уходит гиду автоматически. Она падает в очередь оператора (PENDING_OPERATOR), оператор назначает гида и подтверждает. Автоподтверждение закладываем как флаг на уровне экскурсии (instantConfirm), но по умолчанию выключено.

Цена ошибки: если оператор не нужен — просто включаем instantConfirm глобально, код уже есть.

4. Партнёры: «финансовые расчёты не реализуются» vs «зачисление баллов»

  • ТЗ №2: «На первом этапе финансовые расчёты и вознаграждение партнёров не реализуются, но система должна технически фиксировать источник привлечения».
  • Дополнение второй заказчицы: «зачисление баллов на счёт партнёра».

Решение: реализуем баллы, а не деньги. Баллы — это запись в леджере в отдельной валюте PTS, без вывода. Вывод/конвертация в деньги — отдельная веха, требует отдельного юридического решения (выплаты физлицам в Грузии = налоги). Так и требование заказчицы закрыто, и юридический риск не создан.

5. SMS

  • ТЗ: SMS в списке каналов.
  • Встреча: «сложно и того не стоит».

Решение: канал SMS существует в архитектуре как реализация интерфейса NotificationChannel, но провайдер не подключается. Включается конфигом за день.

6. Email

  • Встреча: «фоллбек, в первой итерации можно не брать в работу, но задизайнить».

Решение: email делаем в первой итерации. Расхожусь с заказчиком осознанно: WhatsApp Business API требует верификации Meta Business (недели, нужно юрлицо), и до её прохождения email — единственный рабочий канал. Стоимость реализации email через Resend — меньше дня. Это страховка, а не расширение скоупа.

7. «Российские карты + зарубежные карты + зарубежный счёт»

Это не противоречие во входных данных, а противоречие с реальностью. Российские Visa/Mastercard не работают за пределами РФ с 2022 года, «Мир» в Грузии не принимается. Один эквайер эту задачу не решает физически. Развёрнуто — в payments-and-money.md §3. Это риск №1 проекта.

8. Языки интерфейса

Нигде не указаны явно. Но: заказчики русскоязычные, туристы в Батуми — преимущественно русско- и англоязычные, юрисдикция грузинская.

Предположение (требует подтверждения): RU + EN на запуске, KA заложить в модель (поля переводов) но не наполнять. Фильтр «язык» в поиске — это язык проведения экскурсии, отдельная от локали сущность.

9. Кто владелец цены

В ТЗ не сказано, гид ставит цену или платформа. Из «в админке полный доступ, должно шататься всё (цены, промокоды)» следует, что платформа управляет ценой.

Предположение: цена экскурсии задаётся/утверждается платформой, гид получает net-rate. Поставщик даёт гиду net-rate, невидимый туристу. Требует подтверждения — см. open-questions.md Q4.

Контекст

Открытые вопросы

🔴 — блокирует разработку или имеет длинный срок исполнения, нужен ответ на этой неделе. 🟡 — влияет на дизайн модели, нужен ответ до соответствующей вехи. ⚪️ — уточнение, можно решить по ходу.


A. Заказчикам — юридические и денежные (🔴 все, кроме помеченных)

Q1 🔴 Юрлицо. Есть ли зарегистрированная компания в Грузии (LLC / ИП / Virtual Zone)? Если нет — в какой стадии? Без юрлица нельзя ни открыть эквайринг, ни верифицировать WhatsApp Business, ни легально обрабатывать перс. данные. Срок исполнения: регистрация LLC в Грузии — 1–3 дня, счёт в банке — 1–3 недели.

Q2 🔴 Банк и эквайринг. Открыт ли счёт в Bank of Georgia или TBC? Подавали ли заявку на интернет-эквайринг? Какой оборот и средний чек заявляли? Это самый длинный срок в проекте: онбординг эквайринга — недели. Надо начинать на неделе 1, параллельно с разработкой.

Q3 🔴 «Российские карты». Что именно имели в виду? Варианты: (а) карты, выпущенные в РФ, — физически не проходят за рубежом; (б) карты россиян, выпущенных в третьих странах (Казахстан, Армения, Киргизия) — это обычные международные карты, работают; (в) отдельный российский канал приёма (ЮKassa/CloudPayments) с последующим выводом — требует отдельного юрлица/договора и несёт санкционные и налоговые риски, которые я не могу оценить, нужен юрист. От ответа зависит, одна у нас платёжная интеграция или две.

Q4 🔴 Кто владелец цены и какая комиссионная модель.

  • Цену экскурсии задаёт платформа или гид?
  • Комиссия платформы: % от суммы, фиксированная, или разная по категориям?
  • Комиссия берётся с туриста (наценка) или с гида (удержание)?
  • Размер предоплаты: % или фиксированная сумма? Одинаковый для всех?

Q5 🔴 Кто несёт риск возврата. Турист отменил / гид заболел / чарджбэк — из чьих денег возврат? Это определяет, держим ли мы деньги на эскроу-балансе до завершения экскурсии или выплачиваем гиду сразу.

Q6 🔴 Условия отмены. Нужен точный текст политики: за сколько часов бесплатная отмена, какой процент удерживается дальше, что при no-show. На карточке экскурсии это отдельный блок — без текста страница не собирается.

Q7 🟡 Валюта. В какой валюте цены на сайте (GEL / USD / EUR / RUB)? В какой валюте расчёт с гидами? Нужна ли мультивалютная витрина с конвертацией и кто несёт курсовую разницу?

Q8 🟡 Выплаты гидам и поставщикам. Как сейчас происходят расчёты офлайн? Банковский перевод, наличные, раз в неделю/месяц? Мы автоматизируем это в MVP или достаточно реестра к выплате с выгрузкой?

Q9 🟡 Баллы партнёров. Сколько баллов за что начисляется (за переход, за бронь, за оплаченную бронь)? Что партнёр может с баллами сделать? Есть ли срок сгорания?

B. Заказчикам — продуктовые

Q10 🔴 Роль оператора. Сколько операторов будет? Работают ли они по сменам? Какой SLA подтверждения заявки (час? день?)? Оператор общается с туристом внутри платформы или в WhatsApp вручную?

Q11 🟡 Свободная продажа или заявка. Бронь подтверждается мгновенно, если слот свободен, или всегда проходит через подтверждение человеком? От этого зависит вся механика доступности и «овербукинга».

Q12 ✅ ЗАКРЫТ. Экскурсия принадлежит платформе. Одну и ту же программу ведут разные гиды, турист выбирает из доступных — модель Viator, не Tripster. Зафиксировано в ADR-011 и раскрыто в domain-model §0. Снимает блокер со спринта S2.

Порождает новый вопрос к заказчикам (не блокирующий): Q12a 🟡 Раз цена и программа принадлежат платформе, а гид получает net-rate — net-rate одинаковый для всех гидов на экскурсии или индивидуальный? И знают ли гиды, что их программу смогут вести другие? Это лучше проговорить на входе, чем после первого конфликта.

Q13 🟡 Групповые vs индивидуальные. Есть ли сборные группы (несколько независимых броней в один слот с общим лимитом мест) или только выкуп экскурсии целиком? Если сборные — нужен контроль вместимости и pricing per person с порогами.

Q14 ⚪️ Контент. Кто пишет тексты экскурсий, категорий, направлений, FAQ? Откуда фото и видео (свои съёмки / стоки / от гидов)? Есть ли права на них? Контент — типичная причина срыва запуска: код готов, страницы пустые.

Q15 ⚪️ Бренд. Название, домен, логотип — когда будут? Работаем под рабочим названием batumi-travel и меняем в один коммит, но домен нужен до запуска WhatsApp-верификации.

Q16 ⚪️ Языки. RU + EN на старте, грузинский позже — подтверждаете?

Q17 ⚪️ Дата запуска. Есть ли привязка к туристическому сезону? В Батуми сезон — май–сентябрь. Если целимся в май 2027, план ниже с запасом; если в этот сезон — нужно резать скоуп.

C. Тебе — по разработке

Q18 ✅ ЗАКРЫТ, вопрос снят. Код пишу я, непрерывно; ты ревьюишь и принимаешь. Оценки времени из плана убраны — они ничего не описывают. Дата запуска определяется внешними очередями: эквайринг, верификация Meta, контент, ответы заказчиков.

Q19 🔴 Один или команда. Будет ли второй разработчик, дизайнер, контентщик? Если дизайнера нет — я закладываю готовую дизайн-систему (shadcn/ui + токены), а не кастомный дизайн.

Q20 🟡 Хостинг и бюджет инфраструктуры. Твой Coolify-хост подходит для staging. Для прода с деньгами и перс. данными я рекомендую отдельный сервер

  • managed Postgres. Бюджет ~$40–80/мес. Заказчик платит или ты?

Q21 🟡 Кто эксплуатирует после запуска. Ты остаёшься на поддержке или проект передаётся? От этого зависит, сколько инвестировать в рантбуки, автотесты и «защиту от дурака» в админке.

Q22 ✅ ЗАКРЫТ. NestJS согласован.

Продукт

Границы скоупа и модель ролей

1. Что такое «MVP» в этом проекте

Заказчик формулирует MVP как всю цепочку целиком. Это не MVP, это релиз 1.0. Чтобы не строить всё это без единой демонстрации по дороге, режу на три публичных релиза:

R1 — «Витрина и заявка» (спринты S1–S5)

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

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

R2 — «Деньги» (спринты S6–S7)

Предоплата картой, промокоды, комиссии, леджер, возвраты, реестр выплат, баллы партнёров, полноценная CRM оператора.

R3 — «Самообслуживание» (спринт S9)

ЛК гида, ЛК поставщика, B2B-бронирование услуг гидом, календарь и доступность на стороне гида, Google Calendar.

Между R1 и R2 проект уже приносит деньги офлайн. Это снижает главный риск — построить всё целиком и обнаружить, что спроса нет.

2. Не входит в скоуп (явно)

  • Мобильное приложение (подтверждено заказчиком).
  • SMS-уведомления (решение встречи).
  • Вывод баллов партнёра в деньги.
  • Динамическое ценообразование, апсейлы, пакеты, подарочные сертификаты.
  • Мультирегиональность как реально работающие другие страны — закладываем модель (region_id везде), наполняем только Аджарией.
  • Интеграция с Viator/GYG как каналами дистрибуции (важно: это отдельный большой проект, см. architecture.md §9).
  • Онлайн-чат между туристом и гидом внутри платформы — общение идёт в WhatsApp.

3. Модель ролей

Плоского поля user.role не будет. Причина из конспекта встречи: «гиды… на локациях (на винодельне)» — один человек может быть и гидом, и сотрудником поставщика. Плюс у поставщика может быть несколько сотрудников.

User (аккаунт, логин)
  └─ Membership (роль + область действия)
        ├─ role: TOURIST | GUIDE | SUPPLIER_STAFF | PARTNER | OPERATOR | ADMIN
        └─ scope: null | organization_id

Organization — это юридическая/операционная единица: поставщик (винодельня), агентство, партнёрская точка. Гид-физлицо — тоже Organization из одного человека, чтобы расчёты и выплаты были единообразны.

Матрица прав (сокращённая)

Действие Турист Партнёр Гид Поставщик Оператор Админ
Смотреть каталог ✅ ✅ ✅ ✅ ✅ ✅
Создать B2C-бронь ✅ — — — ✅ (от имени) ✅
Видеть свои брони ✅ — ✅ ✅ — ✅
Видеть все брони — — — — ✅ ✅
Назначить гида на бронь — — — — ✅ ✅
Подтвердить/отменить бронь своя — своя своя все все
Создать экскурсию — — ✅ (R3) — ✅ ✅
Опубликовать экскурсию — — — — — ✅
Создать B2B-бронь у поставщика — — ✅ (R3) — ✅ ✅
Видеть net-rate поставщика — — ✅ ✅ своя ✅ ✅
Видеть контакты туриста — — ✅ после подтв. — ✅ ✅
Менять цены и комиссии — — — — — ✅
Создавать промокоды — — — — — ✅
Инициировать возврат запрос — — — до лимита ✅
Модерировать отзывы — — ответ — ✅ ✅
Видеть партнёрскую статистику — своя — — ✅ ✅
Читать аудит-лог — — — — — ✅

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

Два уровня админа

Из встречи: «мы (доступ менять процент, полный доступ)».

  • OPERATOR — работа с заявками, без доступа к деньгам и настройкам.
  • ADMIN — всё, включая комиссии, промокоды, возвраты, выплаты.

Все действия ADMIN над деньгами и перс. данными пишутся в неизменяемый аудит-лог.

4. Нефункциональные требования

Параметр Цель на R1 Обоснование
Пиковая нагрузка 50 rps, 500 одновременных сезонный трафик одного города
LCP мобильный < 2.5 с на 4G SEO — основной канал
Доступность 99.5% (≈3.6 ч/мес) не банк, но бронирования теряются
RPO (потеря данных) ≤ 5 минут PITR Postgres
RTO (восстановление) ≤ 1 час отрепетированный рестор
Языки RU, EN (KA в модели) см. Q16
SEO SSR/SSG всех публичных страниц агрегатор без органики мёртв
Продукт

Доменная модель

0. Ключевая развилка: что такое «экскурсия»

Из наброска карточки: «Гид (показать сколько доступных)» — у одной экскурсии несколько доступных гидов. Значит модель такая:

Экскурсия — это программа платформы. Гид — исполнитель. Один и тот же «Винный тур по Кахетии» может вести пятеро гидов с разными языками, рейтингом и net-rate.

✅ Подтверждено заказчиком (Q12 закрыт). Это модель Viator, а не Tripster: экскурсия не привязана к автору, гид — сменная роль в ней.

Что из этого следует, и это стоит держать в голове при всей дальнейшей работе:

  • контент и цена экскурсии принадлежат платформе, не гиду. Гид не может переписать программу под себя — иначе это уже другая экскурсия;
  • гид получает net-rate, который может отличаться от гида к гиду (опыт, язык, рейтинг) — переопределение лежит в ExcursionGuide;
  • слот привязан к паре (экскурсия, гид), а не к экскурсии: у каждого гида своя занятость, и доступность экскурсии на дату — это объединение доступностей её гидов;
  • рейтинг двойной: у экскурсии (насколько хороша программа) и у гида (насколько хорошо провёл). Отзыв пишется на бронь и разносится на оба;
  • в карточке «сколько гидов доступно» — это счётчик гидов со свободным слотом на выбранную дату и подходящим языком;
  • модерация экскурсии и модерация гида — разные процессы.

В R3, когда гид сможет создавать экскурсии сам, он создаёт заявку на программу, которую админ публикует — после чего её смогут вести и другие гиды. Это надо проговорить с гидами на входе, иначе будет конфликт ожиданий.


1. Карта сущностей

                       ┌──────────────┐
                       │ Organization │  поставщик / агентство / гид-физлицо / партнёрская точка
                       └──────┬───────┘
                              │
          ┌───────────────────┼────────────────────┐
          │                   │                    │
    ┌─────┴────┐       ┌──────┴──────┐      ┌──────┴──────┐
    │   User   │──────►│ Membership  │      │   Partner   │
    └──────────┘       │ role+scope  │      └──────┬──────┘
                       └─────────────┘             │ PartnerLink (slug, QR)
                                                   │ AttributionTouch
  КАТАЛОГ                                          │
  ┌──────────┐  ┌────────────┐  ┌────────────┐     │
  │ Category │  │Destination │  │ Collection │     │   ← контент, редактируется в админке
  └────┬─────┘  └─────┬──────┘  └─────┬──────┘     │
       └──────────────┼───────────────┘            │
                 ┌────┴──────┐                     │
                 │ Excursion │ (программа)         │
                 └────┬──────┘                     │
        ┌─────────────┼──────────────┐             │
   ┌────┴─────────┐ ┌─┴───────────┐ ┌┴──────────┐  │
   │ExcursionGuide│ │AvailabilityRule│ │ Media   │  │
   │  (+netRate)  │ │  +Exception  │ └──────────┘  │
   └────┬─────────┘ └─┬───────────┘                │
        │             │                            │
        │        ┌────┴────┐                       │
        └───────►│  Slot   │ дата+время+гид+мест    │
                 └────┬────┘                       │
                      │                            │
                 ┌────┴──────────────────────────┐ │
                 │          Booking              │◄┘ partnerId, promoCodeId
                 │  channel: B2C | B2B           │
                 │  status: стейт-машина         │
                 └────┬──────────────────┬───────┘
                      │                  │
              ┌───────┴──────┐    ┌──────┴────────┐
              │ BookingItem  │    │   Payment     │──► Refund
              │ (услуги      │    │  PaymentAttempt│
              │  поставщиков)│    └──────┬────────┘
              └───────┬──────┘           │
                      │            ┌─────┴──────┐
              ┌───────┴────────┐   │LedgerEntry │◄── LedgerAccount
              │SupplierService │   └────────────┘        (двойная запись)
              └────────────────┘
                      │
                 ┌────┴────┐      ┌─────────┐  ┌──────────┐
                 │ Review  │      │ Payout  │  │ AuditLog │
                 └─────────┘      └─────────┘  └──────────┘

2. Сквозные правила модели

Эти шесть правил — то, что отличает надёжный бэкенд от сырого. Они применяются ко всем таблицам без исключений.

  1. region_id есть у каждой бизнес-сущности. Даже если регион один. Масштабирование Батуми → Грузия → другие страны потом будет фильтром, а не миграцией.
  2. Деньги — только целые в минорных единицах + код валюты. amount_minor BIGINT NOT NULL, currency CHAR(3) NOT NULL. Типа float/double в проекте нет нигде. Линтер-правило в CI.
  3. Soft delete через deleted_at для всего, на что ссылаются брони. Удалённый гид не должен ломать историю заказов.
  4. created_at / updated_at / created_by везде, время в UTC, тип timestamptz. Отображение — в Asia/Tbilisi.
  5. Идемпотентность: у любой операции, меняющей деньги или создающей бронь, есть idempotency_key UNIQUE. Повтор запроса возвращает прежний результат, а не создаёт вторую бронь.
  6. Переводимые поля вынесены в translations (entity_type, entity_id, locale, field, value), а не в колонки title_ru / title_en. Добавление грузинского = строки в таблице, не миграция схемы.

3. Критичные сущности подробно

Booking — центральная

model Booking {
  id              String   @id @default(uuid())
  reference       String   @unique          // BT-2026-000123, человекочитаемый
  channel         Channel                   // B2C | B2B
  regionId        String

  // кто у кого покупает
  buyerUserId     String?                   // B2C: турист
  buyerOrgId      String?                   // B2B: организация гида
  sellerOrgId     String                    // B2C: орг гида; B2B: поставщик

  slotId          String?                   // B2C всегда; B2B опционально
  excursionId     String?
  guideId         String?                   // назначается оператором

  status          BookingStatus
  paxAdults       Int
  paxChildren     Int      @default(0)
  languageCode    String                    // язык проведения
  scheduledStart  DateTime                  // денормализовано из slot, UTC
  durationMinutes Int

  // деньги, все в минорных единицах
  currency        String   @db.Char(3)
  grossAmount     BigInt                    // что платит покупатель
  discountAmount  BigInt   @default(0)
  prepaidAmount   BigInt   @default(0)      // сколько реально оплачено
  platformFee     BigInt   @default(0)      // наша комиссия
  supplierNet     BigInt   @default(0)      // к выплате исполнителю

  promoCodeId     String?
  partnerId       String?                   // атрибуция на момент создания
  attributionId   String?

  cancellationPolicyId String
  cancelledAt     DateTime?
  cancelledBy     String?
  cancelReason    String?

  customerNote    String?
  operatorNote    String?                   // внутреннее, туристу не видно

  idempotencyKey  String   @unique
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([status, scheduledStart])
  @@index([sellerOrgId, scheduledStart])
  @@index([buyerUserId, createdAt])
}

Важно: grossAmount, platformFee, supplierNet фиксируются в момент создания брони по действующим тогда правилам. Админ поменял комиссию завтра — старые брони не меняются. Это и бухгалтерски правильно, и защищает от «поехавших» отчётов.

Availability: правила → слоты

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

AvailabilityRule       — «по вт/чт/сб в 10:00, вместимость 12, с 01.05 по 30.09»
AvailabilityException  — «14.08 не работаем» / «14.08 вместимость 6»
Slot                   — материализованный конкретный слот

Slot создаётся лениво: при первом обращении к дате генерируем слоты на горизонт 120 дней фоновой задачей. Бронь всегда ссылается на существующий Slot, и вместимость списывается в транзакции с SELECT ... FOR UPDATE на строке слота.

Это то место, где новички получают овербукинг. Правило: изменение slot.booked_seats возможно только внутри транзакции с блокировкой строки, и есть CHECK (booked_seats <= capacity) на уровне БД как последний рубеж.

Атрибуция партнёра

PartnerLink       — slug, QR (генерим PNG/SVG), целевая страница, UTM
AttributionTouch  — визит: partner_link_id, anonymous_id (cookie), ip_hash,
                    user_agent_hash, created_at, expires_at (+30 дней)

Модель атрибуции: last non-direct touch, окно 30 дней. Промокод партнёра, введённый вручную, перебивает cookie-атрибуцию — потому что это явное действие туриста. ip и user_agent храним только в хешированном виде — это перс. данные.

Промокоды

PromoCode: code, type(PERCENT|FIXED), value, currency,
           validFrom/validTo, maxUses, usedCount, maxUsesPerUser,
           minAmount, appliesTo (категории/экскурсии/направления),
           ownerPartnerId?, stackable(false), regionId
PromoRedemption: promoCodeId, bookingId, userId, amountApplied  — UNIQUE(promo, booking)

Списание использования — в той же транзакции, что и создание брони, с UPDATE ... WHERE used_count < max_uses и проверкой затронутых строк.

Отзывы

Review: bookingId (UNIQUE — один отзыв на бронь), targetType(GUIDE|EXCURSION),
        targetId, rating 1..5, text, status(PENDING|PUBLISHED|REJECTED),
        moderatorId, moderatedAt, guideReply, publishedAt

Создание отзыва разрешено, только если booking.status = COMPLETED и прошло не больше 60 дней. Рейтинг гида — материализованное представление, пересчитываемое по событию публикации отзыва.

4. Контентные сущности

Category, Destination, Collection («Идеи/подборки»), FaqItem, Page, MediaAsset. Все со слагами, SEO-полями (meta_title, meta_description, og_image), сортировкой и признаком публикации.

Страницы категорий и направлений из набросков собираются из блоков: PageBlock(page_id, type, position, payload jsonb), типы — hero, category_grid, excursion_carousel, collection_list, reviews, faq, cta.

Зачем: требование «перешатать дизайн или выкинуть и поставить новый». Если страницы — это данные, а не вёрстка, новый дизайн переопределяет рендереры блоков и не трогает бэкенд вообще.

Продукт

Сценарии и стейт-машины

1. Стейт-машина брони (B2C)

                    ┌──────────┐
   турист оформил   │  DRAFT   │  корзина/незавершённое оформление, TTL 30 мин
                    └────┬─────┘
                         │ submit
                         ▼
                ┌────────────────────┐
                │ PENDING_OPERATOR   │◄─── очередь оператора, SLA из Q10
                └───┬────────────┬───┘
       назначен гид │            │ отклонено / нет мест
                    ▼            ▼
         ┌────────────────┐  ┌──────────┐
         │ PENDING_GUIDE  │  │ REJECTED │ (терминальное)
         └───┬────────┬───┘  └──────────┘
   гид принял│        │ гид отказался → назад в PENDING_OPERATOR
             ▼
      ┌──────────────────┐
      │ AWAITING_PAYMENT │  выставлена предоплата, TTL 24 ч (конфиг)
      └───┬──────────┬───┘
   оплачено│          │ TTL истёк
           ▼          ▼
     ┌───────────┐  ┌─────────┐
     │ CONFIRMED │  │ EXPIRED │ (терминальное, слот освобождается)
     └──┬────┬───┘  └─────────┘
        │    │ отмена (турист/оператор) → CANCELLED + расчёт возврата по политике
        │    ▼
        │  ┌───────────┐      ┌──────────┐
        │  │ CANCELLED ├─────►│ REFUNDED │
        │  └───────────┘      └──────────┘
        │ дата прошла (фоновая задача)
        ▼
   ┌───────────┐  ── запрос отзыва через 2 ч ──►  Review
   │ COMPLETED │
   └───────────┘
        │ не пришёл
        ▼
   ┌──────────┐
   │ NO_SHOW  │ → удержание по политике
   └──────────┘

Правила перехода, зашитые в код (а не в UI):

  • Переходы описаны одной таблицей ALLOWED_TRANSITIONS, любой переход вне неё — исключение. Проверка на уровне сервиса и триггером в БД.
  • Каждый переход пишет строку в BookingStatusHistory (кто, когда, почему).
  • Место в слоте резервируется при PENDING_OPERATOR и освобождается при REJECTED / EXPIRED / CANCELLED. Это «мягкий холд» — он мешает продать одно место дважды, пока оператор думает.
  • instantConfirm = true на экскурсии схлопывает PENDING_OPERATOR и PENDING_GUIDE, бронь сразу уходит в AWAITING_PAYMENT. Флаг существует с первого дня, выключен по умолчанию (см. contradictions §3).

2. Стейт-машина брони (B2B: гид → поставщик)

Проще, оператор не участвует:

DRAFT → REQUESTED → (поставщик) CONFIRMED | DECLINED
CONFIRMED → COMPLETED | CANCELLED

Оплата на первом этапе — вне платформы (взаиморасчёты гид↔поставщик), в леджере фиксируется обязательство. Онлайн-оплата B2B — отдельная веха.

3. Стейт-машина платежа

   ┌─────────┐ создан intent у эквайера
   │ CREATED ├──────────────┐
   └─────────┘              ▼
                    ┌───────────────┐   3DS, редирект на сторону банка
                    │  PROCESSING   │
                    └───┬───────┬───┘
            webhook ok  │       │ webhook fail / таймаут
                        ▼       ▼
                 ┌───────────┐ ┌────────┐
                 │ AUTHORIZED│ │ FAILED │──► можно повторить новой попыткой
                 └─────┬─────┘ └────────┘
        capture (сразу │ или при подтверждении)
                       ▼
                 ┌───────────┐
                 │ CAPTURED  │──► PARTIALLY_REFUNDED ──► REFUNDED
                 └───────────┘
                       │ банк не подтвердил / отменили холд
                       ▼
                 ┌───────────┐
                 │  VOIDED   │
                 └───────────┘

Отдельная сущность PaymentAttempt: одна бронь может иметь несколько попыток оплаты (карта не прошла — турист пробует другую). Payment — успешная попытка.

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

4. Сценарий A: партнёр → турист → бронь

  1. Партнёр в ЛК генерирует ссылку batumi.travel/p/{slug} и QR-код (SVG+PNG).
  2. Турист сканирует QR в отеле → переход → сервер ставит cookie att_id (HttpOnly, 30 дней) и пишет AttributionTouch. IP и UA — хешированные.
  3. Турист ищет экскурсию, открывает карточку, выбирает дату/время/язык/кол-во.
  4. Оформление: имя, телефон (WhatsApp), email, промокод. Регистрация не нужна — гостевая бронь с magic-link доступом к истории. Барьер регистрации на мобильном убивает конверсию.
  5. Booking создаётся с partner_id из cookie. Уведомление оператору.
  6. Оператор назначает гида → гид подтверждает (в R1 — через оператора по WhatsApp).
  7. Туристу уходит ссылка на оплату предоплаты.
  8. Оплата → CONFIRMED → WhatsApp-подтверждение + .ics + ссылка «добавить в Google Calendar» + контакты гида и место встречи на карте.
  9. Напоминания: за 24 ч и за 2 ч до начала.
  10. Через 2 ч после завершения — запрос отзыва.
  11. Партнёру начисляются баллы за оплаченную бронь.

5. Сценарий B: гид собирает программу (R3)

  1. Гид открывает свою экскурсию → «Программа» → добавляет пункты.
  2. Ищет поставщиков по категории, локации, вместимости, свободной дате.
  3. Создаёт B2B-заявки на дату экскурсии; они связываются с B2C-бронью через BookingItem.
  4. Поставщик получает WhatsApp «заявка от гида X на 14.08, 12 чел.» и подтверждает одной кнопкой (ссылка с одноразовым токеном).
  5. В карточке B2C-брони у оператора видно, все ли составляющие подтверждены.
  6. Если поставщик отказал — гиду уведомление и задача заменить пункт.

6. Сценарий C: отмена и возврат

  1. Инициатор: турист (из ЛК), оператор (в CRM) или система (гид не смог).
  2. Движок политики отмены считает удержание: на вход — время до начала, политика брони, инициатор; на выход — refundAmount + основание.
  3. Если отменяет платформа/гид — возврат всегда полный, независимо от срока.
  4. Создаётся Refund в статусе PENDING → запрос в эквайер → вебхук → DONE.
  5. Проводки в леджере разворачиваются обратной записью (не удалением!).
  6. Уведомления всем сторонам, освобождение слота, отмена связанных B2B-заявок.

7. Что делаем, когда что-то сломалось (фоллбеки)

Требование «во всех местах уметь фоллбекаться» разворачивается так:

Отказ Фоллбек
WhatsApp API недоступен очередь с ретраями → через 15 мин email → задача оператору в CRM
Эквайер недоступен бронь остаётся AWAITING_PAYMENT, туристу «оплатим позже», ссылка живёт 24 ч, оператору алерт
Вебхук не пришёл сверочная задача каждые 15 мин опрашивает статусы
Google Maps квота карта деградирует в статичную картинку + ссылку
Поиск тормозит таймаут 2 с → отдаём популярное вместо ошибки
Redis лёг кэш мимо, очереди встают — бронирование продолжает работать (очереди только для уведомлений)
Postgres лёг сайт в read-only из кэша CDN, бронирование недоступно, алерт
Упал фронт бэк независим, админка и API живы, оператор ведёт заявки
Техника

Ресёрч стека

Критерии выбора

Взвешены под конкретную ситуацию, а не «вообще»:

Критерий Вес Почему
Понятность фронтенд-разработчику ★★★ один разработчик, бэкенд — не основная экспертиза
Защита от ошибок «по умолчанию» ★★★ деньги и перс. данные, цена бага высокая
Один язык на весь проект ★★★ нет переключения контекста, общие типы
Зрелость и объём документации ★★☆ нельзя зависеть от нишевых решений
Стоимость эксплуатации ★★☆ проект знакомых, не корпоративный бюджет
Возможность передать проект другому ★★☆ см. Q21
Скорость первого релиза ★☆☆ важна, но не ценой надёжности денег

1. Язык и общая форма

Выбор: TypeScript, монорепозиторий (pnpm workspaces + Turborepo).

Рассматривались:

  • TS везде ✅ — общие типы домена между фронтом и бэком, одна экосистема, ты уже эксперт в половине стека.
  • Go / Python(FastAPI) / Kotlin для бэка — объективно не хуже, но добавляют второй язык человеку, который «в бэке скорее не шарит». Это удваивает площадь для ошибок именно там, где ошибки дороги.
  • PHP (Laravel) — сильная админка из коробки, но выпадает из твоей экспертизы.

Монорепо даёт главное: пакет @bt/contracts с zod-схемами и типами, который импортируют и API, и фронт, и админка. Рассинхрон контракта становится ошибкой компиляции, а не багом в проде.

2. Бэкенд-фреймворк

Выбор: NestJS.

NestJS Fastify + tRPC Next.js API / Server Actions Encore.ts
Структура навязана ✅ модули, DI, слои ❌ решаешь сам ❌ ✅
Транзакции, фоновые задачи, cron ✅ штатно вручную ⚠️ чужеродно ✅
Валидация на границе ✅ pipes + zod ✅ ручками ✅
OpenAPI из кода ✅ ⚠️ ❌ ✅
Документация и ответы на StackOverflow ★★★ ★★ ★★★ ★
Порог входа средний низкий низкий средний
Риск «размазать бизнес-логику по фронту» низкий средний высокий низкий

Ключевой аргумент: многословность NestJS здесь — это фича. Он заставляет раскладывать код по модулям, отделять слой домена от транспорта и явно описывать зависимости. Именно эта дисциплина заменяет отсутствующий опыт.

Отвергнут Next.js-монолит на server actions: соблазн посчитать сумму заказа в компоненте слишком велик, а цена — деньги. Отвергнут Encore.ts: элегантный, но нишевой, при передаче проекта станет проблемой.

3. База и доступ к данным

Выбор: PostgreSQL 17 + Prisma.

Postgres — без вариантов: транзакции, FOR UPDATE для слотов, CHECK-констрейнты, jsonb для блоков страниц, полнотекстовый поиск с pg_trgm, PITR.

Prisma vs Drizzle vs TypeORM:

  • Prisma ✅ — лучшая в классе система миграций (migrate dev/deploy, drift detection), генерируемые типы, читаемая схема. Минусы: генерация, ограничения в сложных запросах. Оба минуса лечатся $queryRaw для 5% запросов (аналитика, отчёты).
  • Drizzle — ближе к SQL, легче, но миграции и экосистема слабее; при слабом знании SQL «ближе к SQL» — это минус, а не плюс.
  • TypeORM — исторический багаж, непредсказуемые каскады. Нет.

Schema-first Prisma даёт ещё один бонус: файл schema.prisma — это читаемая документация модели, которую можно показать заказчику.

4. Аутентификация

Выбор: Better Auth, смонтированный в NestJS. Фоллбек — Logto (self-hosted).

Требования специфичные: вход туриста по телефону (основной канал — WhatsApp), гостевые брони без регистрации, несколько ролей у одного пользователя, организации, сессии для админки.

  • Своя реализация с нуля — категорически нет. Ротация refresh-токенов, защита от timing-атак, брутфорс, сброс пароля — здесь ошибаются все, кто делает это впервые, и цена ошибки — утечка перс. данных.
  • Better Auth — TypeScript, данные в твоём Postgres, плагины phone-OTP / magic-link / organization / 2FA. Фреймворк-агностичный handler, монтируется в Nest. ✅
  • Auth.js — заточен под Next.js, тянуть его логику в отдельный API неудобно.
  • Keycloak / Logto — полноценный IdP, надёжно, но отдельный сервис в проде, который надо обновлять и бэкапить. Overkill для R1, но правильный фоллбек, если Better Auth упрётся в потолок.
  • Supabase Auth / Clerk — быстро, но привязка к внешнему вендору для персональных данных требует отдельного DPA и осложняет комплаенс в Грузии.

5. Фоновые задачи и очереди

Выбор: BullMQ (Redis).

Нужны: отложенные напоминания (за 24 ч и 2 ч до экскурсии), ретраи уведомлений с экспоненциальной задержкой, DLQ для разбора, cron для сверки платежей и материализации слотов, генерация «Slot» на горизонт.

Альтернативы: pg-boss (очередь в Postgres — на одну зависимость меньше, но нагружает основную БД и слабее по фичам), Temporal (правильная оркестрация саг, но целый кластер ради R1 — нет), голый setTimeout — просто нет, он не переживает рестарт.

6. Фронтенд

Выбор: Next.js 15 (App Router) + React 19 + Tailwind + shadcn/ui + дизайн-токены.

SSR/SSG обязателен: агрегатор без органического трафика не существует, а карточки экскурсий, категорий и направлений — это SEO-актив.

Требование «перешатать дизайн или выкинуть и поставить новый» закрывается тремя приёмами:

  1. Все цвета, радиусы, тени, типографика — CSS-переменные в одном файле токенов. Смена палитры и скруглений = правка tokens.css.
  2. shadcn/ui — компоненты копируются в репозиторий, а не приходят из библиотеки. Их можно переписать полностью, не воюя с чужими стилями.
  3. Страницы собираются из блоков по данным из API (см. domain-model §4). Новый дизайн — это новый набор рендереров блоков.

Отвергнуты: Remix/React Router v7 (хорош, но экосистема Next больше и под Vercel/Coolify накатаннее), Astro (отличен для контента, слаб для приватных кабинетов — пришлось бы два приложения), Nuxt (второй фреймворк без выгоды).

7. Админка и CRM

Выбор: отдельное SPA на Refine + shadcn, поверх того же API.

  • Refine ✅ — даёт быстрый CRUD, но не мешает писать произвольные экраны. Требование «накидать CRM-ку» — это не CRUD: воронка заявок, назначение гида, лог коммуникаций, массовые действия. Автогенерируемые админки на этом ломаются.
  • AdminJS ❌ — привязывается к моделям БД и обходит бизнес-логику API. Для системы с деньгами это опасно: админ через AdminJS может поменять booking.status напрямую, минуя стейт-машину и леджер.
  • react-admin — зрелый, но Material-дизайн и своя парадигма; Refine гибче и ближе к обычному React.
  • Directus / Payload как админка домена ❌ — тянут свою модель и свой рантайм. Payload хорош как CMS, но в проекте уже есть источник правды.

Принцип, зашитый жёстко: админка — это обычный клиент публичного API с привилегированными правами. Прямого доступа к БД у неё нет. Любое действие админа проходит те же проверки и пишется в аудит-лог.

8. Инфраструктура

Выбор: Docker + Coolify на выделенном VPS (Hetzner), Postgres — managed.

  • Coolify ты уже используешь, он даёт деплой, TLS, env-менеджмент и preview-окружения без Kubernetes.
  • Postgres прода — не на том же хосте. Managed (Neon / DigitalOcean / Hetzner+managed) даёт PITR, автобэкапы и failover «из коробки». Экономия $15/мес на самохосте оплачивается одной потерянной ночью и объяснением заказчику, куда делись брони.
  • Файлы и медиа — Cloudflare R2 (нулевая плата за исходящий трафик, критично для сайта с видео на главной) + Cloudflare CDN.
  • Kubernetes — нет. Один разработчик, 50 rps.

9. Наблюдаемость

Sentry (ошибки фронта и бэка, один проект — один trace), OpenTelemetry → Grafana Cloud free tier (логи, метрики, трейсы), Better Stack / UptimeRobot (внешний аптайм + алерты в Telegram).

Для бэкендера-новичка это не «нice to have»: без Sentry ты узнаёшь об упавшей оплате от заказчика через два дня.

10. Платежи и уведомления

Вынесены в отдельные документы, потому что это основной риск: payments-and-money.md, notifications.md.

Итоговый стек одной таблицей

Слой Выбор
Язык TypeScript (strict)
Репозиторий pnpm workspaces + Turborepo
Публичный сайт и ЛК Next.js 15, React 19, Tailwind, shadcn/ui
Админка / CRM Refine SPA
API NestJS, REST + OpenAPI, zod-контракты
БД PostgreSQL 17 + Prisma
Кэш и очереди Redis + BullMQ
Аутентификация Better Auth (фоллбек: Logto)
Файлы Cloudflare R2 + Images/CDN
Поиск Postgres (pg_trgm + GIN), Meilisearch при необходимости
Карты Google Maps JS API + Places (за абстракцией)
Платежи BOG iPay (основной), абстракция PaymentRail
Уведомления WhatsApp Cloud API, Resend (email), Telegram Bot
Тесты Vitest, Testcontainers, Playwright
CI/CD GitHub Actions
Хостинг Hetzner + Coolify; Postgres managed
Мониторинг Sentry, OTel → Grafana Cloud, Better Stack

Источники

Техника

Журнал архитектурных решений (ADR)

Формат: решение — контекст — последствия — как откатить. Статусы: принято (действует), предложено (ждёт ответа на вопрос), отклонено.


ADR-001 · TypeScript везде, монорепо — принято Один язык, общий пакет контрактов между API, сайтом и админкой. Последствие: рассинхрон контракта ловится компилятором. Откат: невозможен дёшево, это фундамент.

ADR-002 · NestJS как бэкенд — принято и подтверждено Навязанная структура заменяет недостающий бэкенд-опыт. Последствие: больше boilerplate, медленнее первые экраны. Откат: до конца M1 можно уйти на Fastify, позже — дорого.

ADR-003 · PostgreSQL + Prisma — принято Откат: смена ORM при сохранении Postgres — 1–2 недели.

ADR-004 · Деньги только в минорных единицах (BIGINT) + ISO-код валюты — принято Ни одного float в денежных путях. ESLint-правило + ревью схемы в CI. Откат: не предусмотрен, это правило гигиены.

ADR-005 · Двойная запись (ledger) с первого дня — принято Даже когда платежей нет, бронь создаёт проводки «обязательство». Последствие: +неделя на M4, но отчёты и сверка становятся тривиальными, а «где деньги» перестаёт быть вопросом. Откат: нет смысла.

ADR-006 · Абстракция PaymentRail, эквайер — деталь реализации — принято Прямая интеграция с одним банком в коде брони — главная архитектурная ошибка в таких проектах. См. payments §4. Откат: не требуется, абстракция дешёвая.

ADR-007 · Источник правды по платежу — вебхук, не редирект — принято Плюс сверочная задача каждые 15 минут.

ADR-008 · Идемпотентность на всех мутациях денег и броней — принято Idempotency-Key в заголовке, таблица idempotency_records с ответом.

ADR-009 · Transactional outbox для внешних эффектов — принято Уведомления, вебхуки и интеграции не вызываются прямо из транзакции. Пишем событие в outbox в той же транзакции, воркер публикует. Последствие: нет ситуации «письмо ушло, транзакция откатилась».

ADR-010 · Роли через Membership(role, scope), а не поле user.role — принято Гид может быть сотрудником винодельни (из конспекта встречи).

ADR-011 · Экскурсия — программа платформы, гид — исполнитель — принято и подтверждено заказчиком (Q12 закрыт) Одну и ту же программу ведут разные гиды, турист выбирает из доступных. Следствия: цена и контент экскурсии принадлежат платформе; гид получает net-rate; ExcursionGuide — связь многие-ко-многим с переопределением ставки; слот привязан к паре (экскурсия, гид); рейтинг считается отдельно для экскурсии и для гида.

ADR-012 · Оператор в цепочке подтверждения, instantConfirm — флаг — принято

ADR-013 · Better Auth вместо самописной аутентификации — принято Фоллбек: Logto self-hosted, если упрёмся в ограничения. Откат: болезненный, но возможен — сессии и пользователи в нашей БД.

ADR-014 · Гостевое бронирование без обязательной регистрации — принято Доступ к истории по magic-link. Регистрация — опционально, после брони. Обоснование: mobile-first + туристы, конверсия важнее «базы пользователей».

ADR-015 · Админка — клиент API, а не прямой доступ к БД — принято Исключает обход стейт-машины и леджера. Отсюда отказ от AdminJS.

ADR-016 · Страницы собираются из блоков (PageBlock) — принято Закрывает требование «перешатать дизайн или выкинуть и поставить новый».

ADR-017 · Переводы в таблице translations, не в колонках — принято

ADR-018 · Баллы партнёров — валюта PTS в том же леджере, без вывода — принято Закрывает требование заказчицы, не создавая обязательств по выплатам физлицам.

ADR-019 · region_id у каждой бизнес-сущности с первого дня — принято Цена сейчас — одна колонка. Цена потом — миграция всей базы.

ADR-020 · Email делаем в R1, вопреки «можно не брать» — принято WhatsApp требует верификации Meta (недели). Email — страховка запуска. См. contradictions §6.

ADR-021 · Контакты туриста видны гиду только после подтверждения — принято И скрываются через 30 дней после завершения. Требование перс. данных

  • защита от обхода платформы.

ADR-022 · Вместимость слота — транзакция + FOR UPDATE + CHECK в БД — принято Два рубежа против овербукинга.

ADR-023 · Postgres managed, не на том же хосте, что приложение — принято (Q20) PITR и автобэкапы важнее $15/мес.

ADR-024 · Cloudflare R2 для медиа — принято Нулевой egress критичен для сайта с видео на главной.

ADR-025 · Google Maps за абстракцией MapProvider — принято Требование заказчика — Google. Квоты и биллинг Google Maps — известная ловушка, абстракция позволяет уйти на MapLibre без переписывания.

ADR-026 · Отдельное staging-окружение на поддомене — принято (твоё предложение) staging.<домен> с обезличенными данными, noindex, отдельный эквайер-sandbox.

ADR-027 · Sentry + OTel с первого коммита, а не «потом» — принято

ADR-028 · Никакой интеграции с Viator/GYG в R1–R3 — принято Это отдельный проект уровня «сертификация у партнёра». Заложить в модели поле external_refs jsonb и забыть до отдельного решения.

Техника

Архитектура

1. Форма системы

Модульный монолит. Не микросервисы: один разработчик, 50 rps, распределённые транзакции с деньгами — верный способ утонуть.

Но внутри — жёсткие границы модулей, чтобы при необходимости любой модуль можно было вынести.

┌───────────────────────────────────────────────────────────┐
│  apps/web        Next.js 15 — публичный сайт + ЛК          │  SSR/ISR, SEO
│  apps/admin      Refine SPA — админка и CRM                │
└──────────────┬──────────────────────────┬─────────────────┘
               │ REST + OpenAPI           │
               ▼                          ▼
┌───────────────────────────────────────────────────────────┐
│  apps/api  (NestJS, модульный монолит)                     │
│                                                            │
│  ┌──────────┐┌──────────┐┌──────────┐┌──────────┐         │
│  │ identity ││ catalog  ││availabil.││ booking  │  ДОМЕН  │
│  └──────────┘└──────────┘└──────────┘└──────────┘         │
│  ┌──────────┐┌──────────┐┌──────────┐┌──────────┐         │
│  │ pricing  ││ payments ││ ledger   ││ payouts  │         │
│  └──────────┘└──────────┘└──────────┘└──────────┘         │
│  ┌──────────┐┌──────────┐┌──────────┐┌──────────┐         │
│  │ partners ││ reviews  ││ content  ││ notify   │         │
│  └──────────┘└──────────┘└──────────┘└──────────┘         │
│  ┌──────────┐┌──────────┐┌──────────┐                     │
│  │  audit   ││ search   ││ media    │                     │
│  └──────────┘└──────────┘└──────────┘                     │
│                                                            │
│  ── ПОРТЫ (интерфейсы) ────────────────────────────────    │
│  PaymentRail · NotificationChannel · MapProvider ·         │
│  StorageProvider · CalendarProvider · SearchProvider       │
└──────────────┬─────────────────────────────────────────────┘
               │
   ┌───────────┼─────────────┬──────────────┬───────────────┐
   ▼           ▼             ▼              ▼               ▼
Postgres     Redis      Cloudflare R2   BOG iPay      WhatsApp Cloud
(+PITR)   (кэш,BullMQ)   (медиа)        (эквайринг)      (Meta)

┌───────────────────────────────────────────────────────────┐
│  apps/worker  (BullMQ) — уведомления, напоминания,        │
│  материализация слотов, сверка платежей, отчёты            │
└───────────────────────────────────────────────────────────┘

Воркер — отдельный процесс на том же коде. Важно: падение воркера не роняет приём броней, а тяжёлая задача не съедает латентность API.

2. Структура репозитория

apps/
  web/            Next.js: публичный сайт, кабинеты туриста и партнёра
  admin/          Refine: админка, CRM оператора
  api/            NestJS: домен и HTTP
  worker/         BullMQ-процессы (переиспользует api/src)
packages/
  contracts/      zod-схемы + типы DTO, общие для всех приложений
  domain/         чистые функции: цены, возвраты, переходы статусов
  ui/             дизайн-система: токены, компоненты shadcn
  emails/         React Email шаблоны
  config/         eslint, tsconfig, prettier
infra/
  docker/         Dockerfile'ы, compose
  migrations/     Prisma миграции
  runbooks/       что делать, когда сломалось
docs/             этот каталог

packages/domain — важная деталь. Расчёт цены, расчёт возврата и таблица переходов статусов живут вне NestJS, как чистые функции без зависимостей. Их можно покрыть property-based тестами, прочитать глазами и переиспользовать на фронте для предпросмотра суммы. Именно там сосредоточена цена ошибки.

3. Правила модульности

  1. Модуль экспортирует только свой публичный сервис и типы. Никто не лезет в чужой репозиторий Prisma напрямую.
  2. Модули общаются синхронно через сервисы (в одной транзакции) или асинхронно через доменные события в outbox (вне транзакции).
  3. Циклических зависимостей между модулями нет — проверяется eslint-plugin-boundaries в CI.
  4. Внешний мир — только через порт-интерфейс. Ни одного fetch('https://api.bog.ge') вне payments/rails/bog/.

4. API

REST + OpenAPI. Не GraphQL: избыточен для одного клиента и усложняет кэширование и rate limiting; не tRPC: админка и потенциальное мобильное приложение выиграют от честной OpenAPI-спеки.

Разделение по аудитории — три префикса с разными политиками:

/api/public/v1/*    без авторизации, агрессивно кэшируется на CDN
/api/app/v1/*       авторизованный пользователь (турист, гид, поставщик, партнёр)
/api/admin/v1/*     оператор и админ, 2FA, полный аудит, жёсткий rate limit
/api/webhooks/*     входящие от эквайера и Meta, проверка подписи, без CORS

Конвенции: версия в пути; курсорная пагинация; ошибки в формате RFC 9457 (application/problem+json) с машиночитаемым code; Idempotency-Key обязателен на мутациях денег и броней; traceparent пробрасывается сквозь всё для трейсинга.

Клиент для фронта генерируется из OpenAPI. Руками HTTP-вызовы не пишутся — это убирает целый класс рассинхронов.

5. Кэширование

Что Где TTL / инвалидация
Страницы каталога, категорий, направлений Next.js ISR + CDN 5 мин + тег-ревалидация при публикации
Карточка экскурсии ISR 5 мин + ревалидация по событию
Доступность (слоты) Redis 60 с, сброс при брони
Результаты поиска Redis 2 мин по ключу фильтров
Настройки, правила цен in-memory + Redis pub/sub сброс при изменении в админке
Медиа Cloudflare CDN год, версионирование по хешу

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

6. Поиск и фильтры

Фильтры из наброска главной: язык, время дня, продолжительность, цена, рейтинг, категория, направление, спец. условия.

R1 — чистый Postgres: составные индексы под частые комбинации, pg_trgm GIN для текстового поиска по названию и описанию, tsvector с русской и английской конфигурацией. На объёме в сотни-тысячи экскурсий это работает быстрее, чем любой внешний поиск, и не добавляет сервиса, который надо бэкапить.

Meilisearch добавляем только если появится фасетный поиск по десяткам тысяч позиций или опечаточный поиск станет критичным. Порог перехода фиксируем заранее: p95 поиска > 300 мс.

7. Медиа и видео

Видео на главной (референс — Withlocals) и в карточках — это самая тяжёлая часть трафика.

  • Изображения: загрузка в R2 → пересжатие в AVIF/WebP в нескольких размерах фоновой задачей → отдача через CDN, next/image с кастомным лоадером.
  • Видео: короткие hero-ролики — mp4/webm на R2 за CDN, preload="none", постер-кадр, автоплей только на десктопе. Длинные — Cloudflare Stream или Mux (HLS, адаптивный битрейт).
  • Правило: главная страница на мобильном не грузит видео, пока пользователь не проскроллил — иначе LCP и мобильный трафик страдают, а это основная аудитория.

8. Многоязычность

  • Интерфейс: next-intl, локали ru / en (ka заложена).
  • Контент: таблица translations, фоллбек на язык по умолчанию.
  • URL: /ru/..., /en/..., hreflang, канонические ссылки.
  • Язык проведения экскурсии — отдельная сущность от локали интерфейса.

9. Точки расширения, заложенные заранее

Будущее требование Что заложено сейчас
Новые регионы и страны region_id везде, регион в URL и фильтрах
Комиссионная модель, сплит леджер, PricingRule, PaymentRail.split
Вознаграждение партнёров деньгами леджер с валютой PTS → конвертация
Мобильное приложение REST+OpenAPI, авторизация по токенам
Новый дизайн «с нуля» PageBlock + токены + shadcn в репозитории
Дистрибуция через Viator/GYG external_refs jsonb на экскурсии и брони
Онлайн-оплата B2B тот же Booking с channel=B2B, рельс уже есть
Динамические цены PricingRule с приоритетами и периодами
Техника

Деньги: платежи, комиссии, леджер, выплаты

Самый важный документ проекта. Здесь же — риск №1.


1. Кто такая платформа с точки зрения денег

Развилка, которую нужно закрыть до первой строчки кода (Q4, Q5):

Модель A — Merchant of Record (рекомендую). Платформа принимает деньги на свой счёт, сама отвечает перед туристом, сама разбирает чарджбэки, потом выплачивает гидам и поставщикам.

  • ➕ один договор эквайринга, один счёт, полный контроль над возвратами, возможность держать предоплату до завершения экскурсии.
  • ➖ платформа несёт риск чарджбэка и налоговые обязательства с полного оборота, нужна аккуратная бухгалтерия.

Модель B — маркетплейс со сплитом. Каждый гид/поставщик — субмерчант, деньги делятся на уровне эквайера. BOG iPay сплит поддерживает.

  • ➕ меньше налоговой нагрузки на платформу, меньше ручных выплат.
  • ➖ каждый гид должен быть зарегистрирован как ИП и пройти KYC у банка. В Батуми, где половина гидов работает неформально, это убивает онбординг.

Решение: A на R1–R2, архитектура допускает B. В леджере это разница в одной проводке; переход на сплит не переписывает домен. Но это вопрос к заказчику и его бухгалтеру, не ко мне — от него зависят налоги. См. OTA guide: merchant of record.

2. Выбор эквайера

Требование из встречи: приём карт, зачисление на зарубежный (= грузинский) счёт.

Провайдер Кому доступен Сплит Холд (pre-auth) Токенизация Комментарий
BOG iPay (Bank of Georgia) клиент BOG с businessonline.ge ✅ ✅ ✅ ~60% рынка e-com Грузии, REST+OAuth2+JWT, вебхуки. Рекомендую как основной
TBC E-commerce клиент TBC ⚠️ ✅ ✅ сопоставим с BOG; хороший второй рельс
Flitt (группа TBC) юрлица GE/UZ/MD/AM ⚠️ ✅ ✅ PCI DSS L1, redirect / embedded / direct; международная ориентация
Keepz GE — — — карты + open banking + крипта; нишевой
Payze — ✅ ✅ ✅ по данным на сентябрь 2026 свернул работу в Грузии и не онбордит новых. Проверить лично перед тем, как рассматривать
Stripe ❌ — — — Грузия не в списке поддерживаемых стран мерчанта

План: BOG iPay основным, TBC/Flitt как второй рельс. Два рельса — это не паранойя: эквайер может заблокировать мерчанта за подозрительный профиль транзакций (тур-отрасль — категория повышенного риска), и тогда проект встаёт полностью. Абстракция PaymentRail позволяет переключить рельс конфигом.

Комиссии, по публичным данным: ~2.0–2.5% локальные карты, ~2.8–3.2% международные. Это закладываем в юнит-экономику, а не «узнаем потом».

3. 🔴 Риск №1: «принимать российские карты»

Разложу, потому что требование отмечено на встрече как ключевое, а в текущем виде технически невыполнимо.

Факты:

  • Карты Visa/Mastercard, выпущенные в РФ, не работают за пределами РФ с 2022 года. Это отключение на уровне международных платёжных систем, никакой эквайер это не обходит.
  • «Мир» в Грузии не принимается. Грузинские банки к НСПК не подключены.
  • UnionPay российских банков теоретически работает, практически — нестабильно и у крайне узкого круга эмитентов.

Значит, реальных вариантов четыре:

Вариант Как работает Цена/риск
1. Карты россиян, выпущенные вне РФ (Казахстан, Армения, Киргизия, ОАЭ) обычные международные карты, проходят штатно ✅ ничего делать не надо, покрывается основным эквайрингом. Очень вероятно, что заказчики имели в виду именно это
2. Отдельный российский рельс (ЮKassa/CloudPayments) ЮKassa работает с нерезидентами, принимает российские карты ⚠️ требует договора, идентификации, есть ограничения для компаний из ряда стран; вывод на зарубежный счёт и валютный контроль — вопрос к юристу и бухгалтеру, не ко мне. Санкционные и налоговые риски я оценить не могу
3. Переводы (Korona Pay и аналоги) турист переводит вручную, оператор сверяет ⚠️ не автоматизируется как эквайринг: нет мгновенного подтверждения, нет чарджбэков, ручная сверка. Реализуемо как «ручной рельс» с подтверждением оператором
4. Крипта (USDT) через Keepz/CityPay принимается стейблкоин ⚠️ нишевая аудитория, отдельный комплаенс

Что делаю я: архитектура с первого дня поддерживает несколько одновременных рельсов и выбор рельса по стране карты/предпочтению туриста. В R2 включаю вариант 1 (бесплатно) и вариант 3 как ручной рельс. Варианты 2 и 4 — только после юридического заключения.

Что нужно от заказчика: ответ на Q3 и, если речь про вариант 2, консультация грузинского и российского юриста. Это не техническая задача, и её нельзя решить кодом.

4. Абстракция платёжного рельса

interface PaymentRail {
  readonly code: 'bog' | 'tbc' | 'flitt' | 'manual_transfer' | 'yookassa';
  readonly capabilities: {
    hold: boolean; partialRefund: boolean; tokenization: boolean; split: boolean;
  };

  createIntent(input: CreateIntentInput): Promise<IntentResult>;  // → ссылка на оплату
  capture(paymentId: string, amountMinor: bigint): Promise<CaptureResult>;
  refund(paymentId: string, amountMinor: bigint, reason: string): Promise<RefundResult>;
  void(paymentId: string): Promise<void>;

  verifyWebhook(rawBody: Buffer, headers: Headers): WebhookEvent | null;  // подпись!
  fetchStatus(externalId: string): Promise<PaymentStatus>;                // для сверки
}

Правила:

  • Домен брони не знает названия банка. Он знает PaymentIntent.
  • Выбор рельса — в PaymentRouter по правилам (страна, валюта, сумма, доступность рельса), правила редактируются в админке.
  • Реализация рельса не содержит бизнес-логики: только перевод формата и подписи.
  • verifyWebhook обязательно проверяет подпись и защищается от replay (nonce + таймстемп + таблица обработанных событий).
  • Никогда, ни при каких условиях, номер карты не проходит через наш бэкенд. Только hosted-страница или iframe эквайера. Это разница между PCI DSS SAQ-A (анкета) и полноценной сертификацией.

5. Леджер (двойная запись)

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

LedgerAccount(id, owner_type, owner_id, kind, currency)
  kinds: PLATFORM_CASH, PLATFORM_REVENUE, CUSTOMER_LIABILITY,
         GUIDE_PAYABLE, SUPPLIER_PAYABLE, PARTNER_POINTS,
         PSP_RECEIVABLE, REFUND_CLEARING, TAX_PAYABLE

LedgerTransaction(id, type, booking_id?, payment_id?, created_at, description)
LedgerEntry(id, transaction_id, account_id, direction(DEBIT|CREDIT),
            amount_minor, currency)

Инвариант, проверяемый в коде и CHECK-констрейнтом: сумма дебетов любой транзакции равна сумме кредитов, в каждой валюте отдельно. Проводки никогда не удаляются и не редактируются — только сторнируются обратной записью.

Пример: бронь 400 GEL, предоплата 30% = 120 GEL, комиссия платформы 20%.

1) Оплата предоплаты (вебхук CAPTURED):
   DEBIT  PSP_RECEIVABLE        120.00
   CREDIT CUSTOMER_LIABILITY    120.00      ← деньги туриста, мы их ещё не заработали

2) Поступление от эквайера на счёт (сверка выписки), комиссия 2.5%:
   DEBIT  PLATFORM_CASH         117.00
   DEBIT  PSP_FEES                3.00
   CREDIT PSP_RECEIVABLE        120.00

3) Экскурсия завершена (COMPLETED) — только теперь признаём выручку:
   DEBIT  CUSTOMER_LIABILITY    120.00
   CREDIT PLATFORM_REVENUE       80.00      ← комиссия 20% от 400
   CREDIT GUIDE_PAYABLE          40.00      ← остаток предоплаты гиду

4) Баллы партнёру (отдельная валюта, не смешивается):
   DEBIT  PLATFORM_POINTS_EXPENSE  400 PTS
   CREDIT PARTNER_POINTS           400 PTS

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

6. Движок цен и комиссий

Требование «в админке должно шататься всё (цены, промокоды, проценты)» означает: правила — это данные, а не код.

PricingRule(id, scope_type, scope_id, priority, valid_from, valid_to,
            prepayment_type(PERCENT|FIXED), prepayment_value,
            commission_type(PERCENT|FIXED), commission_value,
            currency, region_id, created_by)
  scope_type: GLOBAL | REGION | CATEGORY | EXCURSION | ORGANIZATION | GUIDE

Разрешение: берём все подходящие правила, сортируем по специфичности (GUIDE > EXCURSION > ORGANIZATION > CATEGORY > REGION > GLOBAL), затем по priority, применяем первое. Результат расчёта сохраняется в бронь вместе с pricing_rule_id и снимком правила — чтобы через полгода можно было объяснить, почему получилась именно эта сумма.

Правило безопасности: любое изменение PricingRule требует роли ADMIN, пишется в аудит-лог со старым и новым значением, и не применяется задним числом к существующим броням.

Расчёт стоимости — чистая функция, покрытая property-based тестами:

calculatePrice(excursion, slot, pax, promo, partner, rules, now) → PriceBreakdown

PriceBreakdown возвращает построчную раскладку (база × человек, скидка, комиссия, предоплата, к оплате на месте), которая показывается туристу и хранится в брони. Никаких «магических» итогов.

7. Возвраты и политика отмены

CancellationPolicy(id, name, rules jsonb)
  rules: [{ hoursBefore: 48, refundPercent: 100 },
          { hoursBefore: 24, refundPercent: 50  },
          { hoursBefore: 0,  refundPercent: 0   }]

Политика привязывается к экскурсии, её снимок сохраняется в бронь. Расчёт возврата — тоже чистая функция. Если отменяет платформа или гид — возврат 100% всегда, независимо от таймингов.

Возврат идёт через тот же рельс, которым платили. Частичные возвраты поддерживаются. Каждый возврат — отдельная сущность со своим статусом и сторнирующими проводками.

8. Выплаты гидам и поставщикам

R2: реестр к выплате, не автоматические переводы. Админка показывает «к выплате по гидам за период» из GUIDE_PAYABLE, админ формирует Payout (период, получатель, сумма, метод), отмечает исполненным, прикладывает подтверждение. Проводка закрывает обязательство.

Автоматические выплаты — только после ответа на Q8 и отдельного решения по сплиту (§1, модель B).

9. Защита от типовых ошибок

Ошибка Защита
Двойное списание при двойном клике Idempotency-Key, уникальный индекс
Овербукинг транзакция + FOR UPDATE + CHECK(booked <= capacity)
Потерянный вебхук сверочная задача каждые 15 мин + алерт на расхождение
Повторный вебхук таблица обработанных событий по external_event_id
Подделанный вебхук проверка подписи, IP-allowlist эквайера
Ошибки округления целые минорные единицы, округление только в одном месте, тест на сумму частей = целому
Смена комиссии ломает старые заказы снимок правила в брони
Админ «поправил» сумму руками запрещено; только через сторнирующую проводку с причиной
Расхождение с банком ежедневная автосверка выписки, отчёт о расхождениях
Утечка ключей эквайера секреты в Coolify/секрет-менеджере, не в репозитории; ротация; отдельные ключи staging

10. Тестирование денег

Отдельный уровень строгости:

  • Unit + property-based тесты на calculatePrice и calculateRefund (fast-check): сумма раскладки всегда равна итогу, скидка никогда не делает сумму отрицательной, комиссия никогда не превышает gross.
  • Инвариант леджера проверяется тестом на каждом сценарии: после любой последовательности операций сумма всех дебетов = сумме всех кредитов.
  • Интеграционные тесты на sandbox эквайера: успех, отказ, таймаут, потерянный вебхук, дубль вебхука, частичный возврат, возврат сверх суммы.
  • E2E в Playwright на полный путь «бронь → оплата → подтверждение → отмена → возврат».
  • Запрет на мердж в main при падении любого теста в денежных модулях.
Техника

Уведомления

WhatsApp — главный канал (решение встречи). Всё остальное — страховка.

1. Архитектура

Один интерфейс, много каналов, маршрутизация по правилам:

interface NotificationChannel {
  code: 'whatsapp' | 'email' | 'telegram' | 'sms' | 'inapp';
  isAvailable(): Promise<boolean>;
  send(to: Recipient, template: RenderedTemplate): Promise<DeliveryResult>;
}
Domain event (outbox) → NotificationDispatcher
                          ├─ подбор шаблона по (event, role, locale)
                          ├─ маршрут по правилам канала
                          ├─ BullMQ job с ретраями 1м/5м/30м
                          └─ фоллбек по цепочке при исчерпании ретраев

Цепочка фоллбека по умолчанию: WhatsApp → (15 мин) → Email → (30 мин) → задача оператору в CRM

Задача оператору — не «на всякий случай», а обязательный последний рубеж: если турист не получил подтверждение ни одним каналом, это должен увидеть человек.

Каждая отправка пишется в NotificationDispatch (событие, получатель, канал, шаблон, статус, external_id, попытки, ошибка). Это и аудит, и то, что оператор видит в карточке брони: «подтверждение доставлено в WhatsApp в 14:32».

2. WhatsApp: что нужно знать до того, как обещать заказчику

Реализация — Meta WhatsApp Cloud API напрямую (не Twilio: лишняя наценка; 360dialog — вариант, если понадобится упрощённый онбординг).

Требования к запуску, все с длинным сроком:

  1. Верифицированный Meta Business Account — нужны документы юрлица (Q1).
  2. Подтверждённый номер телефона, не привязанный к обычному WhatsApp.
  3. Шаблоны сообщений проходят модерацию Meta — от часов до дней, отклоняются за формулировки. Шаблоны нужно подать заранее.
  4. Display name тоже модерируется.

Ограничения, которые определяют дизайн:

  • Вне 24-часового окна диалога можно отправлять только утверждённые шаблоны. Все наши уведомления — шаблонные, это нормально.
  • Тарификация с июля 2025 — за доставленное шаблонное сообщение, по категориям (utility / marketing / authentication) и стране получателя. С 1 октября 2026 тарифицируется больше типов сообщений.
  • Наши подтверждения, напоминания и статусы — категория utility (дешевле и без ограничений по объёму). Промо-рассылки — marketing, дороже и без объёмных скидок. Не смешивать в одном шаблоне: добавишь «а ещё посмотрите наши туры» в подтверждение брони — Meta переклассифицирует его в marketing, и стоимость вырастет в разы.
  • Бюджет: при ~500 броней/мес и ~5 сообщениях на бронь это тысячи сообщений; считать по актуальному прайсу для Грузии и РФ отдельно.

Источники: SetSmart, EngageLab — изменения с 1 октября 2026.

Юридическая часть: для marketing-сообщений нужен явный opt-in с сохранением доказательства (время, IP-хеш, текст согласия). Utility по факту брони — законно. Галочка согласия на маркетинг в форме брони не должна быть предотмечена.

3. Email

Провайдер: Resend (простой API, хорошая доставляемость, дешёвый) или Postmark (дороже, лучше по транзакционной доставляемости). Шаблоны — React Email, живут в монорепо рядом с кодом.

Обязательная настройка домена: SPF, DKIM, DMARC. Без них письма о бронировании уходят в спам, и фоллбек не работает именно тогда, когда он нужен.

Делаем в R1 вопреки «можно не брать в работу» — см. ADR-020.

4. Telegram

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

Для туристов — опционально, вторым приоритетом после WhatsApp.

5. SMS

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

6. Матрица событий и каналов

Событие Турист Гид Поставщик Партнёр Оператор
Заявка создана WA+Email — — — TG
Гид назначен WA WA — — —
Ссылка на оплату WA+Email — — — —
Оплата прошла, бронь подтверждена WA+Email+.ics WA — — —
Оплата не прошла WA — — — TG
Напоминание за 24 ч WA WA — — —
Напоминание за 2 ч WA WA — — —
B2B-заявка от гида — — WA — —
B2B подтверждена/отклонена — WA — — TG при отказе
Отмена WA+Email WA WA — TG
Возврат исполнен WA+Email — — — —
Запрос отзыва (+2 ч после) WA — — — —
Новый отзыв — WA — — TG (на модерацию)
Начислены баллы — — — Email —

Правило тишины: никаких уведомлений с 22:00 до 08:00 по Asia/Tbilisi, кроме срочных (отмена в день экскурсии). Отложенные ставятся в очередь на утро.

7. Календарь

R1: к письму и в WhatsApp-сообщение прикладывается .ics + deep-link «Добавить в Google Calendar». Это покрывает 90% потребности за час работы.

R3: полноценная двусторонняя синхронизация через Google Calendar API (OAuth гида, создание события, обновление при переносе, отмена). Двусторонняя синхронизация — известный источник боли (конфликты, токены, webhook renewal), поэтому явно откладывается.

Техника

Безопасность и персональные данные

Ты писал, что переживаешь именно за это. Ниже — конкретный, проверяемый список.

1. Правовой контур

Грузия. Закон «О защите персональных данных» принят 14.06.2023, вступил в силу 01.06.2024 (отдельные нормы — 01.03.2024 и 01.01.2025). Он сознательно сближен с GDPR: обязанности контролёра, DPIA, уведомление об инцидентах, права субъекта, трансграничная передача, институт уполномоченного по защите данных.

Назначение офицера по защите данных (DPO) обязательно для госорганов, банков, страховых, медицинских. Для остальных — если обрабатывается персональных данных более 3% населения Грузии, либо особых категорий более 1%, либо ведётся систематический масштабный мониторинг поведения. Наш проект под эти пороги не попадает, DPO не обязателен — но это должен подтвердить местный юрист, а не я.

GDPR. Туристы из ЕС — почти наверняка. Предложение услуг субъектам в ЕС подводит нас под экстерриториальное действие GDPR. Практический вывод: проще сразу соблюдать GDPR, тогда грузинский закон выполняется автоматически.

Что нужно подготовить к запуску (юридические документы, не код): политика конфиденциальности (RU/EN), пользовательское соглашение, политика cookie + баннер согласия, согласие на маркетинговые рассылки, реестр операций обработки, DPA с подрядчиками (хостинг, Sentry, Resend, Meta, эквайер), порядок ответа на запросы субъектов, порядок уведомления об утечке (72 часа).

Источники: текст закона (англ.), DLA Piper — Georgia.

⚠️ Это не юридическая консультация. Документы должен согласовать грузинский юрист по защите данных. Стоимость такой консультации — небольшая часть бюджета проекта, и это правильная трата.

2. Какие персональные данные мы вообще обрабатываем

Принцип минимизации: если поле не нужно для оказания услуги — его нет в базе.

Данные Зачем Срок хранения
Имя, фамилия оформление брони 3 года после последней брони
Телефон (WhatsApp) основной канал связи то же
Email фоллбек-канал, чеки то же
Язык, гражданство (опц.) подбор гида то же
Состав группы (взрослые/дети) вместимость то же
IP, user-agent атрибуция, антифрод только хеш, 30 дней
Cookie атрибуции партнёрская программа 30 дней
История броней и платежей бухгалтерия по налоговому сроку Грузии
Отзывы публичные бессрочно, имя сокращаем до «Анна К.»

Чего в базе нет и не будет: номера карт, CVV, паспортные данные, данные о здоровье, точная геолокация. Если заказчик попросит паспорта (бывает для некоторых туров) — это особая категория, отдельный разговор и отдельные требования.

3. Технические меры

Шифрование

  • TLS 1.3 везде, HSTS, редирект с http.
  • Шифрование диска БД (у managed-провайдера — из коробки).
  • Колоночное шифрование телефона и email (pgcrypto или шифрование на уровне приложения с ключом в секрет-менеджере) + отдельная колонка с детерминированным хешем для поиска. Причина: дамп базы, утёкший через бэкап, не должен быть готовой базой контактов туристов.
  • Бэкапы шифруются отдельным ключом, который не лежит там же, где бэкапы.

Доступ

  • Прод-доступ к БД — только через bastion, по ключам, с логированием. Разработчик не ходит в прод-базу «посмотреть».
  • Админка: обязательная 2FA для ролей ADMIN и OPERATOR.
  • Принцип минимальных прав: оператор не видит платёжные реквизиты и не меняет комиссии.
  • Аудит-лог просмотра перс. данных: если оператор открыл карточку туриста — это строка в логе. Это и требование закона, и защита от слива базы своим же сотрудником.

Логи

  • Запрещено логировать: телефоны, email, токены, тела платёжных запросов. Реализуется redaction-интерсептором на уровне логгера + тестом, который проверяет, что в логах нет паттернов телефона/email.
  • Sentry: beforeSend вычищает PII, sendDefaultPii: false.

Приложение

  • Валидация всего входящего через zod на границе, включая вебхуки.
  • Rate limiting: по IP и по пользователю, отдельно жёсткий на аутентификацию, отправку OTP и создание броней.
  • CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy.
  • CSRF-защита для cookie-сессий админки.
  • Все запросы к БД — через Prisma (параметризация); $queryRaw только с тегированными шаблонами, никогда конкатенацией.
  • Загрузка файлов: проверка MIME по содержимому, лимит размера, пересжатие изображений (убивает вложенные эксплойты и EXIF-геометки), отдача с отдельного домена.
  • Секреты — в секрет-менеджере Coolify, не в .env в репозитории. gitleaks в pre-commit и в CI.

Права субъекта данных

  • Экспорт своих данных — кнопка в ЛК, генерирует JSON асинхронно.
  • Удаление — псевдонимизация: имя/телефон/email затираются, бронь и проводки остаются (бухгалтерия обязывает), отзывы анонимизируются. Полное удаление строки ломает леджер — так делать нельзя.
  • Оба сценария реализуются в R2, не откладываются «на потом».

4. Зависимости и цепочка поставок

  • pnpm audit + Dependabot/Renovate в CI, обновления безопасности — раз в неделю.
  • Lockfile обязателен, --frozen-lockfile в CI.
  • Минимум зависимостей в денежных модулях.
  • Образы Docker — от официальных базовых, сканирование Trivy в CI.

5. Что делаем при инциденте

Рантбук runbooks/incident-data-breach.md (пишется в M6): изоляция → оценка объёма → уведомление службы данных Грузии в течение 72 ч → уведомление субъектов, если высокий риск → разбор → отчёт. Рантбук должен существовать до инцидента, иначе в момент паники никто не вспомнит про 72 часа.

6. Чек-лист перед запуском

  • Пентест-лайт: OWASP Top-10 прогон по основным флоу
  • Проверка IDOR: может ли турист A открыть бронь туриста B по id
  • Проверка прав на каждом эндпоинте (автотест по матрице ролей)
  • Нет секретов в git-истории (gitleaks по всей истории)
  • Логи без PII (автотест)
  • Бэкап восстановлен на чистом сервере и проверен
  • 2FA включена у всех админов
  • Юридические документы опубликованы и согласованы юристом
  • DPA подписаны со всеми подрядчиками
  • staging закрыт от индексации и от публичного доступа
  • На staging нет боевых персональных данных
Техника

Инфраструктура, окружения, бэкапы, CI/CD

Отдельное внимание, потому что ты просил «во всех местах уметь фоллбекаться и бэкапиться».

1. Окружения

Окружение Где Данные Кто видит Эквайринг
local твоя машина, docker-compose сиды ты мок-рельс
preview Coolify, по одному на PR сиды ты мок-рельс
staging staging.<домен>, отдельный сервер/проект обезличенная копия прода ты + заказчик sandbox банка
production <домен> боевые все боевой

staging — твоя идея, поддерживаю и усиливаю:

  • закрыт basic-auth + X-Robots-Tag: noindex;
  • на нём нет боевых персональных данных. Копия прода прогоняется через скрипт анонимизации: телефоны и email заменяются на сгенерированные, имена — на фейковые, суммы остаются. Скрипт — часть репозитория и единственный разрешённый способ залить данные на staging;
  • отдельные ключи всех интеграций, отдельный WhatsApp-номер (или mock-канал), отдельная база;
  • каждый релиз сначала едет на staging, заказчик принимает там.

2. Продакшн-топология

              Cloudflare (DNS, CDN, WAF, DDoS, R2)
                            │
                  ┌─────────┴──────────┐
                  │  Hetzner VPS (CX32) │  Coolify
                  │  ┌──────┐ ┌───────┐ │
                  │  │ web  │ │ admin │ │
                  │  └──────┘ └───────┘ │
                  │  ┌──────┐ ┌───────┐ │
                  │  │ api  │ │worker │ │
                  │  └──────┘ └───────┘ │
                  │  ┌──────┐           │
                  │  │redis │           │
                  │  └──────┘           │
                  └─────────┬───────────┘
                            │ TLS, приватная сеть
                  ┌─────────┴───────────┐
                  │ Managed PostgreSQL  │  автобэкапы + PITR
                  └─────────────────────┘

Regional note: сервер во Франкфурте или Хельсинки — ~50 мс до Батуми, приемлемо, и это территория ЕС (проще с GDPR). Данные в ЕС — сознательный выбор, подтвердить у юриста применительно к грузинскому закону о трансграничной передаче.

3. Бэкапы

Правило 3-2-1: три копии, два носителя, одна вне основной площадки.

Что Как Частота Хранение Проверка
Postgres автобэкап провайдера + PITR (WAL) непрерывно 14 дней ежемесячный тестовый рестор
Postgres pg_dump → зашифрованный архив → R2 ежедневно 04:00 30 дней + 12 месячных автопроверка целостности
R2 медиа версионирование объектов + репликация в другой бакет непрерывно 30 дней ежеквартально
Секреты экспорт из Coolify → зашифрованный архив офлайн при изменении — ежеквартально
Код GitHub + зеркало при пуше — —

RPO ≤ 5 минут (PITR), RTO ≤ 1 час.

Ключевая дисциплина, без которой бэкапы бесполезны: бэкап, который ни разу не восстанавливали, — это не бэкап. Раз в месяц по календарю: поднять чистый сервер, восстановить вчерашний дамп, прогнать смоук-тесты, записать время. Это занимает час и однажды спасёт проект. В M6 это оформляется как рантбук и первый прогон.

4. Рантбуки (infra/runbooks/)

Пишутся в M6, но список фиксирую сейчас — чтобы не «вспоминать» в панике:

  • db-restore.md — восстановление БД на момент времени
  • payment-stuck.md — платёж висит в PROCESSING
  • webhook-missed.md — вебхуки не приходят, ручной прогон сверки
  • whatsapp-down.md — канал недоступен, переключение на email
  • rollback-release.md — откат релиза и миграции
  • incident-data-breach.md — утечка перс. данных, 72 часа
  • overbooking.md — продано больше мест, чем есть
  • acquirer-blocked.md — эквайер заблокировал мерчанта, переключение рельса

5. CI/CD (GitHub Actions)

На каждый PR: typecheck → lint (+ boundaries, + запрет float в денежных модулях) → unit → integration (Testcontainers Postgres+Redis) → prisma migrate diff (нет ли дрейфа схемы) → gitleaks → trivy → сборка образов → деплой preview-окружения → Playwright smoke на preview.

Мердж в main: автодеплой на staging + миграции + smoke + уведомление в Telegram.

Релиз в прод: по тегу, с ручным подтверждением. Порядок строгий:

  1. бэкап БД прямо перед релизом;
  2. миграции только обратимо-совместимые (expand/contract: сначала добавили колонку, задеплоили код, потом удалили старую — двумя релизами);
  3. деплой api → worker → web/admin;
  4. smoke-тесты;
  5. при провале — автооткат образов, миграции откатываются вручную по рантбуку.

Запрет: прямой пуш в main, мердж с красным CI, деплой в прод в пятницу после 16:00 и в день экскурсионного пика.

Сборка образов — на Coolify-хосте, не на этой VPS (см. CLAUDE.md: 4 ГБ, был OOM). В CI образы собираются раннерами GitHub.

6. Мониторинг и алерты

Сигнал Порог Куда
Ошибки 5xx > 1% за 5 мин Telegram
p95 латентности API > 800 мс Telegram
Неуспешные платежи > 20% за 15 мин Telegram + email
Расхождение сверки с банком любое Telegram + задача
Очередь уведомлений растёт > 100 или задержка > 10 мин Telegram
Недоставленные WhatsApp > 10% Telegram
Место на диске > 80% Telegram
Свободная память < 15% Telegram
Сертификат TLS < 14 дней Telegram
Аптайм извне 2 проверки подряд Telegram + звонок
Бэкап не выполнился любой пропуск Telegram

Дашборды: бизнес (брони, конверсия, GMV, отмены) и технический (латентность, ошибки, очереди, БД). Бизнес-дашборд — заказчику, это снимает половину вопросов «а как там дела».

7. Стоимость эксплуатации (оценка, в месяц)

Позиция ~$
Hetzner CX32 (прод) 15
Hetzner CX22 (staging) 7
Managed Postgres 20–30
Cloudflare (Pro, опц.) 0–25
R2 хранилище + Stream 5–20
Resend 0–20
Sentry 0–26
Grafana Cloud 0 (free tier)
Better Stack 0–10
Google Maps 0–50 (зависит от трафика, есть бесплатный лимит)
WhatsApp по факту сообщений, ~20–100
Домен ~1
Итого ~70–300

Плюс эквайринг ~2.5–3.2% от оборота. Google Maps — известная ловушка с неожиданными счетами: сразу ставим квоты и биллинг-алерты.

Поставка

Порядок работ

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

Значение имеет другое: в каком порядке идут работы, что чем блокируется и на каком шаге появляется что-то работающее.


Что на самом деле определяет дату запуска

Блокер Чья очередь Когда подавать
🔴 Договор интернет-эквайринга с банком Bank of Georgia немедленно
🔴 Верификация Meta Business + модерация WhatsApp-шаблонов Meta немедленно
🔴 Юрлицо в Грузии и банковский счёт регистратор, банк если ещё нет — немедленно
🔴 Ответы заказчиков на Q3–Q6, Q10 заказчики переписка, зависит от них
🟡 Контент: тексты, фото, видео экскурсий заказчики начать сейчас
🟡 Юридические документы (ПДн, оферта) юрист до запуска
🟡 Твоё ревью и приёмка ты по ходу

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


Спринты

Каждый спринт заканчивается чем-то, что можно открыть на staging и потрогать. Порядок не произвольный: каждый следующий опирается на предыдущий.

S1 · Фундамент — ✅ сделано

Монорепо, строгий TS, линтеры, docker-compose, Prisma, первая миграция (User, Organization, Membership, Region, AuditLog), Better Auth (email + телефон-OTP), матрица прав, аудит-интерсептор, CI, staging на Coolify, Sentry, дизайн-токены.

Результат: ✅ монорепо, схема БД, миграция, сиды, API с health, витрина, дизайн-токены, линтер с архитектурными правилами, CI, Dockerfile'ы и compose для развёртывания. Локально проверено: миграция накатывается, сиды заводят регион, платформу и админа, API отвечает ok с живой базой, 21 тест зелёный, сборка проходит.

Остаётся из S1 (переносится в S2, не блокирует): Better Auth подключается вместе с первыми пользовательскими сценариями — без экранов входа его нечем проверить; Sentry — когда появится прод-домен.

S2 · Каталог и доступность — ⏳ в работе

Категории, направления, экскурсии, медиа, переводы, профили гидов, правила доступности → материализация слотов, публичный API, страницы списка / экскурсии / категории / направления, поиск и фильтры, админка каталога, Google Maps.

Результат: ты заводишь экскурсию в админке — она появляется на сайте с расписанием и находится поиском.

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

Осталось: страницы категории и направления, карточка экскурсии, полнотекстовый поиск, админка каталога, загрузка медиа в R2, Google Maps.

Блокеры: нет. ✅ Q12 закрыт: экскурсия принадлежит платформе, гид — исполнитель. Слот привязан к паре (экскурсия, гид), рейтинги двойные, net-rate живёт в ExcursionGuide.

S3 · Бронирование

Booking + стейт-машина + история статусов, резерв мест с блокировкой строки, идемпотентность, гостевое оформление, magic-link к истории, политики отмены, очередь оператора, назначение гида.

Результат: путь «бронь → оператор назначил гида → подтверждение» проходится на staging без моего участия. Блокеры: 🟡 Q6 (текст условий отмены), Q10 (SLA оператора) — делаю на заглушках, подставляю реальные значения по ответу.

S4 · Уведомления

Transactional outbox, BullMQ, диспетчер каналов, фоллбек-цепочка, email через Resend + шаблоны, Telegram для внутренних, напоминания за 24 ч и 2 ч, .ics и Google Calendar-ссылка, отзывы и модерация.

Результат: уведомления доставляются и видны в карточке брони. Блокеры: 🔴 WhatsApp. Код пишу сразу, канал работает в mock-режиме до верификации Meta. Переключение на боевой — одна переменная окружения.

S5 · Сайт и партнёры → R1

Главная по макету (hero-видео, поиск, фильтры, категории, направления, популярные, подборки, «почему мы», отзывы, FAQ), PageBlock и редактирование страниц, партнёрские ссылки и QR, атрибуция, ЛК партнёра, карта партнёров, SEO (sitemap, schema.org, hreflang), RU/EN, бюджет производительности.

🚩 R1 — продаваемая витрина. Заявки идут через оператора, деньги пока офлайн. Можно лить трафик и проверять спрос. Блокеры: 🟡 контент. Здесь код будет ждать тексты и фото, а не наоборот.

S6 · Деньги

Леджер с двойной записью, PricingRule и calculatePrice с property-тестами, промокоды, PaymentRail + mock-рельс, реализация BOG iPay, вебхуки с проверкой подписи и защитой от replay, сверочная задача, возвраты полные и частичные, баллы партнёров, админка комиссий и промокодов, реестр к выплате.

Результат: на sandbox банка проходит весь чек-лист из payments §10, включая потерянный и дублированный вебхук; инвариант леджера не падает ни в одном тесте. Блокеры: 🔴 доступ к sandbox банка — без него пишу против mock-рельса и не могу проверить реальные форматы. 🔴 Q4, Q5 (комиссия, предоплата, кто несёт возврат) — без них движок цен настраивать не на что.

S7 · CRM и B2B → R2

Воронка заявок, карточка брони с логом коммуникаций, создание брони оператором, услуги поставщиков, B2B-бронь, подтверждение поставщиком по одноразовой ссылке, связь B2B с B2C, дашборды, экспорт и псевдонимизация данных.

🚩 R2 — платформа принимает деньги.

S8 · Закалка и запуск

Автотест матрицы прав, проверка IDOR, OWASP-прогон, redaction PII в логах, CSP и rate limiting, 2FA админам, нагрузочный смоук, первый реальный тестовый рестор бэкапа, 8 рантбуков, алерты, проверка фоллбеков (выключаю WhatsApp, эквайер, Redis и смотрю, что система жива), инструкции операторам.

🚩 ЗАПУСК. Блокеры: 🔴 боевой эквайринг, 🔴 юридические документы от юриста.

S9 · Кабинеты гида и поставщика → R3

ЛК гида (профиль, экскурсии, календарь, брони), самостоятельное создание экскурсий с модерацией, гид сам бронирует услуги поставщиков, ЛК поставщика, двусторонний Google Calendar, ответы на отзывы.


Где я работаю медленно и почему

Есть места, где скорость — не добродетель. Это не оценка времени, это правило работы:

  • тесты денежных модулей и инвариант леджера;
  • идемпотентность операций с деньгами и бронями;
  • проверка подписи вебхуков и защита от повторов;
  • матрица прав доступа и защита персональных данных;
  • миграции: только обратимо-совместимые, с описанным откатом.

Сэкономленное здесь превращается в потерянные деньги заказчика, а разбираться с этим тебе, а не мне.

Критерии «релиз готов»

Применяется к R1, R2, R3:

  • Все задачи спринта закрыты, ничего не перенесено молча
  • CI зелёный, денежные модули покрыты тестами
  • Playwright e2e на критичных путях проходит
  • Ты принял на staging
  • Бэкап снят перед релизом
  • Миграции обратимо-совместимые, откат описан
  • Алерты настроены на новые сценарии
  • Рантбуки обновлены
Поставка

Бэклог: эпики и задачи

Список задач в порядке исполнения. Оценок времени здесь нет и не будет — значение имеет порядок, зависимости и что можно выбросить.

⚠️ — задача с повышенным риском ошибки. Тут я работаю медленно и с тестами, и это не обсуждается. 🔗 — упирается во внешний ответ или в чужую очередь на согласование.

Соответствие вех и спринтов: M0→S1, M1→S2, M2→S3+S4, M3→S5, M4→S6, M5→S7, M6→S8, M7→S9.


M0 · Фундамент

E0.1 Репозиторий и инструменты

  • Монорепо pnpm + Turborepo, 3 приложения, 5 пакетов
  • TS strict, ESLint (+ boundaries, + запрет float в packages/domain), Prettier
  • docker-compose: Postgres 17, Redis, mailpit
  • Скрипты: dev, test, db:migrate, db:seed, db:anonymize
  • README и CONTRIBUTING с правилами (деньги, миграции, секреты)

E0.2 Базовая схема и аутентификация

  • Prisma: Region, User, Organization, Membership, AuditLog, Translation
  • Better Auth: email+пароль, phone-OTP, сессии, организации ⚠️
  • Guard'ы и декораторы прав по матрице ролей ⚠️
  • Аудит-интерсептор: кто, что, когда, старое/новое значение

E0.3 CI/CD и окружения

  • GitHub Actions: typecheck, lint, test, gitleaks, trivy, build
  • Coolify: staging-проект, домены, TLS, секреты 🔗
  • Preview-окружения на PR
  • Sentry (web + api), OTel-скелет

M1 · Каталог и доступность

E1.1 Модель каталога

  • Category, Destination, Excursion, MediaAsset, Collection
  • translations + хелперы чтения с фоллбеком локали
  • ExcursionGuide, профиль гида (языки, опыт, рейтинг)
  • Сиды: 3 категории, 2 направления, 8 экскурсий, 5 гидов
  • region_id и фильтрация по региону сквозь всё

E1.2 Доступность и слоты ⚠️

  • AvailabilityRule, AvailabilityException
  • Материализация слотов на горизонт 120 дней (BullMQ cron) ⚠️
  • Пересчёт при изменении правил без потери существующих броней ⚠️
  • API доступности: календарь на месяц, слоты на дату
  • Тесты на пограничные случаи: DST, переносы, исключения

E1.3 Публичные страницы

  • Список экскурсий + фильтры (URL-состояние, SSR)
  • Карточка экскурсии по макету (все блоки)
  • Карточка категории
  • Карточка направления
  • Google Maps: место встречи, за абстракцией MapProvider

E1.4 Поиск

  • tsvector + pg_trgm, индексы, ранжирование
  • Автодополнение

E1.5 Админка: каталог

  • Refine-каркас, авторизация, layout
  • CRUD каталога, загрузка медиа в R2, публикация

M2 · Бронирование и уведомления

E2.1 Ядро бронирования ⚠️

  • Booking, BookingStatusHistory, ALLOWED_TRANSITIONS в packages/domain ⚠️
  • Резерв места: транзакция + FOR UPDATE + CHECK ⚠️
  • Идемпотентность мутаций (Idempotency-Key + таблица) ⚠️
  • Гостевое оформление + magic-link к истории
  • CancellationPolicy + calculateRefund + property-тесты ⚠️
  • Экраны: форма брони, подтверждение, история заказов

E2.2 Очередь оператора

  • Список заявок, назначение гида, подтверждение/отклонение
  • Флаг instantConfirm на экскурсии
  • Уведомление оператору в Telegram

E2.3 Уведомления

  • Transactional outbox + воркер публикации ⚠️
  • NotificationChannel, диспетчер, маршруты, фоллбек-цепочка
  • Email через Resend + React Email шаблоны (6 шт.)
  • Telegram-бот для внутренних
  • WhatsApp Cloud API: клиент, шаблоны, обработка статусов 🔗⚠️

E2.4 Отзывы

  • Создание после COMPLETED, окно 60 дней, один на бронь
  • Модерация в админке, статусы
  • Пересчёт рейтингов гида и экскурсии по событию

M3 · Сайт и партнёры → R1

E3.1 Главная и контент-блоки

  • PageBlock + рендереры (hero, сетки, карусели, отзывы, FAQ, CTA)
  • Главная по макету, hero-видео с ленивой загрузкой
  • Collection (подборки) + страницы подборок

E3.2 Партнёрская программа

  • Partner, PartnerLink, генерация QR (SVG/PNG)
  • AttributionTouch: cookie, хеширование IP/UA, окно 30 дней ⚠️
  • Приоритет промокода над cookie
  • ЛК партнёра: ссылки, QR, статистика

E3.3 Карта партнёров

E3.4 SEO и локализация

  • sitemap, robots, canonical, hreflang
  • schema.org разметка
  • next-intl, RU/EN
  • OG-картинки

E3.5 Производительность

  • Бюджет LCP, оптимизация изображений, аудит Lighthouse

M4 · Деньги ⚠️ (вся веха)

E4.1 Леджер ⚠️

  • LedgerAccount, LedgerTransaction, LedgerEntry, констрейнты
  • Сервис проводок с проверкой баланса дебет=кредит
  • Сценарии: бронь, оплата, завершение, отмена, возврат, баллы
  • Тест инварианта на всех сценариях

E4.2 Цены, комиссии, промокоды ⚠️

  • PricingRule + разрешение по специфичности
  • calculatePrice в packages/domain + property-тесты (fast-check)
  • PromoCode, PromoRedemption, атомарное списание
  • Админка: правила и промокоды с аудитом

E4.3 Эквайринг ⚠️🔗

  • PaymentRail + PaymentRouter + mock-рельс для тестов
  • Реализация BOG iPay: intent, capture, refund, void, status 🔗
  • Вебхуки: подпись, идемпотентность, replay-защита ⚠️
  • Сверочная задача каждые 15 мин + алерт
  • Страница оплаты, TTL, повторные попытки

E4.4 Возвраты ⚠️

  • Refund, полные и частичные, сторнирование
  • Инициация из ЛК, из CRM, автоматическая при отмене платформой

E4.5 Выплаты и баллы

  • Реестр к выплате, Payout, отметка исполнения
  • Начисление баллов партнёру, история

M5 · CRM и B2B → R2

E5.1 CRM оператора

  • Воронка заявок: канбан/таблица, фильтры, поиск
  • Карточка брони: всё в одном месте, лог уведомлений
  • Создание брони оператором от имени туриста
  • История коммуникаций, заметки

E5.2 Поставщики и B2B

  • SupplierService, профиль, расписание, net-rate
  • B2B-бронь (channel=B2B), своя стейт-машина ⚠️
  • Подтверждение по одноразовой ссылке из WhatsApp ⚠️
  • BookingItem: связь B2B с B2C, индикатор готовности программы

E5.3 Аналитика

  • Дашборд заказчика: брони, GMV, конверсия, отмены, партнёры
  • Выгрузки в CSV

E5.4 Права субъекта данных

  • Экспорт своих данных
  • Псевдонимизация вместо удаления ⚠️

M6 · Закалка и запуск

E6.1 Безопасность ⚠️

  • Автотест матрицы прав по всем эндпоинтам
  • Проверка IDOR, прогон OWASP Top-10
  • Redaction PII в логах + тест
  • CSP, заголовки, rate limiting, 2FA админам

E6.2 Надёжность

  • Нагрузочный смоук k6 (50 rps)
  • Реальный тестовый рестор бэкапа
  • Алерты по всем порогам
  • Проверка фоллбеков: выключить WhatsApp, эквайер, Redis

E6.3 Рантбуки и документация

  • 8 рантбуков из infrastructure §4
  • Инструкции для операторов и заказчика

E6.4 Запуск 🔗

  • Перевод эквайринга в прод, тестовые транзакции 🔗
  • Публикация юридических документов 🔗
  • Обучение команды заказчика
  • Мягкий запуск и ежедневный разбор первой недели

M7 · Самообслуживание → R3

  • ЛК гида: профиль, экскурсии, календарь, брони
  • Самостоятельное создание экскурсий + модерация
  • Гид бронирует услуги поставщиков сам
  • ЛК поставщика
  • Двусторонний Google Calendar ⚠️
  • Ответы гидов на отзывы

Что резать, если понадобится резать

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

  1. M7 целиком — кабинеты гида и поставщика. Их работу делает оператор, он и так в цепочке.
  2. B2B из M5 — «гид бронирует у поставщика» тоже ведётся оператором вручную. Самая большая экономия при наименьшей потере.
  3. Google Calendar двусторонний — хватает .ics и ссылки.
  4. Локализация EN — если первый трафик русскоязычный.
  5. Подборки и контент-блоки из M3 — витрина живёт и без них.

Чего не режем ни при каких условиях: M4 (леджер, идемпотентность, проверка подписи вебхуков) и M6 (права доступа, PII в логах, рестор бэкапа). Почти все задачи там помечены ⚠️ — это места, где сэкономленное сейчас превращается в потерянные деньги заказчика потом, и разбираться с этим тебе.