Інцидент 2026-07-16 — оплачені токени AI-тьютора не нараховувались (дрейф whitelist)
Контекст
Section titled “Контекст”Покупка токенів AI-тьютора — два сервіси (Abitly API + tutor-сервіс на EC2; фіча — plans-billing):
POST /tutor/tokens/purchase(api-v2) створює Monobank-інвойс і рядок уnmt_tests.tutor_token_orders(status=pending).- Webhook Monobank →
processWebhook()ставитьstatus=success,paid_at, і синхронним HTTP-викликом кредитує гаманець:POST {tutor}/internal/wallet/creditз{userId, costUnits, orderId}(заголовокX-Service-Token). - Tutor-сервіс пише аудит-рядок у
nmt_tests.wallet_credits(order_id UNIQUE— захист від дабл-кредиту) і піднімаєnmt_tests.user_token_wallet.paid_balance. - Успіх фіксується в ордері як
wallet_credited_at; safety-netWalletReconcilerCron(кожні 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).
Хронологія (UTC+3)
Section titled “Хронологія (UTC+3)”| Час | Подія | Стан |
|---|---|---|
| 19.05 14:06 | tutor 0e3d04d: security-hardening I5 — whitelist (500_000, 1_500_000, 5_000_000) | 🟢 |
| 19.05 14:07 | api-v2 0568b9b: канарка-дзеркало + live drift-тест проти /api/debug/valid-credit-amounts + реконсилер з >24h ескалацією | 🟢 захист збудовано |
| 21.05 09:22 | api-v2 094d9c0 (великий hardening-коміт): live drift-тест видалено, без згадки в описі коміту | 🟡 захист ослаблено |
| 22.05 12:53 | api-v2 80862da: пакети small_500/best_1500/power_5000 → small_250/best_1000/power_2000, costUnits → 250k/1M/2M. Tutor не оновлено | 🔴 бомба закладена |
| 22.05 12:58 | api-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:09 | tutor cdf904c: whitelist синхронізовано (250_000, 1_000_000, 2_000_000) | 🟡 |
| 16.07 13:14 | GitHub Actions «Deploy tutor to AWS» success (headSha=cdf904c), публічний smoke пройшов | 🟡 fix live |
| 16.07 ~13:20 | Реконсилер автоматично докредитував обидва липневі ордери (підтверджено власником: токени на балансі) | 🟢 |
| 16.07 ~15:00 | SQL-бекфіл 3 червневих ордерів (>24h); контрольний скан → 0 рядків | 🟢 resolved |
Вікно впливу: 01.06–16.07 — кожна покупка токенів (5 з 5 за період) оплачувалась без нарахування. MTTD ≈ 45 діб (виявлення — скарга користувача). Від репорту до фіксу на проді — ~25 хв; до повного закриття (бекфіл) — ~2 год.
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”Дві копії одного контракту в різних репо
Section titled “Дві копії одного контракту в різних репо”Суми, які api-v2 надсилає в кредит, і суми, які tutor приймає, — це та сама константа, скопійована руками:
- api-v2:
src/api/tutor-billing/token-packages.config.ts—costUnits: 250_000 / 1_000_000 / 2_000_000(після80862da); - tutor:
app/constants.py:22—VALID_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_atFROM nmt_tests.tutor_token_ordersWHERE 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. Синк whitelist — abitly-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_balance → UPDATE … 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 NULL | ✅ 0 рядків |
Превентивні заходи
Section titled “Превентивні заходи”- 16.07: whitelist синхронізовано + в обох файлах коментарі-попередження про «трійку» (
token-packages.config.ts↔EXPECTED_TUTOR_VALID_AMOUNTS↔constants.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, а не за фактом оплати (квитанція маскувала збій).
Відкриті питання
Section titled “Відкриті питання”- Коли саме
80862daпоїхав на прод api-v2 — між 22.05 і 01.06 покупок не було, чи деплой стався пізніше? (На масштаб не впливає: перший уражений платіж — 01.06.) - Поведінка фронтенду при вічно порожньому
walletCreditedAt(полінгGET /tutor/tokens/order-status) — що бачив користувач і чому не було таймаута/ескалації в UI. - Чи варто чистити/акумулювати накопичені за 45 днів записи
security_eventsтипуwallet.credit.invalid_amount.
Ключові ідентифікатори
Section titled “Ключові ідентифікатори”| Поле | Значення |
|---|---|
| Коміти | api-v2: 80862da (зміна пакетів), 79b675c (осліплення канарки), 094d9c0 (видалення live-тесту), 0568b9b (створення захисту) · tutor: 0e3d04d (whitelist), cdf904c (фікс) |
| PR | api-v2 #596 — чесна канарка |
| Deploy | GH 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) |
| Cron | WalletReconcilerCron — EVERY_5_MINUTES, ліз 5 хв, ескалація >24h |
- Ручна копія крос-сервісної константи — дрейф «коли», а не «якщо». Контракт-тест, що порівнює репо сам із собою (локальне дзеркало), не перевіряє нічого: при зміні значень дзеркало оновлюють тим самим рухом. Перевірка мусить читати справжнє джерело (live endpoint, спільний пакет, contract-тест).
status=success≠ виконане зобовʼязання. «Гроші взяли, товар не видали» — окремий термінальний-але-незавершений стан, і саме він має бути першим алертом у будь-якому білінгу.- ERROR-лог без маршруту до людини — не детекція. Система 45 днів щодня писала правильний діагноз («likely TOKEN_PACKAGES drift») у stdout, який ніхто не читає.
- Видалення guard-тесту всередині великого коміту непомітне на ревʼю. Демонтаж захисту заслуговує окремого коміту з обґрунтуванням — інакше він зникає «під шум» hardening-у.
- Ідемпотентність на природному ключі (
order_id UNIQUE) робить відновлення тривіальним. І нескінченні ретраї реконсилера, і ручний SQL-бекфіл були безпечні без жодного ризику дабл-кредиту — патерн, вартий повторення в кожному money-flow. - Квитанція до виконання зобовʼязання маскує збій. Користувач отримує «все ок» від платіжки й сервісу — і не має причин скаржитись, поки не зайде витратити токени.
Пов’язана документація
Section titled “Пов’язана документація”- Abitly API service card — біллінг, webhook, cron
- Plans & Billing — фіча токенів AI-тьютора
- Prod DB recipe — як підключатись до прод-БД (SSM-тунель)
- Інцидент 2026-06-12 — попередній: теж «захист існував і був знятий»