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), выполняются два шага:
prepareTagClear(tagId)— возвращаетNfcTagClearCredentials(данные для стирания чипа).- Приложение стирает страницы 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 с тем же значением счётчика):
- Метка блокируется —
status='revoked'немедленно - Пользователю отправляется push-уведомление по каналу
securityс текстом изNotificationTemplates.nfcCloneDetected - Дальнейшие касания отклоняются с ошибкой
NFC_TAG_REVOKED - Пользователь может выполнить сброс и ротацию — либо
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 | Нет (завершение сброса) |
Таблица лимитов и технические ограничения описаны в разделе Лимиты и ограничения.