[Ø Null Review](/)[Снимки GitLab](/#product-film)[Пилот для команды](/pilot)

[Отдельный реальный AI-прогон: код до / после, 8 тестов и квитанция ↗](/examples/review-journey.zip)

РАЗБОР КОДА · PYTHON + SQL

# Ошибки не в синтаксисе. Ошибки между шагами. 

Правильная подпись не спасает от двойного зачисления. Проверка баланса не останавливает гонку. Вход в систему не гарантирует изоляцию данных.

3 воспроизводимых дефекта 10 проверок пройдено Реальная SQLite и конкуренция 

Специально написанный исполняемый стенд, не клиентский production-код. Это технический разбор с тестами, а не результат запуска AI-ревью Null. Снимки реального GitLab показаны отдельно.

[Скачать код и тесты ↓](/examples/payment-review.zip)[Разобрать первый дефект ↗](#callback)[01 · Повтор callback](#callback)[02 · Гонка списаний](#race)[03 · Чужая выписка](#tenant)

01 

НАДЕЖНОСТЬ ПЛАТЕЖЕЙ

## Подпись верна. Деньги зачислены дважды.

Провайдер повторяет callback, потому что не получил ответ. Первая попытка уже изменила баланс, но упала до записи идентификатора события. Проверка «видели ли мы event_id» не спасает.

- 1 Подписанный callback: +1 000
- 2 Баланс обновлен, запись события еще не создана
- 3 Сбой между операциями
- 4 Повтор того же события: еще +1 000

ЧТО ЗАМЕТИТЬ НА РЕВЬЮ 

Идемпотентность должна быть частью той же транзакции, что и зачисление. Отдельная проверка event_id оставляет окно сбоя.

### До исправления

```
UPDATE accounts SET balance_minor = balance_minor + ?;
# Здесь баланс уже закоммичен.
# Сбой до INSERT оставляет платеж без следа в ledger.
INSERT INTO ledger (...);
```
Полная функция callback_before
```
def callback_before(self, tenant, raw, signature, *, crash=False):
"""Defect: autocommit splits balance and deduplication into two commits."""
event = authenticate(raw, signature, self.secret)
db = self.connect()
try:
self.account(db, tenant, event.account_id)
seen = db.execute(
"SELECT 1 FROM ledger WHERE tenant=? AND operation_id=?",
(tenant, event.event_id),
).fetchone()
if seen:
return "duplicate"
db.execute(
"UPDATE accounts SET balance_minor=balance_minor+? "
"WHERE tenant=? AND account_id=?",
(event.amount_minor, tenant, event.account_id),
)
if crash:
raise CrashAfterCredit()
db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
(tenant, event.event_id, event.account_id, event.amount_minor))
return "credited"
finally:
db.close()
```

### После исправления

```
BEGIN IMMEDIATE;
-- Проверить tenant, event_id и привязку payload.
INSERT INTO ledger (...);
UPDATE accounts SET balance_minor = balance_minor + ?;
COMMIT;  -- Либо обе записи, либо ни одной.
```
Полная функция callback_after
```
def callback_after(self, tenant, raw, signature, *, crash=False):
"""Fix: event reservation and balance update share one durable transaction."""
event = authenticate(raw, signature, self.secret)
db = self.connect()
try:
db.execute("BEGIN IMMEDIATE")
self.account(db, tenant, event.account_id)
prior = db.execute(
"SELECT account_id, amount_minor FROM ledger "
"WHERE tenant=? AND operation_id=?",
(tenant, event.event_id),
).fetchone()
if prior:
if tuple(prior) != (event.account_id, event.amount_minor):
raise Rejected("event id reused with different payload")
db.commit()
return "duplicate"
db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
(tenant, event.event_id, event.account_id, event.amount_minor))
db.execute(
"UPDATE accounts SET balance_minor=balance_minor+? "
"WHERE tenant=? AND account_id=?",
(event.amount_minor, tenant, event.account_id),
)
if crash:
raise CrashAfterCredit()
db.commit()
return "credited"
except BaseException:
db.rollback()
raise
finally:
db.close()
```

Резервируем событие и меняем баланс внутри BEGIN IMMEDIATE. Ошибка откатывает оба изменения. Повтор с тем же ID, но другой суммой отклоняется.

До 12 000

После 11 000

остаток при исходных 10 000 и одном пополнении на 1 000. Числа из тестового сценария, в минимальных единицах валюты.

Проверено тестом

Сбой откатывает баланс. После повтора одно зачисление. Из восьми одновременных обработчиков один возвращает credited, семь duplicate. Измененная сумма и неверная подпись отклоняются.

[Открыть исходник тестов ↓](/examples/payment-review/test_payments.py)

02 

КОНКУРЕНТНЫЕ ОПЕРАЦИИ

## Оба запроса проверили баланс. Оба ошиблись.

На счете 10 000. Два запроса одновременно хотят зарезервировать по 8 000. Каждый видит достаточный баланс до того, как другой успеет его изменить.

- 1 Запрос A читает 10 000
- 2 Запрос B читает те же 10 000
- 3 A списывает 8 000
- 4 B тоже списывает 8 000

ЧТО ЗАМЕТИТЬ НА РЕВЬЮ 

Проверка balance < amount и последующий UPDATE не являются одним действием. Между ними другой запрос может потратить тот же остаток.

### До исправления

```
balance = SELECT balance_minor FROM accounts ...
if balance >= amount:
# Другой запрос может пройти ту же проверку.
UPDATE accounts SET balance_minor = balance_minor - ?
```
Полная функция withdraw_before
```
def withdraw_before(self, tenant, account_id, amount, operation_id, *, barrier):
"""Defect: two requests can both pass the balance check."""
db = self.connect()
try:
balance = self.account(db, tenant, account_id)["balance_minor"]
if balance < amount:
raise Rejected("insufficient balance")
barrier.wait(timeout=5)
db.execute(
"UPDATE accounts SET balance_minor=balance_minor-? "
"WHERE tenant=? AND account_id=?", (amount, tenant, account_id),
)
db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
(tenant, operation_id, account_id, -amount))
return "reserved"
finally:
db.close()
```

### После исправления

```
UPDATE accounts
SET balance_minor = balance_minor - ?
WHERE tenant = ? AND account_id = ?
AND balance_minor >= ?;
-- rowcount != 1: отказ, транзакция откатывается.
```
Полная функция withdraw_after
```
def withdraw_after(self, tenant, account_id, amount, operation_id):
"""Fix: guarded debit and ledger entry are atomic; retries are bound."""
if type(amount) is not int or not 0 < amount <= 10**9:
raise Rejected("invalid amount")
db = self.connect()
try:
db.execute("BEGIN IMMEDIATE")
prior = db.execute(
"SELECT account_id, amount_minor FROM ledger "
"WHERE tenant=? AND operation_id=?", (tenant, operation_id),
).fetchone()
if prior:
if tuple(prior) != (account_id, -amount):
raise Rejected("operation id reused with different payload")
db.commit()
return "duplicate"
updated = db.execute(
"UPDATE accounts SET balance_minor=balance_minor-? "
"WHERE tenant=? AND account_id=? AND balance_minor>=?",
(amount, tenant, account_id, amount),
)
if updated.rowcount != 1:
raise Rejected("account unavailable or insufficient balance")
db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
(tenant, operation_id, account_id, -amount))
db.commit()
return "reserved"
except BaseException:
db.rollback()
raise
finally:
db.close()
```

Проверка достаточности входит в WHERE самого UPDATE. Изменение баланса и запись в ledger объединены транзакцией. Второй запрос получает отказ, повтор первого не списывает снова.

До −6 000

После 2 000

остаток после двух конкурентных запросов по 8 000. Числа из тестового сценария, в минимальных единицах валюты.

Проверено тестом

Тест с барьером воспроизводит двойной расход в исходном коде. Исправленный вариант резервирует только 8 000: один успех, один отказ, остаток 2 000 и одна запись ledger.

[Открыть исходник тестов ↓](/examples/payment-review/test_payments.py)

03 

ИЗОЛЯЦИЯ КОМПАНИЙ

## Вход выполнен. Но данные принадлежат другой компании.

Пользователь alpha запрашивает выписку по известному идентификатору счета beta. Сессия проверена, однако запрос в базу фильтрует только account_id.

- 1 Пользователь работает в alpha
- 2 В запросе указан wallet-b
- 3 SQL забывает условие tenant
- 4 В ответе оказывается операция beta

ЧТО ЗАМЕТИТЬ НА РЕВЬЮ 

Аутентификация пользователя не заменяет проверку принадлежности объекта. Ограничение компании должно дойти до запроса данных.

### До исправления

```
SELECT * FROM ledger
WHERE account_id = ?;
-- tenant есть в функции, но отсутствует в SQL.
```
Полная функция statement_before
```
def statement_before(self, tenant, account_id):
"""Defect: being logged into a tenant does not scope the SQL query."""
db = self.connect()
try:
return [dict(row) for row in db.execute(
"SELECT * FROM ledger WHERE account_id=?", (account_id,),
)]
finally:
db.close()
```

### После исправления

```
self.account(db, tenant, account_id)  # Проверка владельца.
SELECT operation_id, amount_minor FROM ledger
WHERE tenant = ? AND account_id = ?;
```
Полная функция statement_after
```
def statement_after(self, tenant, account_id):
"""Fix: tenant comes from trusted authentication, never request JSON."""
db = self.connect()
try:
self.account(db, tenant, account_id)
return [dict(row) for row in db.execute(
"SELECT operation_id, amount_minor FROM ledger "
"WHERE tenant=? AND account_id=?", (tenant, account_id),
)]
finally:
db.close()
```

Проверяем пару tenant + account_id и используем ту же пару в SELECT. Tenant приходит из доверенного контекста входа, а не из JSON запроса.

До Данные beta

После Отказ

результат запроса чужой выписки из alpha. Числа из тестового сценария, в минимальных единицах валюты.

Проверено тестом

Исходный метод возвращает операцию beta пользователю alpha. Исправленный отклоняет чужую выписку и callback; собственная выписка beta остается доступной.

[Открыть исходник тестов ↓](/examples/payment-review/test_payments.py)

ПРОВЕРИТЬ САМОСТОЯТЕЛЬНО

## Код можно запустить.

Python 3.10+ и стандартная библиотека. Тесты создают временную базу, воспроизводят дефекты и проверяют исправления. Внешние сервисы и реальные платежи не используются.

```
python3 -W error::ResourceWarning -m unittest -v test_payments.py
```

10 тестов включают проверки ошибочного поведения версий before. Успешный тест подтверждает воспроизведение дефекта, а не корректность этих версий.

[Исходники, тесты и инструкция ↓](/examples/payment-review.zip)

Проверено 27.10.2026, Python 3.14.6. Сбой внедряется исключением; это не тест аварийного завершения процесса. Вывод средств здесь означает резерв в локальном ledger, а не банковский перевод. [Хеши исходников и результат проверки](/examples/payment-review/verification.json) · [Границы стенда](/examples/payment-review/README.md)

## Какие риски есть в вашем коде?

Выберем закрытые MR и проверим Null на известных дефектах. Пользу модели измерим отдельно от корректности демонстрационного стенда.

[Обсудить ретро-прогон ↗](/#book-demo)

[На главную](/) · [Оставить отзыв](/#feedback)
