Перейти до вмісту

Інцидент 2026-07-16 — оплачені токени AI-тьютора не нараховувались (дрейф whitelist)

Покупка токенів AI-тьютора — два сервіси (Abitly API + tutor-сервіс на EC2; фіча — plans-billing):

  1. POST /tutor/tokens/purchase (api-v2) створює Monobank-інвойс і рядок у nmt_tests.tutor_token_orders (status=pending).
  2. Webhook Monobank → processWebhook() ставить status=success, paid_at, і синхронним HTTP-викликом кредитує гаманець: POST {tutor}/internal/wallet/credit з {userId, costUnits, orderId} (заголовок X-Service-Token).
  3. Tutor-сервіс пише аудит-рядок у nmt_tests.wallet_credits (order_id UNIQUE — захист від дабл-кредиту) і піднімає nmt_tests.user_token_wallet.paid_balance.
  4. Успіх фіксується в ордері як wallet_credited_at; safety-net WalletReconcilerCron (кожні 5 хв) ретраїть ордери success && wallet_credited_at IS NULL; застряглі >24 год — лише ERROR-лог і пропуск (wallet-reconciler.cron.ts:77-84).

Перед кредитом tutor тримає defense-in-depth: Layer 1 — whitelist сум VALID_CREDIT_AMOUNTS (app/constants.py:22), Layer 2/3 — денний cap і per-user ліміт (app/routers/internal.py:88-167).

ЧасПодіяСтан
19.05 14:06tutor 0e3d04d: security-hardening I5 — whitelist (500_000, 1_500_000, 5_000_000)🟢
19.05 14:07api-v2 0568b9b: канарка-дзеркало + live drift-тест проти /api/debug/valid-credit-amounts + реконсилер з >24h ескалацією🟢 захист збудовано
21.05 09:22api-v2 094d9c0 (великий hardening-коміт): live drift-тест видалено, без згадки в описі коміту🟡 захист ослаблено
22.05 12:53api-v2 80862da: пакети small_500/best_1500/power_5000small_250/best_1000/power_2000, costUnits → 250k/1M/2M. Tutor не оновлено🔴 бомба закладена
22.05 12:58api-v2 79b675c «fix: unit tests»: дзеркало EXPECTED_TUTOR_VALID_AMOUNTS оновлено на нові значення → канарка зелена🔴 канарка осліплена
01.06 15:23Перша постраждала покупка small_250 (49 ₴): оплата success, кредит → 400 invalid_amount🔴 тихий інцидент
03.06 17:48, 12.06 23:48Ще дві small_250 (по 49 ₴). Реконсилер щоп’ять хвилин → 400; після 24 год — лише ERROR-лог у stdout🔴
16.07 09:48, 10:33Ще дві покупки: best_1000 (149 ₴, Apple Pay) і small_250 (49 ₴, Google Pay)🔴
16.07 ~12:50Скарга користувача; власник відтворив баг на власній покупці; діагностика по коду обох репо🟡 detect
16.07 13:09tutor cdf904c: whitelist синхронізовано (250_000, 1_000_000, 2_000_000)🟡
16.07 13:14GitHub Actions «Deploy tutor to AWS» success (headSha=cdf904c), публічний smoke пройшов🟡 fix live
16.07 ~13:20Реконсилер автоматично докредитував обидва липневі ордери (підтверджено власником: токени на балансі)🟢
16.07 ~15:00SQL-бекфіл 3 червневих ордерів (>24h); контрольний скан → 0 рядків🟢 resolved

Вікно впливу: 01.06–16.07 — кожна покупка токенів (5 з 5 за період) оплачувалась без нарахування. MTTD ≈ 45 діб (виявлення — скарга користувача). Від репорту до фіксу на проді — ~25 хв; до повного закриття (бекфіл) — ~2 год.

Дві копії одного контракту в різних репо

Section titled “Дві копії одного контракту в різних репо”

Суми, які api-v2 надсилає в кредит, і суми, які tutor приймає, — це та сама константа, скопійована руками:

  • api-v2: src/api/tutor-billing/token-packages.config.tscostUnits: 250_000 / 1_000_000 / 2_000_000 (після 80862da);
  • tutor: app/constants.py:22VALID_CREDIT_AMOUNTS = (500_000, 1_500_000, 5_000_000) (не змінювався з 0e3d04d).

Layer-1-перевірка tutor-сервісу (app/routers/internal.py:88-102) відхиляє будь-яку суму поза whitelist:

if body.costUnits not in VALID_CREDIT_AMOUNTS:
...
raise HTTPException(status_code=400, detail="Invalid credit amount")

Після 80862da жодна легітимна сума не входила у whitelist → 100 % відмов.

Чому ордер при цьому success, а користувач отримує квитанцію

Section titled “Чому ордер при цьому success, а користувач отримує квитанцію”

processWebhook() спершу оновлює ордер (status=success, paid_at), і лише потім викликає кредитування; email-квитанція надсилається за фактом оплати, незалежно від кредиту. 400 від tutor обробляється як «DRIFT»: помилка логується, lease знімається, wallet_credited_at лишається NULL (tutor-billing.service.ts:338-409). Для користувача все виглядає як успішна покупка.

Чому ретраї не рятували, а маскували

Section titled “Чому ретраї не рятували, а маскували”

Реконсилер справно ретраїв кожні 5 хв (слід в даних: у обох липневих ордерів wallet_credit_attempted_at = 09:50:00Z — момент чергового циклу), але отримував той самий 400. Після 24 год ордер переходив у гілку «Manual intervention required (likely TOKEN_PACKAGES drift)» — ERROR-лог у stdout контейнера, який ніхто не читає. Діагноз системи був правильний і щоденний — але не мав маршруту до людини.

Доказ масштабу (прод-БД, 16.07)

Section titled “Доказ масштабу (прод-БД, 16.07)”
SELECT id, package_slug, cost_units, paid_at
FROM nmt_tests.tutor_token_orders
WHERE status = 'success' AND wallet_credited_at IS NULL;
-- 01.06 small_250 · 03.06 small_250 · 12.06 small_250 (+ два ордери 16.07,
-- які на момент запиту вже докредитував реконсилер після фіксу)

Чому це не зловили раніше

Section titled “Чому це не зловили раніше”
ГарантіяЧи булаЧому не спрацювала
Live drift-тест проти GET /api/debug/valid-credit-amounts (ендпоінт створено саме для цього, app/routers/debug.py:47-57)✅ була (0568b9b)Видалено через 2 дні у великому hardening-коміті 094d9c0 без згадки в описі; навіть коли існував — it.skip без TUTOR_BASE_URL/секрета в CI
Локальна канарка EXPECTED_TUTOR_VALID_AMOUNTS (tutor-billing.service.spec.ts:411-418)✅ булаДзеркало живе в тому ж репо: 79b675c оновив його разом із пакетами — тест став перевіряти api-v2 сам проти себе
Коментар «you MUST also update that file AND re-deploy the tutor» біля канарки✅ бувКоментар — не контроль: пакети змінили, tutor не редеплоїли
Реконсилер: ERROR «stuck > 24h … likely TOKEN_PACKAGES drift» щоцикла✅ була і працювалаЛог у stdout без алерту; 45 днів правильний діагноз писався в нікуди
security_events (wallet.credit.invalid_amount) на боці tutor✅ пишеТаблиця нікому не моніториться
Сигнал від користувачаКвитанція приходила, баланс «мовчки» не зʼявлявся; перша скарга — лише 16.07

Виправлення (що реально спрацювало)

Section titled “Виправлення (що реально спрацювало)”

1. Синк whitelistabitly-AI-tutor@cdf904c → push у main → «Deploy tutor to AWS» (run 29489853592, headSha збігається, success 13:14):

VALID_CREDIT_AMOUNTS: tuple[int, ...] = (250_000, 1_000_000, 2_000_000)

2. Свіжі ордери (<24h) — нічого: реконсилер докредитував обидва в найближчому циклі (власник підтвердив токени на балансі).

3. Ордери >24h (реконсилер їх пропускає назавжди) — ідемпотентний SQL-бекфіл одним атомарним стейтментом, який відтворює credit_wallet() (token_usage_service.py:211-252): insert в wallet_credits ON CONFLICT (order_id) DO NOTHING → upsert user_token_wallet.paid_balanceUPDATE … SET wallet_credited_at. Повторний запуск безпечний (повертає нулі).

Фінальна перевірка:

ПеревіркаРезультат
Deploy run headSha = cdf904c, conclusion✅ success, 13:14
Токени по 2 липневих ордерах (баланс у UI)✅ зайшли (реконсилер)
wallet_credits по 3 червневих order_id✅ 3 рядки (бекфіл)
Скан status='success' AND wallet_credited_at IS NULL0 рядків
  • 16.07: whitelist синхронізовано + в обох файлах коментарі-попередження про «трійку» (token-packages.config.tsEXPECTED_TUTOR_VALID_AMOUNTSconstants.py) — abitly-AI-tutor@cdf904c, api-v2 PR #596.
  • Змержити PR #596 (dev).
  • Повернути live drift-тест (готовий код — git show 0568b9b в api-v2) і дати йому середовище виконання: CI-джоба або cron із TUTOR_BASE_URL + секретом, що бʼє /api/debug/valid-credit-amounts і порівнює з TOKEN_PACKAGES; фейл → Telegram.
  • Алерт «оплачено, не зараховано»: периодичний запит status='success' AND wallet_credited_at IS NULL AND paid_at < now()-'15 min' → Telegram. Це головна метрика будь-якого білінгу; закрила б дірку за годину замість 45 днів.
  • Маршрутизувати ERROR-логи реконсилера (Manual intervention required) і security_events.wallet.credit.invalid_amount до людини (Telegram-алерти).
  • Написати вибачення 4 постраждалим користувачам (3 червневі + липневий покупець best_1000) — токени зʼявились у них «мовчки».
  • Розглянути: лист «токени зараховано» слати після wallet_credited_at, а не за фактом оплати (квитанція маскувала збій).
  1. Коли саме 80862da поїхав на прод api-v2 — між 22.05 і 01.06 покупок не було, чи деплой стався пізніше? (На масштаб не впливає: перший уражений платіж — 01.06.)
  2. Поведінка фронтенду при вічно порожньому walletCreditedAt (полінг GET /tutor/tokens/order-status) — що бачив користувач і чому не було таймаута/ескалації в UI.
  3. Чи варто чистити/акумулювати накопичені за 45 днів записи security_events типу wallet.credit.invalid_amount.

Ключові ідентифікатори

Section titled “Ключові ідентифікатори”
ПолеЗначення
Комітиapi-v2: 80862da (зміна пакетів), 79b675c (осліплення канарки), 094d9c0 (видалення live-тесту), 0568b9b (створення захисту) · tutor: 0e3d04d (whitelist), cdf904c (фікс)
PRapi-v2 #596 — чесна канарка
DeployGH Actions «Deploy tutor to AWS» run 29489853592 (success, headSha=cdf904c); legacy «Deploy backend to DigitalOcean» падає — сервіс давно на EC2
Постраждалі ордери2dbef61a-… (01.06), 3ed816b3-… (03.06), 35f457b1-… (12.06) — бекфіл · bc62b569-…, 38859793-… (16.07) — реконсилер
Таблиціnmt_tests.tutor_token_orders, nmt_tests.wallet_credits (order_id UNIQUE), nmt_tests.user_token_wallet, nmt_tests.security_events
ЕндпоінтиPOST /internal/wallet/credit, GET /api/debug/valid-credit-amounts (service-auth)
CronWalletReconcilerCron — EVERY_5_MINUTES, ліз 5 хв, ескалація >24h
  1. Ручна копія крос-сервісної константи — дрейф «коли», а не «якщо». Контракт-тест, що порівнює репо сам із собою (локальне дзеркало), не перевіряє нічого: при зміні значень дзеркало оновлюють тим самим рухом. Перевірка мусить читати справжнє джерело (live endpoint, спільний пакет, contract-тест).
  2. status=success ≠ виконане зобовʼязання. «Гроші взяли, товар не видали» — окремий термінальний-але-незавершений стан, і саме він має бути першим алертом у будь-якому білінгу.
  3. ERROR-лог без маршруту до людини — не детекція. Система 45 днів щодня писала правильний діагноз («likely TOKEN_PACKAGES drift») у stdout, який ніхто не читає.
  4. Видалення guard-тесту всередині великого коміту непомітне на ревʼю. Демонтаж захисту заслуговує окремого коміту з обґрунтуванням — інакше він зникає «під шум» hardening-у.
  5. Ідемпотентність на природному ключі (order_id UNIQUE) робить відновлення тривіальним. І нескінченні ретраї реконсилера, і ручний SQL-бекфіл були безпечні без жодного ризику дабл-кредиту — патерн, вартий повторення в кожному money-flow.
  6. Квитанція до виконання зобовʼязання маскує збій. Користувач отримує «все ок» від платіжки й сервісу — і не має причин скаржитись, поки не зайде витратити токени.

Пов’язана документація

Section titled “Пов’язана документація”