Перейти к содержанию

Модуль customer

Статус

Создан каркас Spring Modulith-модуля, публичная граница customer::api. Реализованы вход и регистрация покупателя по телефону и одноразовому коду (CustomerAuthChallenges), серверные refresh-сессии с несколькими устройствами (CustomerSessions), access JWT (CustomerAccessTokens), обновление профиля — имя и дата рождения (CustomerProfiles), сохранённые адреса (CustomerAddresses), блокировка номера до регистрации (customer.blocked_phone) и одноразовый перенос покупателей/адресов/блокировок из legacy MySQL (customer::migration, см. ниже). HTTP-граница storefront подключает /api/v1/auth/code, /login, /refresh, /logout, защищённые bearer-токеном /api/v1/users/me (GET/PATCH) и /api/v1/users/me/addresses (GET/POST/PATCH/DELETE) — см. модуль storefront. Доставка кода по SMS, Telegram и MAX подключена реально, через notifications::api (см. модуль notifications); для Telegram и MAX нужна разовая привязка телефона к чату бота (customer.telegram_contact/customer.max_contact, CustomerTelegramContacts/CustomerMaxContacts, вебхуки StorefrontTelegramWebhookController/ StorefrontMaxWebhookController в storefront) — без неё канал возвращает CustomerChannelUnavailableException (422 CHANNEL_UNAVAILABLE). Список сессий с отзывом одного устройства, logout-all, блокировка уже существующего покупателя через административный сценарий, вход через Telegram WebApp и перенос legacy-токенов не реализованы.

Назначение

Модуль customer отвечает за идентификацию и сессии покупателей сети.

Модуль владеет:

  • учётной записью покупателя CustomerEntity (телефон, имя, дата рождения, статус, версия сессий);
  • challenge одноразового кода (запрос, проверка, троттлинг повторной отправки, попытки);
  • refresh-сессиями по устройствам и их ротацией;
  • выпуском и проверкой access JWT покупателя;
  • редактируемыми полями профиля покупателя — имя и дата рождения (CustomerProfiles.update);
  • сохранёнными адресами покупателя (CustomerAddresses);
  • блокировкой номера телефона независимо от того, есть ли уже аккаунт (customer.blocked_phone);
  • привязкой телефона к чату Telegram- и MAX-бота (customer.telegram_contact/ customer.max_contact, CustomerTelegramContacts/CustomerMaxContacts) — ключ телефон, как у blocked_phone: контакт может появиться до регистрации аккаунта, а сам факт шаринга контакта в боте не является входом;
  • одноразовым переносом покупателей/адресов/блокировок из legacy MySQL (customer::migration).

Модуль не отвечает за:

  • HTTP-интерфейс — им владеет storefront (включая вебхуки Telegram- и MAX-бота);
  • сами провайдеры доставки (smsc.ru, Telegram Bot API, MAX Bot API) — ими владеет notifications; customer резолвит канал в конкретного получателя (телефон для SMS, chat_id из telegram_contact/max_contact для Telegram/MAX) и вызывает notifications::api;
  • учётные записи и доступ сотрудников ГК/ФЧ — ими владеет iam;
  • справочник городов, зоны доставки, привязку адреса к ресторану — этим будет владеть restaurant/catalog, когда появится геомодель (см. «Сохранённые адреса» ниже);
  • заказы, программу лояльности, согласие на рекламу и настройки уведомлений, блокировку уже существующего покупателя через отдельный административный API (пока есть только блокировка по номеру).

Сотрудники и покупатели — независимые контуры. Совпадение телефона не объединяет CustomerAccount и iam.UserAccount.

Границы и зависимости

Единственная разрешённая зависимость — notifications::api (используется для доставки кода по SMS/Telegram/MAX, см. ProviderCustomerCodeSender); домен входа по телефону и коду не требует данных iam, franchise или iiko.

Структура пакетов

ru.panampizza.monolith.customer
├── api
│   ├── CustomerApi
│   ├── CustomerPrincipal
│   ├── CustomerIdentity
│   ├── AuthChannel
│   ├── AuthChallengeIssued
│   ├── CustomerLoginResult
│   ├── CustomerAuthChallenges
│   ├── CustomerDevice
│   ├── CustomerSession
│   ├── CustomerSessions
│   ├── CustomerAccessToken
│   ├── VerifiedCustomerAccess
│   ├── CustomerAccessTokens
│   ├── CustomerProfiles
│   ├── CustomerAddresses
│   ├── CustomerTelegramContacts
│   ├── CustomerMaxContacts
│   ├── CustomerChannelUnavailableException
│   └── CustomerCodeDeliveryFailedException
├── migration
│   ├── CustomerLegacyImport
│   └── CustomerLegacyImportConfiguration (публичная точка @Import для cli-модуля)
├── domain                     сущности, инварианты, репозитории, доменные операции
│   ├── CustomerEntity, CustomerRepository
│   ├── CustomerAccountService (findOrCreateByPhone — общая для сценариев операция)
│   ├── PhoneNumbers (normalizePhone / requireValidPhone)
│   ├── AuthChallengeEntity, AuthChallengeRepository, BlockedPhoneEntity, BlockedPhoneRepository
│   ├── CustomerSessionEntity, CustomerSessionCredentialEntity и их репозитории
│   ├── CustomerAddressEntity, CustomerAddressRepository
│   └── TelegramContact*/MaxContact* — entity, репозитории, *ContactLookup
├── application                сценарии — реализации интерфейсов api
│   ├── CustomerIdentities (toIdentity — маппинг покупателя в CustomerIdentity)
│   ├── auth/CustomerAuthChallengeService
│   ├── session/CustomerSessionService, CustomerAccessTokenService
│   ├── profile/CustomerProfileService
│   ├── address/CustomerAddressService
│   └── contact/CustomerTelegramContactService, CustomerMaxContactService
└── infrastructure             криптография, доставка кода, настройки
    ├── CustomerCodeHasher, ChallengeCodeMatcher (+ тестовый режим)
    ├── CustomerCodeSender (ProviderCustomerCodeSender → notifications::api, NoOp)
    ├── CustomerAccessTokenCodec (формат, подпись и проверка access JWT; CustomerAccessClaims)
    ├── CustomerRefreshTokenCodec
    └── CustomerAuthChallengeProperties, CustomerJwtProperties

Правила слоёв проверяет CustomerArchitectureTests:

  • domain не зависит от application, infrastructure и migration (от типов api — может);
  • infrastructure не зависит от application и migration;
  • сценарии в application/<функция> не зависят друг от друга: общая логика опускается в domain, общий маппинг лежит в корне application;
  • migration зависит только от domain.

Access JWT разделён по слоям: CustomerAccessTokenCodec отвечает за формат токена (подпись, заголовок, iss/aud/typ, временные claims), а CustomerAccessTokenService — за то, действует ли доступ: срок с учётом сессии, отзыв и срок сессии, статус покупателя и совпадение ver.

Модель данных (customer.*)

customer.customer
├── id: UUID
├── phone: varchar(20), unique, формат +7XXXXXXXXXX
├── name: varchar(200), по умолчанию ''
├── birth_date: date, опциональна, не раньше 1900-01-01; задаётся один раз
├── status: ACTIVE | BLOCKED
├── session_version: integer, по умолчанию 1
├── created_at
└── version

customer.auth_challenge
├── id: UUID
├── phone: varchar(20)
├── purpose: varchar(20), сейчас только 'LOGIN'
├── channel: SMS | TELEGRAM | MAX
├── code_hash: varchar(64), HMAC-SHA256 с серверным pepper — код не хранится открытым текстом
├── attempts / max_attempts
├── expires_at, consumed_at, requested_ip, created_at
└── unique index (phone, purpose) where consumed_at is null

customer.customer_session
├── id: UUID
├── customer_id: UUID, FK → customer.customer
├── device_id: UUID, device_type, device_name, app_version
├── auth_method: OTP | TELEGRAM
├── expires_at (скользящий срок, продлевается при ротации), created_at, last_used_at, revoked_at
├── version
├── index (customer_id) where revoked_at is null
└── unique index (customer_id, device_id) where revoked_at is null

customer.customer_session_credential
├── id: UUID
├── session_id: UUID, FK → customer.customer_session
├── generation, token_hash (SHA-256), created_at, rotated_at, successor_id
└── version

customer.customer_address
├── id: UUID
├── customer_id: UUID, FK → customer.customer
├── city, street, house: varchar, обязательны — только для отображения, без FK на справочник
├── housing, floor, apartment, entrance, doorphone: varchar, опциональны
├── comment: text, опционален
├── latitude, longitude: numeric(9,6), обязательны
├── created_at
└── version

customer.blocked_phone
├── phone: varchar(20), primary key, формат +7XXXXXXXXXX
├── reason: text, обязателен
├── blocked_at
└── version

customer.telegram_contact
├── phone: varchar(20), primary key, формат +7XXXXXXXXXX
├── chat_id: bigint
├── username: varchar(64), опционален
├── linked_at
└── version

customer.max_contact
├── phone: varchar(20), primary key, формат +7XXXXXXXXXX
├── chat_id: bigint
├── username: varchar(64), опционален
├── linked_at
└── version

Сценарии, схема и claim'ы согласованы в технической постановке входа покупателей; этот документ фиксирует, что из неё реализовано.

Вход и регистрация по коду

Публичный контракт CustomerAuthChallenges:

request(phone, channel, ip) -> AuthChallengeIssued
verify(phone, code, ip) -> CustomerLoginResult

request нормализует телефон до +7XXXXXXXXXX, отклоняет заблокированный телефон и проверяет единственный незакрытый challenge на телефон: повторный запрос в течение resendAfter (2 минуты) отклоняется с оставшимся временем ожидания, а после его истечения прежняя строка гасится (consumed_at), а не остаётся второй активной записью — иначе она нарушила бы уникальный индекс (phone, purpose) where consumed_at is null. Код — шесть цифр, генерируется SecureRandom, хранится только в виде HMAC-SHA256 с серверным pepper (CustomerCodeHasher). Открытый код передаётся только в CustomerCodeSender.send, ничего не логирует по умолчанию и никуда не сохраняется.

verify находит активный challenge под блокировкой строки (SELECT ... FOR UPDATE), отклоняет истёкший или исчерпанный по попыткам код одной и той же ошибкой CustomerAuthenticationFailedException — ответ не показывает, что именно не так и существует ли телефон. Неверный код увеличивает счётчик попыток; после maxAttempts (5) challenge сгорает досрочно. Верный код гасит challenge и вызывает CustomerAccountService.findOrCreateByPhone: вход и регистрация — один сценарий, newAccount в результате отличает только что созданного покупателя.

Доставка кода

ProviderCustomerCodeSender (единственная реализация CustomerCodeSender) резолвит канал в получателя и делегирует отправку notifications::api:

Канал Получатель Провайдер Если недоступен
SMS телефон (сам по себе адрес) notifications.api.SmsSender —
TELEGRAM chat_id из customer.telegram_contact по телефону notifications.api.TelegramSender CustomerChannelUnavailableException — контакт не привязан
MAX chat_id из customer.max_contact по телефону notifications.api.MaxSender CustomerChannelUnavailableException — контакт не привязан

Любая ошибка провайдера (сетевая, код ошибки от smsc.ru, ok: false от Telegram, HTTP-статус с телом от MAX) оборачивается в CustomerCodeDeliveryFailedException. Оба исключения выбрасываются из send() внутри @Transactional request(): challenge не сохраняется, клиент получает ошибку и может повторить запрос (см. модуль notifications).

Тестовый режим

Флаг окружения panam.test-mode.enabled (env PANAM_TEST_MODE_ENABLED, по умолчанию false) — не специфичен для customer, тем же именем в будущем сможет пользоваться и другой модуль (например, подавление выгрузки заказов в iiko), но переключается независимо в каждом месте через @ConditionalOnProperty на бинах, а не через общий инжектируемый бин — так verify() и request() остаются без ветвления по флагу внутри метода. При true:

  • ProviderCustomerCodeSender заменяется на NoOpCustomerCodeSender: код не уходит ни в SMS, ни в Telegram, ни в MAX, только пишется в лог — реальный SMS-баланс и Telegram/MAX API не расходуются на test-сервере;
  • вместо бина HashChallengeCodeMatcher (реальная проверка кода по хешу) поднимается TestModeChallengeCodeMatcher: он сам создаёт HashChallengeCodeMatcher поверх CustomerCodeHasher (чтобы в контексте был ровно один ChallengeCodeMatcher) и дополнительно принимает фиксированный код 1111 — CustomerAuthChallengeService.verify вызывает единый ChallengeCodeMatcher.matches(...) и не знает о тестовом режиме вообще. Сам код по-прежнему генерируется и хешируется как обычно, 1111 — запасной вариант поверх него. Остальные проверки (TTL, maxAttempts, блокировка телефона) не меняются.

Ставится только в .env test-сервера (deploy/monolith/.env.example); на prod всегда false.

Блокировка по номеру

requireNotBlocked проверяет два независимых источника блокировки — оба дают одну и ту же CustomerPhoneBlockedException, не раскрывая, какой именно сработал:

  1. customer.blocked_phone — номер в чёрном списке, привязки к аккаунту не требует: блокирует request/verify даже для телефона, у которого ещё нет customer.customer (перенесено из legacy black_lists, см. «Перенос legacy-данных» ниже).
  2. customer.customer.status = BLOCKED — блокировка уже существующего покупателя через будущий административный сценарий, которого пока нет.

Разблокировка (DELETE из blocked_phone или смена status) сейчас возможна только вручную — административного endpoint нет ни для одного из двух механизмов.

Refresh-сессии

Публичный контракт CustomerSessions — create/rotate/revoke/revokeAll, по аналогии с AdminSessions/FranchiseSessions модуля iam, но с явным устройством и скользящим сроком вместо фиксированного 30-дневного окна.

create(customerId, device, method) привязывает сессию к device.id — постоянному UUID установки клиента, который генерирует и хранит сам клиент (не секрет и не доказательство подлинности устройства). Повторный вход с тем же device.id отзывает прежнюю сессию этого устройства перед вставкой новой — без этого нарушился бы уникальный индекс (customer_id, device_id) where revoked_at is null, а по продукту повторный вход не должен плодить дубли одного устройства в списке сессий.

rotate(refreshToken) — та же идемпотентная ротация с 30-секундным окном повтора и правилом reuse, что в iam (повтор старого token в течение окна возвращает уже выпущенного преемника; повтор позже считается reuse и отзывает сессию), и дополнительно продлевает expires_at ещё на 365 дней при каждой успешной ротации — регулярно используемое устройство не имеет предельной календарной даты входа, как того требует продукт.

revoke/revokeAll отзывают одну или все сессии покупателя. Публичного GET /sessions и отзыва одного устройства по sessionId из HTTP пока нет — это следующий этап вместе со списком устройств.

Refresh-токен — не JWT, а непрозрачный id.подпись (CustomerRefreshTokenCodec, HMAC-SHA256), секрет переиспользуется из CustomerJwtProperties — тот же приём, что уже применён для сотрудников в iam.RefreshTokenCodec. Это осознанное отличие от технической постановки, которая описывает refresh как отдельный JWT с typ=refresh: непрозрачный формат физически не совпадает с access-JWT, поэтому токен нельзя спутать местами (передать access туда, где ожидается refresh) даже при проверке одной подписи — ровно та проблема legacy-сервиса, которую и требовалось закрыть.

Access JWT

Публичный контракт CustomerAccessTokens выпускает token для существующей refresh-сессии и проверяет полученный. JWT подписывается HS256, JOSE typ=at+jwt, действует 15 минут, но не дольше самой refresh-сессии.

Обязательные claims:

sub    ID покупателя
sid    ID refresh-сессии
jti    уникальный ID access token
iss    настраиваемый issuer, по умолчанию panam
aud    panam-storefront
typ    access
ver    customer.session_version на момент выпуска
iat, nbf, exp

Проверка требует точного совпадения алгоритма, типа, issuer, audience и временных claims, затем IAM-аналогично iam — перезагружает сессию и покупателя по sid, проверяет отзыв/срок сессии, статус ACTIVE покупателя и совпадение ver с текущим session_version. Отзыв refresh-сессии немедленно делает связанный access token недействительным; будущий сценарий «выйти на всех устройствах» инкрементирует session_version (CustomerEntity.invalidateIssuedSessions) — это уже проверяется тестами, хотя сам публичный сценарий отзыва всех сессий с инкрементом версии пока не собран в HTTP.

permissions/membership в claims нет: у покупателя нет ролей.

Настройки — префикс panam.customer.jwt:

PANAM_CUSTOMER_JWT_ISSUER        по умолчанию panam
PANAM_CUSTOMER_JWT_AUDIENCE      по умолчанию panam-storefront
PANAM_CUSTOMER_JWT_ACCESS_TTL    по умолчанию 15m
PANAM_CUSTOMER_JWT_SECRET
PANAM_CUSTOMER_JWT_SECRET_FILE

Должен быть указан ровно один источник секрета, не короче 32 UTF-8 байт; проверяется при старте контекста (CustomerAccessTokenCodec), как и в iam.

Отдельный pepper для хеширования кода — префикс panam.customer.auth-challenge:

PANAM_CUSTOMER_AUTH_CHALLENGE_PEPPER_SECRET
PANAM_CUSTOMER_AUTH_CHALLENGE_PEPPER_SECRET_FILE
PANAM_CUSTOMER_AUTH_CHALLENGE_CODE_TTL          по умолчанию 10m
PANAM_CUSTOMER_AUTH_CHALLENGE_RESEND_AFTER      по умолчанию 2m
PANAM_CUSTOMER_AUTH_CHALLENGE_MAX_ATTEMPTS      по умолчанию 5

Профиль

Публичный контракт CustomerProfiles.update(customerId, CustomerProfileUpdate(name?, birthDate?)) — частичное обновление профиля, перенос legacy ProfileController::update (POST /client/update). null в поле означает «не менять». Все поля проверяются до изменения сущности, поэтому невалидное поле не оставляет частично применённого обновления. Любое нарушение бросает CustomerProfileRejectedException, неизвестный customerId — тоже (маловероятная гонка между проверкой access token и обновлением профиля в рамках одного запроса).

  • name обрезается по пробелам и должен содержать от 2 до 32 символов (те же границы, что использовала регистрация в legacy Go-сервисе). Подсказки вариантов имени при вводе даёт не customer, а модуль dadata; сохраняемое имя с ними не сверяется.
  • birthDate — от 1900-01-01 до сегодняшнего дня (UTC). Как и в legacy, задаётся один раз (CustomerEntity.assignBirthDate): попытка сменить уже заданную дату отклоняется. Повтор той же даты принимается, чтобы клиент мог отправлять профиль целиком. В legacy другая дата молча игнорировалась, здесь клиент получает явную ошибку. Исправить ошибочную дату можно будет только через будущий административный сценарий.

Из legacy update осознанно не перенесены:

  • allow_ads — согласие на рекламные рассылки. Нужны ли отдельные согласия и кто обрабатывает отказ, пока открытый вопрос (верхнеуровневые решения), а старый фронтенд это поле не отправлял: менялось оно только из админки.
  • dish_customizations — не профиль, а непрозрачный JSON-снимок состояния старого фронтенда (активные адрес, город и пиццерия, тип доставки и оплаты, приборы, сдача и т.п.). Синхронизацию такого состояния между устройствами, если она понадобится, нужно проектировать отдельно, а не хранить чужой JSON в профиле. Чтение профиля отдельного публичного метода не имеет: storefront строит ответ прямо из CustomerIdentity, которую уже возвращает CustomerAccessTokens.verify — лишний поход в БД не нужен.

Сохранённые адреса

Публичный контракт CustomerAddresses — list/add/updateComment/remove. Адрес неизменяем: изменённый адрес — это по сути другой адрес, поэтому клиент удаляет старый и сохраняет новый, а не правит существующий. Меняться может только comment (updateComment; null или пустая строка удаляют комментарий); остальные колонки сущности помечены updatable = false. updateComment/remove ищут адрес парой (id, customerId): адрес чужого покупателя или несуществующий id дают одну и ту же CustomerAddressNotFoundException, не различая эти случаи. city/street/house/housing/ floor/apartment/entrance/doorphone/comment — свободный текст (обрезается по пробелам, пустая опциональная строка становится null); latitude/longitude обязательны и проверяются на допустимый диапазон (CustomerAddressRejectedException иначе). Координаты сервер не вычисляет — их присылает клиент (обычно с виджета карты), как и в legacy.

Здесь намеренно нет сущности «Город» и нет привязки адреса к ресторану/iiko при сохранении — это осознанное отличие от старого Laravel-бэкенда (site/backend, не Go-сервис user, где адресов не было вовсе). В legacy client_addresses в 2020 году ссылался на city_id/street_id через FK, но в 2021-м эту связь явно убрали миграцией refactoring_client_addresses в пользу свободного текста — сама команда отказалась от нормализации города на адресе. Отдельная проблема legacy: CreateAddressHandler резолвил street_crm_id/iiko_street_name под конкретную пиццерию, переданную при создании адреса, — при закрытии ресторана и открытии нового под другим ФЧ этот резолв протухал молча.

Целевая модель: city/street остаются свободным текстом только для отображения в адресной книге; сопоставление адреса с рестораном/зоной доставки, когда появится order, будет геометрическим — точка (latitude/longitude) внутри полигона зоны доставки, а не сравнение названий городов/улиц. Поэтому координаты обязательны уже сейчас, а не добавляются позже. Restaurant-специфичные поля (аналог street_crm_id/iiko_street_name) на сохранённый адрес никогда не пишутся — такой резолв будет выполняться заново на этапе заказа под конкретный ресторан, а не кэшироваться на профиле покупателя. Справочник городов (аналог dictionary_cities — с таймзоной, полигоном зоны доставки, баннерами) — если понадобится, это будущая задача модуля restaurant/catalog, а не customer: в legacy он был привязан к пиццерии, а не к адресу клиента.

Что осознанно не перенесено из legacy-сервиса user

Полный список — в разделе 12.2 технической постановки. В этом модуле уже закрыто:

  • access JWT действует 15 минут вместо exp в 2100 году;
  • refresh — отдельный непрозрачный токен с серверной ротацией, а не вторая JWT с тем же форматом, что и access;
  • код подтверждения хранится только как HMAC-хеш, а не открытым текстом;
  • нет query-параметра ?token= и нет неявного фолбэка «любой невалидный JWT — это legacy-токен»;
  • одна учётная запись customer.customer вместо дублирующих таблиц блокировки в двух системах.

Ещё не перенесено и не реализовано: список устройств с отзывом одного, logout-all, вход через Telegram WebApp, impersonation, миграция legacy-токенов (/auth/legacy-exchange) и связанное с ней временное хранилище обмена. Перенос телефона/имени/даты рождения/адресов/блокировок — реализован частично, см. «Перенос legacy-данных» ниже; бонусы и накопления (§6.5 06-decisions.md) не переносятся вовсе — источник ещё не определён.

Перенос legacy-данных

Публичный контракт CustomerLegacyImport (named interface customer::migration) — не для обычных сценариев приложения, только для одноразового CLI-инструмента (./gradlew importLegacyCustomers, модуль cli, см. LegacyImportCommand). Источник — старый Laravel-бэкенд (site/backend, таблицы clients/client_addresses/black_lists в MySQL), а не Go-сервис user, в котором адресов не было вовсе.

Перенос идемпотентен, безопасно перезапускать:

  • upsertCustomer — dedup по телефону (уже unique). При создании id покупателя — это legacy UUID напрямую, не новый случайный, для сквозной прослеживаемости при отладке. Пустой/невалидный телефон — пропуск, имя существующего покупателя не перезаписывается, если уже задано. birth_date переносится по тем же правилам, что и в профиле: только в пустое поле и только в диапазоне от 1900-01-01 до сегодняшнего дня; дата вне диапазона (мусор в legacy) пропускается, а не прерывает импорт.
  • upsertAddress — dedup по совпадению текстовых полей адреса у одного покупателя (тот же приём, что использовал сам legacy в Client::findAddress()). Адреса без координат (в legacy они появились только в 2022 году) пропускаются — координаты в нашей схеме обязательны.
  • upsertBlockedPhone — dedup по телефону (primary key). Пустая причина в legacy (comment nullable) заменяется на текст-заглушку, а не оставляется пустой — reason обязателен.

Явно не переносится: crm_id, tg_id/tg_username, max_chat_id/max_name, dish_customizations, allow_ads/allow_notification — источник (MySQL) будет доступен ещё долго. dish_customizations и allow_ads/allow_notification привязаны к сценариям, которые ещё не спроектированы (см. «Профиль» выше); переносить их сейчас означало бы расширять схему customer.customer под функциональность, которой нет. tg_id/tg_username и max_chat_id/max_name не переносятся по другой причине — целевая связь телефона с чатом уже реализована (customer.telegram_contact, customer.max_contact), но заново, через привязку в боте (см. «Доставка кода» выше), а не переносом legacy-идентификатора: непроверенный перенос выдавал бы код по чужому chat_id, если legacy-запись устарела.

dryRun=true (по умолчанию) не пишет в БД, но выполняет все проверки — статистика в логе честная (created/updated/skipped по каждой из трёх таблиц), а не просто «сколько строк прочитано».

Проверка модульности

Модуль входит в общий ModularityTests (ApplicationModules.verify()), который проверяет отсутствие запрещённых зависимостей и циклов.

Следующий этап

Доставка кода по всем трём каналам (SMS, Telegram, MAX) реализована. Остаются: настройки уведомлений и согласие на рекламу (после решения открытого вопроса о согласиях), список сессий с отзывом одного устройства и logout-all, блокировка уже существующего покупателя через административный сценарий, вход через Telegram WebApp, перенос legacy-токенов.