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

09 nfc payments

NFC-платежи в OneWallet: управление метками, лимиты, безопасность.

Что такое NFC-метка

NFC-метка — пластиковая или виниловая карта (чип NTAG) с записанным секретным токеном. Пользователь привязывает её к своему кошельку и предъявляет мерчанту вместо PIN; мерчант считывает токен своим устройством и инициирует списание средств. Метка действует 90 дней, после чего требует перевыпуска через rotateTag.


Жизненный цикл метки

sequenceDiagram
    participant App as Flutter App (consumer)
    participant AC as Auth Center
    participant Chip as NFC Chip

    App->>AC: registerTag(label?)
    AC->>AC: assertNfcEnabled(userId)
    AC->>AC: count active < 3 → генерировать token (22 chars base64url)
    AC-->>App: NfcTagRegistration { tagId, token, expiresAt }

    App->>Chip: NDEF write(token)
    App->>AC: confirmTagWritten(tagId, chipUid)
    AC-->>App: OK

    Note over App,AC: метка активна — ready for payments

    App->>AC: setTagLimits(tagId, perTapLimit?, dailyLimit?)
    AC-->>App: NfcTagDto (обновлённые лимиты)

    alt ротация по истечении / по желанию
        App->>AC: rotateTag(tagId)
        AC->>AC: новый token, lastCounter=0, новый expiresAt
        AC-->>App: NfcTagRegistration { token }
        App->>Chip: NDEF write(новый token)
    else отзыв
        App->>AC: revokeTag(tagId)
        AC-->>App: OK (status=revoked)
    end

Примечания:

  • token возвращается клиенту единственный раз — на сервере хранится только его SHA-256 хеш (tokenHash).
  • confirmTagWritten фиксирует физический chipUid чипа, чтобы поддержка могла найти карту по UID.
  • rotateTag сохраняет per-tag лимиты — perTapLimit и dailyLimit не сбрасываются.
  • listTags и revokeTag доступны даже если NFC глобально отключён; остальные мутации требуют assertNfcEnabled.

Лимиты и ограничения

Параметр Значение Пояснение
perTapLimit задаётся пользователем (satang) Максимальная сумма за одно касание; null = без ограничения
dailyLimit задаётся пользователем (satang) Максимальный суточный расход по метке; null = без ограничения
TTL 90 дней от выпуска После expiresAt метка отклоняется с NFC_TAG_EXPIRED; требует rotateTag
Максимум активных меток 3 На одного пользователя; при превышении — NFC_TAG_QUOTA

Дневной расход рассчитывается по v_tx_history (SUM(amount) за текущие сутки UTC для direction=DEBIT). Счётчика расхода на самой метке нет — единственный источник истины — история транзакций.


Clone Detection

При каждом касании чип NTAG аппаратно инкрементирует счётчик (READ_CNT). Сервер атомарно продвигает lastCounter с условием lastCounter < counter: если счётчик не вырос, а idempotencyKey не совпадает с предыдущим — метка немедленно отзывается (status=revoked) и владельцу отправляется FCM push-уведомление по каналу security с текстом из NotificationTemplates.nfcCloneDetected. Легитимный сетевой ретрай (тот же counter + тот же idempotencyKey) проходит идемпотентно без повторного списания.


Сброс чипа

Если пользователь хочет переписать тот же физический чип (новый токен на тот же NTAG), выполняются два шага:

  1. prepareTagClear(tagId) — возвращает NfcTagClearCredentials (данные для стирания чипа).
  2. Приложение стирает страницы NTAG; после успеха вызывает confirmTagCleared(tagId).

После confirmTagCleared метка переходит в состояние, готовое к повторной записи (rotateTag), без создания новой записи в реестре.


Ротация метки

Ротация происходит когда: - TTL истёк (90 дней) — метка автоматически отклоняется при касании - Превентивно — пользователь инициирует rotateTag(tagId) до истечения срока

Процесс ротации создаёт новый токен и новый пароль для записи на чип, оставляя физический UID неизменным. Старый токен деактивируется; все per-tag лимиты (perTapLimit, dailyLimit) сохраняются.

sequenceDiagram
    participant User as Пользователь
    participant App as Flutter App
    participant AC as Auth Center
    participant Chip as NFC Chip

    User->>App: инициирует ротацию метки
    App->>AC: rotateTag(tagId)
    AC->>AC: создать новый token + writePassword
    AC->>AC: deactivate старый token
    AC->>AC: обновить expiresAt на 90 дней
    AC-->>App: NfcTagRegistration { newToken, writePassword }

    App->>Chip: NDEF write(newToken с новым паролем)
    App->>AC: confirmTagWritten(tagId)
    AC-->>App: OK (status='active')

    Note over App,AC: новая метка активна, счётчик обнулён

После ротации счётчик lastCounter сбрасывается на 0; это позволяет использовать ту же физическую карту без повторной привязки.


Что происходит при Clone Detection

Если обнаружен клон метки (регресс счётчика или новый idempotencyKey с тем же значением счётчика):

  1. Метка блокируетсяstatus='revoked' немедленно
  2. Пользователю отправляется push-уведомление по каналу security с текстом из NotificationTemplates.nfcCloneDetected
  3. Дальнейшие касания отклоняются с ошибкой NFC_TAG_REVOKED
  4. Пользователь может выполнить сброс и ротацию — либо prepareTagClear() + confirmTagCleared() для переписи того же чипа, либо полностью revokeTag() и registerTag() новой метки

Все события клонирования логируются в nfc_tag_events с eventType='clone_detected'.


Управление метками

Полный набор действий с NFC-метками:

Действие Метод Когда Требует NFC enabled
Регистрация registerTag(label?) Пользователь добавляет новую метку Да
Подтверждение записи confirmTagWritten(tagId, chipUid) После физической записи токена на чип Нет (завершение регистрации)
Установить лимиты setTagLimits(tagId, perTapLimit?, dailyLimit?) Пользователь настраивает лимиты за касание и за сутки Да
Ротация rotateTag(tagId) TTL истёк (90 дн) или пользователь инициирует превентивно Да
Отзыв revokeTag(tagId) Утеря метки, блокировка при clone detection Нет (может быть выполнен без NFC)
Список меток listTags() Просмотр в приложении ("Мои метки") Нет (читать можно всегда)
Подготовка сброса prepareTagClear(tagId) Перед стиранием физического чипа Да
Подтверждение сброса confirmTagCleared(tagId) После успешного стирания чипа NTAG Нет (завершение сброса)

Таблица лимитов и технические ограничения описаны в разделе Лимиты и ограничения.