Если одноразовый промокод снова принимается после отмены заказа, это не всегда ошибка. Для одной акции код должен сгорать сразу после создания заказа, для другой — возвращаться покупателю, если магазин отменил неоплаченный заказ из-за отсутствия товара. Проблема начинается, когда поведение не соответствует правилам акции, зависит от порядка webhook или позволяет получить скидку несколько раз через отмену и повторное оформление.

Надежное исправление требует отделить сам промокод от факта его применения к конкретному заказу. Поле used = 0/1 плохо описывает резервирование, оплату, отмену, возврат и повторные события. Сначала нужно определить бизнес-правило, затем восстановить последовательность событий одного проблемного заказа и только после этого менять код или данные.

Сначала ограничьте возможный ущерб

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

  • зафиксируйте идентификатор кампании и промокода без публикации самого кода;
  • сохраните order_id, customer_id, итоговую сумму и размер скидки;
  • запишите время создания, оплаты, отмены и повторного заказа;
  • отметьте инициатора отмены: клиент, менеджер, склад, платежная система или автоматизация;
  • сохраните входящие webhook и идентификаторы событий без платежных секретов;
  • проверьте, не создавались ли дубли заказов при повторном нажатии;
  • сделайте резервную копию затрагиваемых таблиц или выгрузку выбранных строк;
  • не исправляйте историю массовым UPDATE до понимания правильного состояния.

Если промокоды уникальны и уже утекли, можно временно блокировать только повторные применения для клиентов, у которых есть связанный заказ. Такое ограничение безопаснее, чем удаление кампании: действующие заказы и отчетность сохраняются.

Определите, когда одноразовый промокод считается использованным

Слово «одноразовый» должно иметь точное определение. Код может быть одноразовым глобально, один раз на аккаунт, один раз на телефон, один раз на заказ или один раз для определенной группы товаров. Отдельно задается момент расходования. Без этих двух решений разработчик вынужден угадывать, что делать после отмены.

  • при вводе в корзине — слишком рано, потому что заказ еще не создан;
  • при создании заказа — защищает от параллельных оформлений, но требует резервирования;
  • при авторизации платежа — зависит от платежной схемы и поздних callbacks;
  • при успешном списании — не покрывает заказ с оплатой при получении;
  • при отгрузке — оставляет длинное окно для повторного применения;
  • при завершении заказа — удобно для программы лояльности, но рискованно для уникального купона.

Практичная модель обычно разделяет «зарезервирован» и «израсходован». При создании заказа код атомарно резервируется за ним. После подтвержденного этапа резерв превращается в окончательное использование. При допустимой отмене резерв можно освободить, а окончательное использование — только по явно утвержденному правилу.

Составьте таблицу правил отмены

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

Сценарий Что делать с кодом Клиент отменил до оплаты По правилам акции: вернуть или сжечь Оплата не состоялась Обычно освободить резерв после тайм-аута Магазин отменил из-за отсутствия Часто вернуть клиенту право применения Заказ оплачен и затем отменен Не возвращать автоматически без правила Частично отменена одна позиция Пересчитать право и распределение скидки Платеж в неизвестном состоянии Не освобождать до сверки с провайдером Дубликат заказа удален системой Освободить только резерв дубля

Таблица должна учитывать срок действия акции. Если код возвращается после окончания кампании, нужно решить, продлевается ли его действие персонально или клиент получает новый код. Автоматическое включение истекшего купона способно нарушить цену и отчетность.

Восстановите хронологию одного проблемного заказа

Возьмите один заказ, где ошибка воспроизводится, и соберите события по времени. Не ограничивайтесь текущим status заказа: он показывает результат, но не объясняет путь. Нужны запись использования промокода, журнал смены статусов, платежные callbacks, сообщения очереди и действия менеджера.

  1. Клиент проверил код в корзине.
  2. Сервер рассчитал скидку и создал заказ.
  3. Использование было зарезервировано или записано как окончательное.
  4. Платежный запрос получил идентификатор и статус.
  5. Заказ перешел в ожидание, оплату, отмену или неизвестное состояние.
  6. Обработчик отмены освободил, удалил или не изменил использование.
  7. Поздний webhook мог повторно изменить заказ после отмены.
  8. Клиент снова ввел тот же код и получил решение валидатора.

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

Проверьте модель данных

Один флаг в таблице promo_codes не хранит, кто, когда и в каком заказе использовал код. Для расследования и идемпотентной обработки нужна отдельная запись применения. Ее имя зависит от проекта, но смысл остается одинаковым: уникальное использование связывает кампанию, код, клиента и заказ, содержит состояние и историю времени.

promo_redemptions - id - campaign_id - promo_code_id - customer_key - order_id - state: reserved | consumed | released | void - discount_amount - currency - reserved_at - consumed_at - released_at - reason - version или updated_at

customer_key должен соответствовать правилу акции. Для авторизованного клиента это стабильный внутренний ID. Использовать только email или телефон опасно из-за нормализации, смены контакта и гостевых заказов. Если код уникален глобально, ограничение строится по promo_code_id; если один раз на клиента — по campaign_id и customer_key.

Не удаляйте применение при отмене

Удаление строки уничтожает аудит и позволяет повторному событию создать новое использование. Лучше переводить запись в released с причиной. Тогда валидатор понимает, можно ли восстановить право, а аналитика видит, сколько кодов было зарезервировано, израсходовано и возвращено. Для окончательно сгоревшего кода остается consumed или отдельное закрывающее состояние.

Закройте гонку между проверкой и применением

Типичная реализация сначала выполняет SELECT «код свободен», затем рассчитывает скидку и позже делает INSERT. Два параллельных запроса успевают увидеть свободный код и создают два заказа. Проверка в приложении не заменяет ограничение базы. Резервирование должно выполняться атомарно в транзакции либо через уникальный индекс и обработку конфликта.

Небезопасно: 1. SELECT available 2. расчет скидки 3. INSERT redemption Безопасная идея: 1. начать транзакцию 2. создать резерв с уникальным ограничением 3. пересчитать заказ по серверным правилам 4. сохранить заказ и связь redemption 5. зафиксировать транзакцию

Состав уникального индекса зависит от семантики. Для глобального одноразового кода достаточно запретить больше одного активного или окончательного применения. Для акции «один раз на клиента» нужен campaign_id вместе с customer_key. Если released снова разрешает использование, частичный уникальный индекс или отдельная активная сущность должны учитывать состояние. Реализацию подбирают под конкретную СУБД.

Сделайте обработку отмены идемпотентной

Событие отмены может прийти дважды: пользователь нажал кнопку, менеджер подтвердил действие, а затем платежная система прислала webhook. Один и тот же event иногда повторяется после тайм-аута. Обработчик должен распознавать уже примененное событие и возвращать прежний результат, а не повторно освобождать код или менять его состояние назад.

  • храните provider_event_id или собственный idempotency key;
  • проверяйте допустимый переход состояния перед записью;
  • не переводите consumed в released обычным повтором cancellation;
  • записывайте инициатора и причину изменения;
  • используйте compare-and-set по версии или текущему состоянию;
  • после повторного события не создавайте новую строку redemption;
  • возвращайте стабильный ответ уже обработанному событию.

Учтите поздние платежные webhook

Пользователь может закрыть платежную страницу, магазин отменит заказ как неоплаченный и освободит код, а через минуту придет успешный webhook. Если обработчик безусловно переведет заказ в paid, получится оплаченный заказ с уже доступным промокодом. Состояние платежа unknown нельзя приравнивать к failed, пока не выполнена сверка с провайдером.

  • связывайте платеж и заказ стабильным идентификатором;
  • проверяйте подпись и уникальность webhook;
  • разделяйте authorization, capture, cancel и refund;
  • не освобождайте резерв при сетевом тайм-ауте автоматически;
  • перед освобождением запрашивайте фактический статус операции при необходимости;
  • определите приоритет позднего успешного события и уже выполненной отмены;
  • отправляйте конфликтное состояние в очередь ручной сверки.

Проверьте все точки, где меняется статус заказа

В старом проекте отмена часто реализована в нескольких местах: контроллер клиента, административная панель, интеграция склада, cron неоплаченных заказов и webhook платежной системы. Один обработчик вызывает общий сервис, другой напрямую меняет поле status, третий удаляет связь с промокодом. Поиск только по функции cancelOrder не покажет все пути.

  • HTTP endpoint отмены пользователем;
  • кнопка менеджера и массовая операция в админке;
  • автоотмена неоплаченных заказов по таймеру;
  • отмена из-за отсутствия товара или ошибки резерва склада;
  • callback платежного провайдера;
  • интеграция CRM, ERP или маркетплейса;
  • повторная обработка очереди после ошибки;
  • скрипт импорта либо ручное изменение в базе.

Правило промокода должно выполняться в одном доменном сервисе переходов, а не копироваться в каждом контроллере. Прямое изменение status без события и проверки допустимого перехода следует постепенно закрыть.

Не пересчитывайте старый заказ по текущим правилам

После отмены или повторного открытия заказа система может заново вызвать калькулятор и применить текущую версию кампании. Но цена заказа должна объясняться теми правилами, которые действовали в момент оформления. Сохраните сумму скидки, валюту, базу расчета и версию правила в заказе или redemption. Иначе изменение процента акции задним числом меняет возврат и отчетность.

  • сохраняйте исходную стоимость товаров и распределенную скидку;
  • фиксируйте campaign_id и версию правил;
  • не вычисляйте возврат только из текущей цены каталога;
  • отдельно учитывайте доставку, бонусы и подарочные сертификаты;
  • для частичного возврата распределяйте скидку детерминированно;
  • не допускайте отрицательного итога из-за повторного применения скидки.

Отдельно разберите частичную отмену

Если удалена одна позиция, заказ может перестать соответствовать минимальной сумме или категории промокода. Автоматически возвращать весь одноразовый код обычно нельзя: часть выгоды уже использована оставшимися товарами. Нужно заранее определить, пересчитывается ли скидка, сохраняется ли она как обещанная цена и как распределяется возврат.

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

Проверьте гостевые заказы и смену аккаунта

Ограничение «один раз на клиента» легко обходится гостевым оформлением, новым email или другим аккаунтом. Но чрезмерная привязка к IP или fingerprint блокирует семьи и корпоративные сети и создает риски для персональных данных. Уровень защиты должен соответствовать стоимости скидки и правилам акции.

  • для персонального кода связывайте его с внутренним customer_id;
  • нормализуйте контакт только как дополнительный сигнал, а не единственную истину;
  • после входа объединяйте гостевой заказ с аккаунтом по явному правилу;
  • не сообщайте постороннему пользователю, кому принадлежит код;
  • ограничивайте попытки перебора и не используйте короткие предсказуемые коды;
  • храните причины отказа для поддержки, но показывайте клиенту безопасное сообщение.

Подготовьте безопасную миграцию данных

Если старая схема хранит только used, новую таблицу нельзя заполнять одним предположением. Сначала классифицируйте историю: активный заказ, оплаченный, отмененный до оплаты, возвращенный, неизвестный. Для неоднозначных записей создайте состояние review и не открывайте код автоматически. Миграция должна быть повторяемой и формировать отчет о количестве строк каждого типа.

  1. Создайте новую структуру и индексы без удаления старых полей.
  2. Заполните однозначные применения из заказов и журнала событий.
  3. Выведите конфликтующие и неизвестные случаи в отдельный отчет.
  4. Переключите чтение на новую модель под feature flag.
  5. Сравните решения старого и нового валидатора на копии трафика или тестах.
  6. Переключите запись и оставьте наблюдение за ошибками.
  7. Удаляйте старую модель только после периода стабильной работы.

Матрица обязательных тестов

Тест «создать, отменить, создать снова» недостаточен. Нужно проверить инициатора отмены, оплату, параллельность и повтор событий. Каждый сценарий должен подтверждать статус заказа, состояние redemption, итоговую сумму и возможность следующего применения.

  • два параллельных оформления с одним глобальным кодом;
  • два заказа одного клиента в разных вкладках;
  • отмена клиентом до платежа;
  • неуспешная оплата и истечение резерва;
  • тайм-аут с поздним успешным webhook;
  • отмена магазином из-за отсутствия товара;
  • отмена оплаченного заказа и полный возврат;
  • частичная отмена одной позиции;
  • повтор одного cancellation event;
  • повтор одного payment webhook;
  • заказ гостя с последующей авторизацией;
  • применение после окончания кампании;
  • одновременное использование бонусов и промокода;
  • округление скидки в разных валютах.

Как внедрить исправление без новых скидочных ошибок

  1. Утвердите правила расходования и возврата кода для каждого типа отмены.
  2. Зафиксируйте проблемный сценарий автоматическим тестом.
  3. Добавьте явные состояния reservation и уникальные ограничения базы.
  4. Сведите отмену к одному идемпотентному доменному обработчику.
  5. Обработайте поздние платежные события и неизвестные статусы.
  6. Выполните миграцию с отчетом неоднозначных записей.
  7. Проверьте матрицу на тестовой базе и sandbox оплаты.
  8. Разверните под feature flag и сравнивайте решения валидаторов.
  9. Контролируйте повторные применения и сумму скидок после выпуска.
  10. Сохраните возможность быстро вернуть старое чтение без потери новых записей.

Типичные ошибки

  • считать любую отмену основанием вернуть код;
  • сжигать код уже при вводе в корзине без резерва и тайм-аута;
  • удалять строку использования вместо изменения состояния;
  • проверять доступность и записывать применение вне одной атомарной операции;
  • надеяться только на проверку в интерфейсе или JavaScript;
  • освобождать код при неизвестном статусе платежа;
  • не учитывать повтор webhook и сообщений очереди;
  • использовать текущие правила акции для пересчета старого заказа;
  • автоматически возвращать весь код после частичного возврата;
  • менять данные массовым UPDATE без отчета и отката;
  • исправить только один endpoint отмены;
  • не проверить два параллельных заказа.

Как проверить результат после выпуска

Исправление считается успешным, когда повторное применение соответствует утвержденной политике во всех каналах, а не только в веб-корзине. Проверьте мобильное приложение, административное создание заказа, API и интеграции. Для каждого отказа поддержка должна видеть безопасную причину, а клиент — понятное сообщение без раскрытия чужих данных.

  • один код не создает два активных резерва;
  • повторный запрос оформления возвращает прежний результат идемпотентно;
  • допустимая отмена освобождает резерв ровно один раз;
  • окончательно использованный код не открывается поздней отменой;
  • неизвестный платеж остается на сверке, а не освобождает скидку;
  • частичный возврат сохраняет корректную историю суммы;
  • отчеты совпадают с заказами и redemption;
  • в журнале есть причина каждого перехода;
  • feature flag и откат проверены до production.

Как предотвратить повторение

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

  • используйте явную state machine для применения промокода;
  • защищайте уникальность ограничениями базы;
  • обрабатывайте команды и события идемпотентно;
  • храните историю изменений и инициатора;
  • добавляйте конкурентные тесты и тесты поздних webhook;
  • контролируйте аномальное число отмен и повторных скидок;
  • сверяйте сумму скидок с заказами и возвратами;
  • проверяйте правила акции до запуска и после завершения кампании.

Когда нужна помощь с промокодами и скидками

Если одноразовый промокод можно использовать после отмены заказа вопреки правилам акции, я могу восстановить цепочку событий, найти гонку или неверный переход состояния, спроектировать резервирование и идемпотентную обработку отмены. Затем проверю платежные webhook, частичные возвраты, конкурентные заказы и отчеты, а исправление внедрю с резервной копией и планом отката. Для оценки достаточно описать правила акции, тип проблемной отмены и обезличенные идентификаторы заказов.