Соображения безопасности в электронной коммерции почти всегда носят технический характер — инъекции, авторизация, секреты, ограничение частоты запросов. Но самые дорогие проблемы безопасности в зрелом ритейле почти никогда не бывают техническими. Это процессные уязвимости — они возникают на стыках двух систем, которые по-разному трактуют один и тот же объект, и в фигуре человека за прилавком, у которого есть кнопка override и нет данных, чтобы усомниться.

Общий паттерн всех дыр

Прежде чем перейти к списку — паттерн, который связывает все десять дыр. В каждом случае факт, зафиксированный в момент заказа — цена, условная корзинная скидка, доля платёжных инструментов, идентичность единицы товара, отгруженное количество — используется в момент возврата актором, который видит этот факт иначе: как простую, безусловную, построчную величину. Две системы расходятся в предположении:

Объект Система A Система B
Цена «цена в строке заказа зафиксирована на €3 000» (заказ) «этот SKU стоит по сегодняшней полочной цене — €4 500» (каталог POS)
Скидка «скидка — свойство всей корзины» (движок промоакций) «у каждой строки фиксированная цена; возврат построчно» (флоу возврата)
Платёж «заказ оплачен на 60% подарочной картой / на 40% картой» (слой списания) «есть одна сумма заказа; вернуть её на карту» (сервис возврата)
Единица «строка 2 — это некий CAM-X100» — идентичность по типу (строка заказа) «EAN совпал, состояние ок — та же единица» (сотрудник магазина)
Статус «эта заявка на возврат ещё открыта; я должен вернуть деньги после получения товара» (возвраты) «забрал товар и рассчитался — готово» (POS/OMS)

Во всех случаях лекарство — один и тот же архитектурный ход: назначить единый авторитетный источник истины для спорной величины и заставить каждый канал — веб, POS, CRM, OMS — опираться на этот источник, а не пересчитывать величину локально. Если величина по определению живёт вне SAP Commerce Cloud, то контролем безопасности становится связка обратной записи или сверки (write-back / reconciliation), и её отсутствие — это и есть уязвимость.

Эта статья — red-team процессов. Она написана специально под SAP Commerce Cloud, но по сути универсальна. Для каждой дыры я разделяю три вещи, которые обычно смешивают, —

Ценообразование

Возврат по полочной цене, а не по цене строки заказа

Этот сценарий совсем уж примитивный. Трудно поверить, что подобное где-то ещё встречается, но начать нужно с азов и постепенно двигаться вглубь. Легальная покупка по промо, тот же ритейлер, возврат по отсканированному SKU, выплаченный по сегодняшней полочной цене — почти никогда не всплывает в виде именованного судебного дела, потому что это был известный стык ещё 15–20 лет назад, и большинство крупных POS-систем его закрыли (поиск по чеку/карте привязывает возврат к исходному списанию; возвраты без чека выплачиваются по наименьшей недавней цене, а не по текущей).

Сценарий эксплуатации. Дмитрий покупает эспрессо-машину онлайн во время флеш-распродажи: базовая каталожная цена €4 500, скидка в треть, оплачено €3 000. Через две недели, когда распродажа закончилась и полочная цена вернулась к €4 500, он возвращает эту единицу во флагманский магазин. Сотрудник сканирует SKU; каталог POS видит, что сегодня этот SKU стоит €4 500, и возвращает полную цену на карту Дмитрия. Он получает +€1 500 с каждой возвращённой единицы — достаточно купить десять единиц по промо и вернуть десять по полочной цене, +€15 000. Вариант «возврат без чека» — та же самая уязвимость с дополнительным прикрытием в виде политики: возврат по текущей цене, потому что заказ «потерян».

Одна реальная преграда, которую стоит назвать сразу: связанный (linked) возврат у большинства PSP не может превышать исходное списание в €3 000, поэтому такой сверх-возврат требует либо ограниченного несвязанного (blind) кредита, либо — гораздо чаще — локального автономного возврата на стороне POS, который вообще не видит лимита исходного списания. Именно на этом POS-локальном пути и живёт данная дыра.

Механизм. Заказ утверждает: «цена этой единицы — зафиксированная цена со скидкой, €3 000». Каталог POS утверждает: «этот SKU стоит столько, сколько написано в нашем прайсе». Возврат считается по неверному предположению о цене единицы, потому что ничто не обязывает POS использовать зафиксированную в заказе цену. Как только единицу отсканировали на стойке возврата, она навсегда теряет идентичность «строки в заказе ORD-88421».

Вердикт.

Как читать вердикты по платформе. Каждая дыра несёт один или несколько из трёх тегов — и отсутствие тега тоже информация (нет — значит, платформа не даёт вам здесь ничего, на что можно опереться):

Моделируется платформой — в SAP Commerce Cloud достаточно хранения или контроля, чтобы «из коробки» быть корректным (или она даёт примитивы, чтобы быть корректным).
Отдано на вашу кастомную разработку — данные есть, но поведение по умолчанию или граница интеграции оставляет дыру открытой; закрыть её — задача вашего проекта.
Вне зоны ответственности платформы — величина или решение хранится во внешней системе (POS, внешний OMS, программа лояльности, сеть кэшбэка, BNPL, межфирменный учёт в ERP, договор франчайзинга). SAP Commerce Cloud в лучшем случае может дать возможность сверить эту величину.

Везде, где поведение зависит от вашей версии, редакции (stock accelerator против SAP Order Management) или предыдущих доработок, мы говорим «проверьте в своей сборке», а не утверждаем. Не воспринимайте ни один вердикт ниже как замену тестированию вашего собственного флоу возврата. И это десять показательных примеров, а не канонический или исчерпывающий набор — короткий список того, что мы оставили за рамками, — в конце.

/ Всё необходимое для корректного расчёта возврата платформа записывает — AbstractOrderEntry хранит basePrice, totalPrice, quantity и скидки через discountValues (список DiscountValue) плюс скидки уровня заказа globalDiscountValues, и всё это зафиксировано в заказе, а не подтягивается «вживую». Но нужно быть точным насчёт поведения по умолчанию: нативный DefaultReturnService.createRefund(...) берёт RefundEntry.amount из базовой цены товара (OrderEntry.basePrice, до скидки), если сумма не передана явно, — а не из totalPrice со скидкой. В заказе Дмитрия (база €4 500, оплачено €3 000) возврат «из коробки» составит €4 500 — дефолт самой платформы «протекает» на промо. Так что возврат именно оплаченной цены строки — вопрос того, что вы вычислите и передадите, а не автоматическая гарантия; бесплатно платформа даёт лишь то, что величина зафиксирована в заказе, а не в каталоге. В остальном домен возвратов полноценный — ReturnRequest, ReturnEntry/RefundEntry, работающие через ReturnService (DefaultReturnService), RefundService и, на стороне OMS, OmsReturnFacade.

Дыра лежит на границе POS ↔ Commerce. POS должен как-то сопоставить физический товар с исходной строкой заказа и вытащить оплаченную цену — через REST-эндпойнт OCC /orders/{code}, отдельный эндпойнт возвратов или слой OMS/RMA. Если же POS создаёт локальный возврат и лишь уведомляет Commerce, что «возврат произошёл», то Commerce превращается в пассивный реестр, а авторитет по цене остаётся на стороне POS. ASM (Assisted Service Module) — штатный механизм, закрывающий дыру, если сотрудники ведут процесс возврата в Commerce, а не в POS.

Физический POS, ручное переопределение цены сотрудником, политика возврата без чека и сторонний OMS, владеющий платёжным инструментом возврата. SAP Commerce не может заставить чужой POS уважать OrderEntry.totalPrice; он может лишь предоставить это через API и записать результат.

Как обнаружить. Сверьте сумму возврата с оплаченной ценой строки:

SELECT {o.code}, {e.entryNumber}, {e.totalPrice} AS paidLine,
       {re.amount} AS refundAmt, {e.product}
FROM   {RefundEntry AS re
        JOIN ReturnEntry AS ren ON {re.pk}={ren.pk}
        JOIN OrderEntry  AS e   ON {ren.orderEntry}={e.pk}
        JOIN Order       AS o   ON {e.order}={o.pk}}
WHERE  {re.amount} > ({e.totalPrice} / {e.quantity})

Любая строка, где сумма возврата превышает оплаченную цену строки, — это дрейф к полочной цене (или легитимное увеличение возврата; нужна проверка). Если возвраты порождаются в POS, артефакт лежит в журнале POS / аудите SAP CAR / логе возвратов OMS и должен сверяться со строками заказов Commerce по коду заказа. Также проверьте Spring-бин за returnService/refundService и любое входящее интеграционное отображение, которое задаёт RefundEntry.amount из внешнего поля.

Тест. Оформите заказ по активной промоакции (оплачено €3 000, база €4 500), завершите промо, затем верните одну единицу через каждый канал — нативный ReturnService, OCC returns API и реальный магазинный POS — и посмотрите RefundEntry.amount. Учтите, что дефолт «из коробки» выдаст €4 500, если сумма не передана явно; правильная цель — €3 000. Любой канал, который молча выдаёт €4 500, пересчитывает цену (из каталога или из дефолта по базовой цене).

Как предотвратить. Сделайте зафиксированную оплаченную цену строки единственным источником истины по цене: каждый путь возврата резолвится в OrderEntry и возвращает оплаченную сумму строки, что закреплено в кастомном расчёте суммы возврата — так это работает независимо от канала и переопределяет дефолт по базовой цене. Примите архитектуру RMA-first — онлайн порождает авторизацию возврата, несущую оплаченную цену и ключ заказа; POS обязан её отсканировать и обратиться в Commerce/OMS, чтобы вычислить сумму, а не предъявлять свою. Возвраты без чека по умолчанию — по наименьшей цене за последние N дней или в виде store credit, с проверками частоты. Ролево ограничивайте и снабжайте кодом причины каждое переопределение цены и отправляйте его обратно для аудита, описанного выше.

Перераспределение корзинной скидки при частичном возврате

Эксплойт. Белла покупает три рубашки по €3 000 каждая. Промо «3 по цене 2» даёт скидку €3 000 на самую дешёвую, так что она платит €6 000. Движок «сажает» −€3 000 на строку 3. Она возвращает строки 1 и 2 — две рубашки по полной цене — и оставляет строку 3. Наивный построчный возврат отдаёт €6 000 за две возвращённые строки, и она оставляет себе рубашку, за которую теперь заплатила ноль. Промо требовало купить три; она «купила» одну бесплатно.

Пороговый вариант тоньше. Купить A=€6 000 и B=€5 000, подытог €11 000, «−20% при сумме от €10 000» → оплачено €8 800. Возвращаем B. Даже если возврат учитывает распределённую долю B (€5 000 − €1 000 = €4 000), в оставшейся корзине теперь лишь €6 000 — ниже порога €10 000, — поэтому A вообще не должна была сохранять свою скидку −€1 200. Пересчёта нет, и Белла оставляет A за €4 800 вместо €6 000. Бесплатная доставка от порога устроена так же: вернуть товары, чтобы опуститься ниже порога, — и списанная доставка уже не удерживается обратно.

Механизм. Движок промоакций трактует скидку как свойство всей корзины, обусловленное её составом. Флоу возврата трактует каждую строку как имеющую фиксированную цену со «вшитой» скидкой и никогда не переспрашивает, удовлетворяет ли корзина условиям по-прежнему.

Вердикт SAP Commerce.

Rule-based promotionengine (на Drools: RuleBasedPromotion, действия вроде RuleBasedOrderAdjustTotalAction, вычисляемые в PromotionResult) фиксирует, какие именно промо сработали и с какими последствиями. Скидки хранятся с распределением — на уровне строк в discountValues, на уровне заказа в globalDiscountValues, разнесённые по строкам при calculateTotals. Платформа хранит достаточно, чтобы вычислить пропорциональный возврат.

Важная оговорка ради честности: дефолт «из коробки» берёт RefundEntry.amount из базовой цены товара (без скидки), поэтому без доработок поведением по умолчанию является протечка на полную цену строки — платформа не распределяет скидку на возврат за вас. И, что критично, флоу возврата не перезапускает движок промоакций на корзину после возврата, чтобы перераспределить общую скидку на оставшиеся строки или отозвать выгоду, порог которой больше не выполняется. (Примитив пересчёта итогов существует — RefundService.createRefundOrderPreview(order) клонирует заказ и пересчитывает итоги, — но он не переоценивает rule-based промо на гипотетическую оставшуюся корзину.) Изменяли ли это прежние доработки — проверьте тестом; но не рассчитывайте, что платформа делает условный пересчёт: по умолчанию — нет.

Бизнес-решение о том, «закрепляется» ли промо навсегда однажды заработанным (легитимная политика доброй воли) или строго условно; и любой внешний OMS или налоговый движок, владеющий расчётом возврата.

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

SELECT {o.code}, {o.globalDiscountValuesInternal},
       {re.amount} AS refundAmt, {ren.receivedQuantity} AS retQty,
       {e.entryNumber}, {e.totalPrice}
FROM   {ReturnRequest AS rr
        JOIN Order      AS o   ON {rr.order}={o.pk}
        JOIN ReturnEntry AS ren ON {ren.returnRequest}={rr.pk}
        JOIN RefundEntry AS re  ON {re.pk}={ren.pk}
        JOIN OrderEntry  AS e   ON {ren.orderEntry}={e.pk}}
WHERE  {o.globalDiscountValuesInternal} IS NOT NULL

Этот запрос лишь отбирает кандидатов; пометка делается на анализе. globalDiscountValues хранится сериализованным, так что декодируйте его, а затем помечайте любой частичный возврат, где итог возврата превышает paidTotal − retainedPaidTotal. Убедитесь по строкам PromotionResult, что при возврате не запускалась новая оценка, и сверьте deliveryCost с оставшимся подытогом на предмет неудержанной бесплатной доставки.

Тест. Запустите «3 по цене 2» (3 × €3 000, оплачено €6 000), отметьте, на какой строке лежит −€3 000, верните две строки по полной цене и посмотрите два значения RefundEntry.amount: складываются ли они в €6 000 (протечка — бесплатная оставленная рубашка) или в €3 000 (правильно — промо распалось)? Затем пороговый случай: A=€6 000 + B=€5 000 при «−20% от €10 000», верните B и проверьте, будет ли возврат €5 000, €4 000 или экономически правильные €2 800 (оплачено €8 800 минус €6 000, во столько теперь должна обойтись оставленная A по полной цене).

Как предотвратить. Правильный примитив: refund = paidTotal(original) − paidTotal(оставшаяся корзина после перезапуска движка промо). Реализуйте кастомный расчёт суммы возврата, который клонирует заказ без возвращённых количеств, прогоняет PromotionEngineService, вызывает calculateTotals и берёт разницу. Это одним согласованным механизмом покрывает распад «3 по цене 2», потерю порога и пропорциональное перераспределение. Сделайте удержание бесплатной доставки переключателем в процессе возврата. И главное — сделайте политику («пересчитывать промо при возврате» против «заработанное промо сохраняется») явным, задокументированным решением. Провал — это когда политики нет, и наивный дефолт (построчный или по базовой цене) решает молча.

Неденежные средства

Обе дыры этого раздела по своей сути лежат вне нативной зоны SAP Commerce Cloud — и в этом вся суть. Платформа владеет заказом, заявкой на возврат, суммой возврата и follow-on-возвратом на карту — и примерно на этом её знание заканчивается. Лояльность, кэшбэк, подарочные карты с балансом и BNPL — всё это живёт во внешних системах. Эксплойт никогда не случается внутри платформы; он случается на стыках.

Баллы лояльности и кэшбэк не отзываются

Эксплойт. Дарио покупает машину за €600 онлайн. Программа лояльности начисляет 6 000 баллов при списании оплаты. В течение 48 часов он тратит 6 000 баллов на отдельный небольшой заказ и получает товар. Затем он возвращает машину и получает полный возврат €600. Отзыв баллов пытается списать 6 000 с баланса, где сейчас лишь 200, — и если правило «баланс не может уйти в минус», списывается 200, а 5 800 баллов (~€58) списываются в убыток. Баллы взаимозаменяемы и тратятся мгновенно; возврат медленный и асинхронный. Окно между начислением и отзывом — это и есть вся поверхность атаки. (Заодно едет и кэшбэк по карте или порталу — но см. оговорку ниже; эта часть обычно восстанавливается, с задержкой.)

Механизм. Система лояльности считает, что начисление — функция события размещения заказа, и считает начисление окончательным. Система коммерции/возвратов считает, что возврат — функция денежной стоимости строки заказа, и не несёт обязательств перед реестром лояльности. Никто не владеет «чистой заработанной стоимостью после расчётов». Начисление считается на брутто-заказ; отзыв, если он есть, считается от того, что осталось на балансе, — а не от исходного начисления.

Вердикт SAP Commerce.

Из коробки платформа этого не делает вовсе — и именно эта рамка важна. В SAP Commerce нет LoyaltyPointsService, нет реестра баллов, нет движка начисления/списания. Лояльность живёт во внешней системе (SAP Emarsys Loyalty, Talon.One, Antavo, Annex Cloud или самописной). Кэшбэк срабатывает у эквайера/эмитента/агрегатора (Rakuten, банк-эмитент) по событию транзакции и управляется их SLA по обнаружению возвратов. У SAP Commerce нулевая видимость и того, и другого.

Чем платформа владеет — это механикой, чтобы инициировать отзыв в нужный момент: флоу ReturnRequest / RefundEntry, бизнес-процесс возврата и платёжный REFUND_FOLLOW_ON против исходного списания. Она может испустить событие; вести реестр она не может.

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

Две оговорки ради честности. (1) Может ли баланс лояльности уйти в минус — это свойство модели данных внешнего вендора, а не SAP Commerce; многие продукты по дизайну жёстко упираются в ноль, что и есть корень проблемы; проверяйте по каждой программе. (2) Кэшбэк по карте/порталу обычно автоматически отзывается эмитентом или удерживается ~60–90 дней и разворачивается порталом при возврате — так что это не гарантированный безвозвратный убыток. Реальная экспозиция — временной зазор и любой портал, который не разворачивает начисление; устойчивый сюжет здесь — взаимозаменяемость баллов, а не кэшбэк.

Как обнаружить. Самый показательный отчёт связывает реестр лояльности с заказами: начисления, у чьего родительского заказа есть завершённый ReturnRequest, но нет соответствующего отзыва (или отзыв меньше начисления). Добавьте отчёт «списанных в убыток отзывов», суммирующий отзывы, упёршиеся в нулевой пол, — этот итог и есть количественно измеренная протечка. По кэшбэку сверяйте ежемесячную выписку об отзывах от агрегатора с вашим реестром возвратов. Проверьте у вендора настройки «разрешить отрицательный баланс» и «отзыв при возврате» и то, есть ли в return-process шаг отзыва вообще.

Тест. Оформите заказ, подтвердите начисление баллов, потратьте весь баланс на втором заказе, затем полностью верните первый. Проверьте, что в реестре есть отзыв, равный исходному начислению, и баланс уходит в минус (или создаётся запись о долге/списании). Повторите с частичным возвратом и проверьте пропорциональный отзыв.

Как предотвратить. Отзывайте против исходного начисления, а не текущего баланса. Разрешите отрицательные балансы по контракту (отзыв при следующем начислении) или конвертируйте невозвратную часть в явную запись о взыскании наличными — но никогда молча не упирайтесь в ноль. Ещё лучше — задерживайте крупные начисления за пределы окна возврата: начисляйте при заказе, но делайте баллы доступными к трате только после периода права на возврат, либо держите часть «в ожидании». Где программа позволяет — испускайте событие начисления по чистой рассчитанной сумме, а не по факту размещения заказа. Запускайте ежедневную трёхстороннюю сверку: возвраты commerce ↔ реестр лояльности ↔ расчёты по платежам/кэшбэку.

Превращение неденежных инструментов в наличные

Эксплойт. Мара покупает товаров на €500, оплачивая €400 промо-подарочной картой (выпущенной по акции «потратьте €300 — получите €100 бесплатно», то есть €100 этого баланса — промо-бонус) и €100 дебетовой картой. Она возвращает всё. Интерфейс возврата видит «сумма заказа €500» и возвращает €500 на её дебетовую карту. Реальные затраты Мары — €400 (€300 за подарочную карту + €100 дебет); она получает €500 — чистая прибыль €100, ровно промо-бонус, обналиченный в кэш. Подарочная карта, которую следовало восстановить как неденежный store credit, испарилась на банковский счёт.

Вариант с BNPL — хуже. Мара покупает диван за €900, разбивая на €300 предоплаты плюс Klarna «Pay in 3» на €600. Klarna уже заплатила продавцу вперёд. Она возвращает диван и получает €900 на карту — при этом рассрочка на €600 остаётся активной и должна быть отдельно отменена или отозвана через API Klarna. Сделав это неправильно, продавец вернул деньги вне канала и всё ещё должен размотать финансирование Klarna: двойная экспозиция на BNPL-плече.

Механизм. Сервис возврата считает, что заказ — единая сумма, погашенная одним инструментом (карта в файле). Слой списания — и внешний провайдер подарочной карты или BNPL — знает, что заказ профинансирован несколькими инструментами в определённых пропорциях, у каждого своя семантика возврата. Идентичность «наличные против неналичных» теряется между списанием и возвратом, поэтому возврат схлопывает разнородный набор инструментов в однородные наличные.

Проверка реальностью по карточному плечу. Возврат €500 на карту, с которой списали €100 (или, в дыре №1, €4 500 на карту, с которой списали €3 000), превышает исходное списание. Связанный/референсный кредит у большинства PSP и по правилам Visa/Mastercard не может превышать списанную сумму, поэтому чистый «сверх-возврат на карту» требует несвязанного кредита (ограниченного и помечаемого) или локального автономного возврата на POS. Там же, где избыток — это плечо подарочной карты или BNPL, возвращаемое на карту, карточная сумма всё ещё может укладываться в собственное списание карты — именно поэтому в возврат должна доживать идентичность инструмента, а не только сумма.

Вердикт SAP Commerce.

Лояльность, подарочные карты с балансом и BNPL — всё внешнее. Voucher/PromotionVoucher/SerialVoucher в SAP Commerce — это скидочные ваучеры, а не инструменты с хранимой стоимостью; настоящая функциональность подарочных карт (реестр баланса, активация, пополнение) — это кастомный тип плюс внешний провайдер (Blackhawk, SVS/Fiserv, Givex). Провайдеры BNPL (Klarna/Affirm/Afterpay) интегрируются как способ оплаты; у платформы нет нативного понимания, что возврат должен отменить рассрочку.

Скажем прямо о слабости платформы: в SAP Commerce «из коробки» очень ограниченная поддержка сплит-оплаты. Классическая модель — один PaymentInfo на заказ; нет полноценного «списка инструментов с суммами», который флоу возврата уважал бы нативно. Модель данных может представить несколько списаний — paymentTransactions это коллекция, каждая PaymentTransaction держит записи AUTHORIZATION/CAPTURE/REFUND_FOLLOW_ON, — но штатная логика возврата не раскладывает возврат по этим исходным транзакциям пропорционально, а RefundEntry несёт сумму, а не инструмент.

Так что поведение с учётом инструментов — целиком кастомное, и у него три части. Во-первых, сохраните структурированный реестр инструментов при списании — кастомный OrderTenderEntry или дисциплинированно тегированные PaymentTransaction — с записью, какой провайдер, какой инструмент и на сколько. Во-вторых, прочитайте его во флоу возврата и разложите каждый RefundEntry по исходным инструментам в исходных пропорциях. В-третьих, оркестрируйте правильный разворот по каждому инструменту: REFUND_FOLLOW_ON на конкретное списание карты, повторное зачисление внешнему провайдеру подарочной карты через его API, разворот лояльности через её API и POST-возврат провайдеру BNPL, чтобы рассрочка уменьшилась. Жёсткий инвариант поверх всех трёх: неденежное никогда не должно возвращаться в наличные.

Точное поведение сильно зависит от PSP. Adyen, например, поддерживает возврат по каждому способу оплаты на стороне PSP и может нести идентичность инструмента, если интеграция её прокидывает, — но SAP Commerce не сделает пропорциональную разбивку за вас без кода. Проверяйте по каждой интеграции.

Как обнаружить. Отчёт-улика связывает записи списания PaymentTransactionEntry (по инструменту/провайдеру) с записями REFUND_FOLLOW_ON по тому же заказу и помечает любой заказ, где распределение возврата по инструментам отличается от распределения списания — особенно возвраты на карту при списаниях с подарочной карты или BNPL. Сверьте внешний реестр подарочных карт на предмет отсутствующих повторных зачислений и отчёт по расчётам BNPL на предмет возвращённых заказов, чья рассрочка так и не была отменена (с продавца всё ещё списывают).

Тест. Оформите заказ 60% подарочной картой / 40% картой, верните полностью и проверьте, что 60% зачислено провайдеру подарочной карты (проверьте внешний баланс), а 40% — REFUND_FOLLOW_ON на карту, а не 100% на карту. Повторите с частичным возвратом (проверьте пропорциональную разбивку) и с BNPL-заказом (проверьте, что рассрочка отменена у провайдера — в его портале).

Как предотвратить. Сделайте возврат на исходный инструмент, по каждому инструменту, пропорционально неоспоримым инвариантом сервиса возврата. Моделируйте сплит-оплату явно, раз «из коробки» её нет. Направляйте BNPL-возвраты через API провайдера, чтобы рассрочка корректировалась. Помечайте промо-стоимость подарочной карты как невозвратную в наличные и отслеживайте промо-финансируемую долю отдельно, чтобы «бесплатная» стоимость никогда не восстанавливалась ничем большим, чем неденежный store credit. Сверяйте ежедневно: возвраты commerce ↔ реестр подарочных карт ↔ расчёты PSP/BNPL.

Физическая единица товара

Одна структурная истина для этого раздела: строка заказа в SAP Commerce Cloud идентифицирована по типу (товар / вариант / EAN), а не по экземпляру. Поэтому любой контроль ниже, основанный на сериализации, — кастомный, и — поскольку модуль комплектов активно раскладывает комплект на компонентные строки заказа — любой контроль «должно вернуться как единое целое» тоже кастомный.

Подмена единицы при несериализованном учёте

Эксплойт. SKU CAM-X100 (беззеркальная камера, «тушка»), один EAN, продаётся онлайн за €1 199 как новая, текущего года выпуска. Тот же EAN есть и на распродажных единицах прошлой ревизии в магазине-партнёре (€649), и на open-box единицах серого рынка на маркетплейсе (€520). Марек покупает единицу за €1 199 онлайн и оставляет её себе. Отдельно он приобретает open-box единицу за €520 с тем же EAN, инициирует онлайн-возврат и сдаёт эту единицу. Оператор сканирует EAN, он совпадает с ReturnEntry, состояние выглядит приемлемым → возвращается €1 199. Марек оставляет себе новую единицу. Чистый убыток ~€679 за цикл (€1 199 возврата минус ~€520 стоимости подменной единицы, которую забирает ритейлер) плюс скрытый второй убыток, когда единица низшего сорта снова попадает в продаваемый сток по полной стоимости. Вариант с одеждой — тот же трюк, когда один EAN напечатан на всём размерном ряду.

Механизм. Сервис заказа/фулфилмента считает, что ReturnEntry идентифицирует отгруженную вещь, — но он ссылается лишь на код Product и количество, без идентичности экземпляра. Оператор считает: «скан совпал со строкой + состояние ок = та же единица». Скан подтверждает принадлежность к классу товара, а не происхождение экземпляра.

Вердикт SAP Commerce.

/ Платформа моделирует иерархию товаров/вариантов (VariantProduct, GenericVariantProduct, ApparelSizeVariantProduct, VariantValueCategory), StockLevel (количества по складам, не идентичности) и граф возвратов с гранулярностью строка заказа + количество. Обратите внимание на терминологическую ловушку: Unit/UnitModel у товара — это единица измерения, а не сериализованная физическая единица. Ядро SAP Commerce не отслеживает индивидуальные серийные номера для обычных товаров. Нет OOTB-атрибута на OrderEntry/ConsignmentEntry/ReturnEntry для поштучного серийника или IMEI. Поэтому идентичность возвращаемого товара платформой не проверяема — она может подтвердить «товар с EAN X, количество 1», но никогда — «конкретную единицу, отгруженную по консигнации C». Возможности, смежные с сериализацией, живут в других продуктах SAP (управление серийниками/партиями в S/4HANA, Advanced Track & Trace, handling units в EWM), а не в строке заказа commerce.

Чтобы закрыть: фиксируйте серийник/IMEI/метку на фулфилменте (расширьте ConsignmentEntry или добавьте тип ProductInstance, связанный с консигнацией и строкой заказа) и добавьте в процесс возврата валидацию, отклоняющую или удерживающую любой ReturnEntry, чей отсканированный серийник не входит в отгруженный по этому заказу набор, — блокируя RefundEntry до прохождения. Делайте это обязательным только для конфигурируемого набора дорогих/рисковых товаров; не сериализуйте весь каталог. Добавьте обязательную оценку состояния при приёмке и никогда не возвращайте автоматически в InStock по полной стоимости без оценки сорта.

Физический осмотр (та ли это единица, тот ли год выпуска, новая или б/у), скан на POS и мастер-данные серийников/партий/гарантий в ERP.

Где это реально бьёт. Для камер, телефонов и ноутбуков зрелые ритейлеры уже ведут учёт серийников и сверяют их при возврате — так что категории из примера часто уже контролируются физически. Дыра реальна внутри строки заказа SAP Commerce (нет идентичности экземпляра, с которой можно сверяться), и сильнее всего бьёт в категориях, которые не сериализованы: одежда с общими EAN, аксессуары, электроника среднего ценового сегмента, private label.

Как обнаружить. Найдите дорогие SKU, чей EAN разделяется несколькими вариантами или базовыми товарами, — это кандидаты на коллизию: запрос Product с группировкой по ean и условием count(distinct code) > 1, отранжированный по цене. Затем отчёт «возврат против себестоимости»: сгруппируйте RefundEntry.amount по возвращённым строкам, сильно превышающим средневзвешенную себестоимость SKU. И аудит повторного ввода: товары, помеченные как возвращённые и возвращённые в продаваемый сток без атрибута состояния/сорта.

Тест. Отгрузите строку для SKU с EAN E по цене P; инициируйте возврат, но при приёмке отсканируйте другую физическую единицу, легитимно несущую EAN E (другая партия / open-box). Примет ли система receivedQuantity и авторизует полный возврат по P без проверки серийника или состояния? Если да — вы уязвимы. Прямой аудиторский вопрос: «Для любого завершённого дорогого возврата — можете ли вы доказать, что полученный физический экземпляр равен экземпляру, отгруженному по этому заказу? Если ответ „мы отсканировали EAN“ — контроля не существует».

Как предотвратить. Сериализуйте на фулфилменте дорогие/склонные к коллизиям товары; жёстко привязывайте возврат к совпадению серийника; убивайте коллизии EAN в мастер-данных (уникальный EAN на продаваемый вариант, через валидационный интерцептор); оценивайте состояние при приёмке, и пусть сорт определяет и сумму возврата, и право на возврат в сток; требуйте совпадения серийника или одобрения супервайзера для возвратов выше маржинального порога.

Комплекты и разные юрлица

Эксплойт. «Home Theatre Pack» продаётся онлайн за €899 = саундбар (прайс €599) + сабвуфер (€299) + тыловые колонки (€199), скидка за комплект €198. Прия покупает его, хочет только саундбар и возвращает в магазин только сабвуфер и тылы. Сотрудник сканирует их как отдельные товары по индивидуальным прайсовым ценам и возвращает €498. Чистая стоимость саундбара для Прии — €401 против €599 при отдельной продаже: она положила себе в карман скидку за комплект, которая была обусловлена сохранением набора. Зеркальный эксплойт — вернуть полную коробку без одного компонента и получить полные €899, если проверки комплектности нет.

Версия вообще без злонамеренного покупателя: тот же возврат принят во франчайзи (отдельное юрлицо). Франчайзи возвращает деньги покупателю, затем выставляет межфирменную заявку на возмещение по €1 097 (сумма частей), тогда как бренд получил лишь €899. Дельта €198 — чистая межфирменная утечка, урегулируемая неделями позже пакетно, где расхождение невидимо. Умножьте на тысячи франчайзи-возвратов: структурная эрозия маржи, за которой не следит ни одна антифрод-команда.

Механизм. Сервис заказа/ценообразования трактует комплект как единую ценовую единицу с условной скидкой; сервис возврата в магазине трактует то, что перед ним, как N независимых товаров с независимыми ценами и EAN. Они расходятся в том, один это объект или три, и предусловие скидки — целостность набора — не доносится в путь возврата. В межфирменном случае продающее и принимающее юрлица расходятся в том, кому принадлежат деньги и по какой оценке кредитуется возврат.

Вердикт SAP Commerce.

Модуль комплектов (configurablebundleservices/bundleservices) моделирует BundleTemplate, товары-компоненты и ценообразование через правила комплекта — но, что важно, представляет комплект в заказе как несколько строк AbstractOrderEntry, связанных bundleNo, а не как одну атомарную строку. То есть на уровне строк заказа комплект уже разложен, и ReturnEntry (на строку заказа + количество) с радостью создаст возврат на один компонент. Возврат на уровне компонента — это дефолт, а не заблокированный краевой случай; нет OOTB-ограничения, что комплект возвращается целиком, и нет OOTB-перерасчёта, отзывающего скидку за комплект с оставленных компонентов. Закрыть — кастомно: правило времени возврата по bundleNo, которое либо требует вернуть весь комплект, либо запускает перерасчёт «разбитого комплекта» (расширение AbstractBundleRule), снимающее скидку и переоценивающее оставленные компоненты по отдельным ценам до расчёта RefundEntry. (Если же вы моделируете набор как единый неразложенный SKU, платформа не знает, что в коробке три сканируемых товара, — проверка комплектности целиком кастомная и отчасти физическая.)

Межфирменные финансовые взаиморасчёты между брендом и франчайзи живут в ERP (межфирменный биллинг S/4HANA), а не в Commerce — нет нативного понятия «принимающее юрлицо ≠ продающее юрлицо» для расчётов. Договоры франчайзинга (кто съедает расхождение, по какой оценке) — юридически-коммерческие. Максимум, что может платформа, — проштамповать принимающее юрлицо и точную оплаченную оценку комплекта на каждом возврате и испустить структурированную запись расчёта, чтобы сверка была автоматической и на одной базе, а не ручной заявкой по прайсу.

Как обнаружить. Найдите комплекты, возвращённые как собственное подмножество компонентов: сгруппируйте OrderEntry по bundleNo, посчитайте строки на комплект и сравните с числом ReturnEntry на bundleNo — любой комплект, где вернули лишь часть компонентов, кандидат. Затем проверка «возврат против оплаты»: суммируйте RefundEntry.amount по комплекту против оплаченной totalPrice комплекта, помечая, где сумма возвратов компонентов превышает распределённую цену комплекта. На стороне ERP следите за межфирменным клиринговым счётом на предмет устойчивого однонаправленного дисбаланса от франчайзи-заявок на возврат.

Тест. Купите комплект за €899, верните два из трёх компонентов: будет ли возврат €498 (по прайсу, уязвимо) или отозванная распределённая стоимость комплекта (правильно), и переоценивается ли оставленный компонент по отдельной цене? Затем межфирменный вопрос: «Для комплекта, возвращённого во франчайзи-магазин, покажите запись автоматической сверки — это возмещение по распределённой цене комплекта, которую реально заплатил покупатель, или ручная заявка по прайсу компонентов?».

Как предотвратить. Держите связку bundleNo/bundleTemplate авторитетной и делайте так, чтобы возврат компонента запускал оценку всего комплекта — возврат комплекта целиком или перерасчёт разбитого комплекта, но никогда не возврат компонентов по прайсу из комплекта со скидкой. Добавьте проверку комплектности для набор-SKU. Сделайте расчёты осведомлёнными о юрлице: штампуйте принимающее юрлицо и оплаченную оценку, испускайте межфирменную запись на одной базе в ERP и сверяйте по каждой транзакции, а не пакетно.

Статусы и время

Двойной возврат из-за асинхронности каналов

Эксплойт. Майя купила эспрессо-машину за €640 (одна строка, кол-во 1). В 10:12 она начинает онлайн-возврат — SAP Commerce создаёт RMA-77341, автоодобренную до WAIT. В 10:40 вместо пункта выдачи она заходит в магазин; POS находит заказ через OMS, видит, что строка ещё возвратна, и возвращает €640 на её карту — не обращаясь обратно в граф ReturnRequest в Commerce. Онлайн-RMA она так и не отменяет. Ночной пакет сверки импортирует магазинный возврат, но запись конфликтует и отбрасывается как предупреждение «дубликат», которое никто не читает. Через три дня склад сканирует приёмку по RMA-77341, процесс возврата доходит до шага возврата денег — и разворачиваются вторые €640. Тот же трюк работает как «возврат в магазине плюс чарджбэк в банке».

Механизм. POS/OMS считает: «я забрал товар и рассчитался — готово». ReturnRequest в Commerce считает: «эта RMA ещё WAIT; у строки ещё есть возвратное количество; когда придёт товар — я должен вернуть деньги». Никто не трактует «деньги, причитающиеся по этой строке заказа» как единый, заблокированный, межканальный факт.

Вердикт SAP Commerce.

Платформа хорошо моделирует граф возвратов: ReturnRequest со своей стейт-машиной ReturnStatus (APPROVAL_PENDING, WAIT, RECEIVED, PAYMENT_REVERSED, COMPLETED, CANCELED, CANCELLING, плюс статусы разворота платежа/налога вроде PAYMENT_REVERSAL_FAILED и др.), ReturnEntry, указывающий на строку заказа, RefundEntry с суммой, и task-engine return-process, где переход в RECEIVED — штатный стык высвобождения денег.

Штатный DefaultReturnService не предотвращает две одновременные ReturnRequest на один OrderEntry. Он лишь проверяет остаточное возвратное количество (getAllReturnableEntries / OrderReturnTool), и эта проверка не заблокирована транзакционно между двумя созданиями — две заявки, каждая читая «возвратно = 1», могут быть созданы обе до того, как любая закоммитит. Хуже того, учёт возвратного количества знает лишь о возвратах, созданных внутри SAP Commerce; возврат POS/OMS, который так и не записал ReturnEntry обратно, для него невидим. Так что даже идеальная защита в рамках одной системы это не закроет. Нужен единый real-time авторитет статуса возврата с гранулярностью строки заказа, распределённый ключ идемпотентности / блокировка на (orderCode, entryNumber, reason) и возвраты денег, привязанные к подтверждённой приёмке, а не к любому статусу, который может выставить канал.

Магазинный POS и его рельсы возвратов, реестр расчётов внешнего OMS/ERP, события сканирования курьера/ПВЗ и банковская сеть чарджбэков. Во многих омниканальных ландшафтах SAP Commerce — участник, а не реестр — расчётами владеет внешний OMS или S/4HANA, и связывает реестры сверка, а не внешний ключ.

Как обнаружить. Найдите строки заказа с более чем одним возвратом и суммы возвратов, превышающие стоимость строки:

SELECT {o.code}, {oe.entryNumber},
       SUM({rf.amount}) AS refunded, {oe.totalPrice} AS lineTotal
FROM   {RefundEntry AS rf
        JOIN ReturnEntry AS re ON {rf.pk}={re.pk}
        JOIN OrderEntry  AS oe ON {re.orderEntry}={oe.pk}
        JOIN Order       AS o  ON {oe.order}={o.pk}}
GROUP BY {o.code}, {oe.entryNumber}, {oe.totalPrice}
HAVING SUM({rf.amount}) > {oe.totalPrice}

Сопоставьте бизнес-процессы return-process в состоянии SUCCEEDED с заказами, которые также появляются в фиде сверки магазинных возвратов, и проверьте историю CronJob этого импорта на предмет проглоченных предупреждений «дубликат возврата» — вот где прячется коллизия. На стороне платежей ищите два возврата на одну карту против одной исходной авторизации в пределах окна сверки.

Тест. Создайте две ReturnRequest на один OrderEntry из двух точек входа одновременно (нативный сервис + OCC API, пересекающиеся транзакции). Обе ли проходят? Продвиньте обе в RECEIVED и посчитайте строки RefundEntry и суммарно захваченное. Затем вбросьте запись магазинного возврата по той же строке через фид сверки и посмотрите, отклонит ли система, смёржит или проведёт дважды. Аудиторский вопрос: «Покажите транзакционную границу и блокировку, гарантирующую, что сумма проведённых возвратов по одной строке заказа никогда не превысит её чистую стоимость — через POS, OMS и Commerce». Если ответ «ночная сверка ловит это» — дыра открыта.

Как предотвратить. Привязывайте деньги только к физическому RECEIVED, выставляемому складом против совпавшего SKU/серийника, — никогда к созданию заявки в портале или к «принято» на POS. Поставьте единый реестр возвратов (выделенный сервис или назначенный OMS) во главу состояния возврата по каждой строке заказа; каждый канал синхронно обращается к нему, чтобы зарезервировать возвратное количество, прежде чем обещать возврат. Навешивайте распределённые ключи идемпотентности на каждую команду возврата, чтобы повтор или второе проведение были no-op. Моделируйте возврат-и-выплату как сагу с транзакционным outbox, чтобы падение не испустило дважды. Оставьте сверку, но повысьте её предупреждения «дубликат» до жёстких блокирующих алертов с автоудержанием второй выплаты.

Выплаты по сканированию и фантомные возвраты через ПВЗ

Эксплойт. Две формы. Отмена после отгрузки: Дэн заказывает ноутбук за €1 200; в 18:00 консигнация SHIPPED и у курьера; в 18:03 он жмёт «Отменить заказ», флоу отмены настроен на авто-возврат по подтверждению, и €1 200 зачисляются. Посылка всё равно доезжает. Дэн оставляет ноутбук. Фантомный возврат через ПВЗ: Надя возвращает куртку за €300 через пункт выдачи; скан курьера RETURN_ACCEPTED отображается входящей интеграцией в ReturnStatus = RECEIVED, процесс возврата продвигается, €300 возвращаются — а через шесть дней коробка доезжает до склада с кирпичом внутри. Проверка содержимого не привязана к возврату, потому что он уже сработал. При тысячах посылок в день это и есть системная протечка.

Механизм. Две системы расходятся в том, какое событие означает «деньги причитаются». Курьер/ПВЗ испускает «посылка поступила ко мне» — логистический факт. Триггер возврата трактует любой сигнал вида RECEIVED как «у нас верифицированный, правильный товар» — расчётный факт. Для отмены-после-отгрузки сервис отмены считает «отмена запрошена ⇒ отгрузку можно остановить ⇒ безопасно возвращать деньги», тогда как курьер уже прошёл точку невозврата.

Вердикт SAP Commerce.

Платформа моделирует отмену (OrderCancelService, CancelRequestRecordEntry, право на отмену по статусу консигнации) и переход возврата в RECEIVED как штатный стык высвобождения денег, с ручным шагом приёмки товара в стандартном процессе.

Триггерит ли отмена авто-возврат — решение конфигурации/расширения; в этом и опасность отмены-после-отгрузки. А RECEIVED может выставить любая интеграция: если фид ПВЗ/курьера сделает PATCH, выплата срабатывает без проверки содержимого, потому что нет встроенного гейта совпадения серийника/SKU между RECEIVED и шагом возврата, а шаг контроля качества, который заблокировал бы возврат, — выбор моделирования, а не дефолт. Платформа нативно не различает «курьер принял» и «склад верифицировал правильный товар» — оба схлопываются в RECEIVED. Исправление — двухфазный статус (CARRIER_ACCEPTED, информационный, без денег; RECEIVED_VERIFIED, содержимое склада + совпадение серийника, высвобождает деньги), обязательный блокирующий узел проверки содержимого перед шагом возврата и гейт возврата при отмене, который атомарно перепроверяет статус консигнации и превращает отмену после SHIPPED во флоу «возврат по прибытии».

Системы сканирования курьера и ПВЗ и их семантика событий, физическая станция осмотра на складе и внешний OMS, если он владеет отменой/расчётами. Эти фиды приходят асинхронно (SCPI/Kafka/impex) без гарантий порядка и exactly-once.

Как обнаружить. Проверьте XML-определение return-process на предмет того, стоит ли узел контроля качества/проверки перед шагом возврата или это прямое ребро RECEIVED → refund, и проверьте ProcessTaskLog на пропущенные шаги осмотра. Прогрепайте входящую интеграцию (SCPI iFlow / Kafka-консьюмер / impex), которая пишет ReturnStatus: если курьерский RETURN_ACCEPTED отображается в RECEIVED — это улика. По отмене-после-отгрузки найдите строки CancelRequestRecordEntry, чья консигнация уже SHIPPED, в корреляции с возвратами, датированными после отгрузки. Вне платформы: посылки, помеченные как принятые в ПВЗ, без соответствующей приёмки на складе в течение N дней.

Тест. Продвиньте консигнацию в SHIPPED, затем вызовите OrderCancelService.requestOrderCancel — срабатывает ли возврат немедленно, без перепроверки отзыва товара? Отдельно вбросьте синтетическое курьерское событие RETURN_ACCEPTED по открытой RMA и проверьте, прыгает ли ReturnStatus в RECEIVED и порождается ли разворот платежа без какой-либо записи о складской приёмке или контроле качества; затем доставьте несовпадающий SKU и посмотрите, отзывается ли что-нибудь обратно. Аудиторский вопрос: «Между статусом высвобождения денег и физическим событием — что проверяет, что получен правильный SKU/серийник? Назовите обязательный узел процесса и покажите возврат, заблокированный из-за проваленной проверки».

Как предотвратить. Привязывайте деньги только к верифицированной физической приёмке — явный статус RECEIVED_VERIFIED, выставляемый складом после совпадения SKU/серийника и состояния, с ребром возврата, исходящим оттуда, а не от курьерского скана. Пусть курьерские сканы обновляют видимый покупателю трекинговый статус, не несущий никакой расчётной силы. Моделируйте отмену-после-отгрузки как сагу: после SHIPPED возврат становится компенсирующим шагом, привязанным к возврату товара, с таймаутом, который эскалирует, а не платит автоматически. Принимайте события курьера/ПВЗ идемпотентно и обеспечивайте легальность переходов стейт-машины, чтобы поздний или дублирующий скан не мог заново запустить уже проведённый переход.

Претензии и обмены

Претензия «товар не доложен» плюс возврат остального

Эксплойт. Заказ из трёх строк — наушники (€220), SSD (€180), клавиатура (€140), итого €540 — едет одной посылкой; все три доезжают. Маркус открывает претензию в CRM: «SSD не было в коробке». Агент поддержки оформляет досыл по доброй воле (или кредит €180) без сверки с подтверждённой доставкой курьера. Через два дня Маркус приходит в магазин со всеми тремя товарами; POS сканирует штрихкод заказа и показывает три возвратные строки в заказанном количестве, и он возвращает все три за €540. За SSD платят дважды — раз досылом, раз внутри возврата €540. Чистая протечка €180 за цикл, и ничто не связывает претензию с возвратным количеством.

Механизм. Актор претензий считает «строка 2 до покупателя не дошла» и выдаёт компенсацию, не уменьшая того, что у покупателя ещё на руках. Актор возвратов считает «возвратное количество = заказано минус уже возвращено» и авторизует возврат по строке 2, потому что с точки зрения заказа её никогда не возвращали. Ни один не сверяется с истиной доставлено-за-вычетом-претензии. Претензия и возврат — сиблинги, которые никогда не видят друг друга.

Вердикт SAP Commerce.

Платформа моделирует заказанное количество (OrderEntry.quantity), сигнал доставки (ConsignmentEntry.shippedQuantity, ConsignmentStatus.DELIVERED) и граф возвратов — но OOTB возвратное количество считается как заказано минус уже возвращено (DefaultReturnService.getAllReturnableEntries / OrderReturnTool). Оно не вычитает количества, кредитованные по претензии о недостаче, и stock accelerator даже не требует жёстко консигнацию DELIVERED. Закрыть — кастомно: переопределите getAllReturnableEntries (или окружающий фасад), чтобы возвратное кол-во = доставлено − уже возвращено − заявлено/кредитовано-как-недостача; смоделируйте претензию как полноценный объект на заказе (MissingItemClaim, ссылающийся на строку заказа), учитываемый в расчёте; и трактуйте досыл как расходование права (обнулите возвратное кол-во исходной строки, перенесите возвратность на консигнацию-замену). Учтите: DELIVERED обычно отражает обновление от курьера/склада, а не поштучное подтверждение покупателя, что товар физически был в коробке.

Претензия WISMO/о недостаче обычно рождается во внешней CRM/CCaaS (Salesforce Service Cloud, Zendesk, Genesys), и кредит по доброй воле может быть выдан там и никогда не записан обратно в Commerce — это и есть ключевой разрыв: претензия живёт в системе, которую движок возвратов не читает. Магазинный POS-терминал и сверка веса посылки у курьера тоже внешние. (Если у вас SAP Order Management, а не stock accelerator, обработка DELIVERED и поведение возвратного количества богаче — проверьте свой OMS-фасад возвратов.)

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

SELECT {o.code}, {oe.entryNumber}, {re.receivedQuantity},
       {ce.shippedQuantity}, {c.status}
FROM   {ReturnEntry      AS re
        JOIN OrderEntry      AS oe ON {re.orderEntry}={oe.pk}
        JOIN Order           AS o  ON {oe.order}={o.pk}
        JOIN ConsignmentEntry AS ce ON {ce.orderEntry}={oe.pk}
        JOIN Consignment     AS c  ON {ce.consignment}={c.pk}}
WHERE  {c.status} <> ?delivered

Решающее соединение нельзя сделать внутри одной системы: выгрузите кредиты по доброй воле/досылы из CRM по коду заказа и найдите заказы, у которых есть и кредит по недостаче, и магазинный возврат по той же строке. Это соединение — улика, а его отсутствие в любой отдельной системе — и есть вся уязвимость.

Тест. Создайте заказ из трёх строк одной посылкой, отметьте консигнации DELIVERED, заведите претензию о недостаче по строке 2 в CRM и оформите досыл, затем попробуйте магазинный возврат всех трёх строк. Предлагает ли UI возвратов/POS строку 2 как возвратную в полном количестве? Если да — система доверяет заказанному количеству, а не доставленному-за-вычетом-претензии. Спросите архитектора: «Назовите единый сервис, вычисляющий возвратное количество, и покажите все входы, которые он читает. Приходит ли какой-то вход из системы претензий?». Если нет — дыра открыта по построению.

Как предотвратить. Сделайте один сервис источником истины по возвратности — возвратное кол-во = доставлено − уже возвращено − заявлено/кредитовано-как-недостача — и единственным путём, который вызывают и веб-, и POS-возвраты. Сохраняйте претензии в графе заказа и записывайте кредиты по доброй воле из CRM обратно синхронно (или блокируйте кредит, пока это не сделано). Трактуйте досыл как замену права. Гейтируйте возвраты по доставке, требуя одобрения супервайзера с кодом причины для строк без консигнации DELIVERED. Запускайте ночную сверку, связывающую кредиты CRM с возвратами Commerce по заказу + строке, с алертом до расчётов.

Обмен как способ «обналичиться»

Эксплойт. Елена покупает куртку A онлайн по промо: прайс €2 000, −40%, оплачено €1 200. Позже промо истекает; магазинная цена A снова €2 000. Она обменивает A в магазине на куртку B по магазинной цене €3 500. Правильная доплата от того, что она заплатила, — €3 500 − €1 200 = €2 300; но если обмен считает разницу от магазинной цены A, он берёт €3 500 − €2 000 = €1 500 — она недоплачивает €800. Обмен чеканит новый чек на B за €3 500: родословная скидки −40% пропадает, а окно возврата запускается заново. Она обменивает B на куртку C за €5 000, разница считается от чековой стоимости B (€5 000 − €3 500 = €1 500). Затем C, внутри своего свежего окна, возвращается за €5 000.

Проследим деньги: она платит €1 200 (A) + €1 500 (A→B) + €1 500 (B→C) = €4 200 внесено; ей возвращают €5 000 за C. Ритейлер в минусе на ~€800 и получает все три куртки обратно — и эти €800 — ровно та промо-стоимость, что была обналичена на переходе A→B (списание по базе €2 000 вместо оплаченной базы €1 200). Денежная цифра мала, но структурный ущерб — нет: скидка −40% была применена к товару, к которому она никогда не относилась, а окно возврата сброшено так, что не закрывается. Цепочка таких обменов — это неограниченная, отмывающая промо, вечно-возвратная машина.

Механизм. Два актора расходятся в идентичности и базе стоимости «заказа» при обмене. Исходный заказ (мир движка промо) несёт PromotionResult, оплаченную цену €1 200 и таймер окна возврата от исходной доставки. Чек обмена (мир POS/нового заказа) — совершенно новый заказ по текущей магазинной стоимости, без унаследованного промо, со свежим окном и базой стоимости, равной стоимости этого чека. Экономическая позиция покупателя в товаре смоделирована как два несвязанных заказа, поэтому заказ №2 отмывает товар начисто от истории скидок и истории «однажды возвращённого».

Вердикт SAP Commerce.

/ Промо (PromotionResult, PromotionOrderEntryConsumed) вычисляются против конкретного AbstractOrder и привязаны к нему — нет OOTB-понятия родословной промо, следующей за товаром в другой заказ. Оплаченная цена живёт на исходной строке заказа; новый заказ пересчитывается от текущих PriceRow и текущих промо. Критично: OOTB returns в SAP Commerce поддерживает возврат денег и скудную семантику замены; настоящий обмен, создающий заказ-замену на другой SKU с вычисленной разницей, почти всегда — кастомный флоу (или поставляется SAP OMS / POS-продуктом). Так что вся проблема родословной закрывается кастомно: дайте каждому заказу обмена/замены жёсткую ссылку на исходный заказ и строку; считайте разницу от исходной OrderEntry.totalPrice за вычетом PromotionOrderEntryConsumed, а не от текущей PriceRow; переносите политику родословной промо явно (переносить пропорционально или считать обмен по полной разнице без скидки — выберите одно, запретите путь молчаливого сброса); и наследуйте исходное начало окна возврата на корне цепочки, чтобы у каждого обменянного товара был один таймер. Магазинный POS — это там, где это реально ломается: если POS использует свой каталог и чеканит свой номер чека, он перебазирует цену и сбрасывает окно целиком вне видимости Commerce.

Как обнаружить. Если вы добавили ссылку originalOrder на заказах обмена, найдите заказы-потомки обмена, не несущие промо и запустившие свежий таймер:

SELECT {o.code}, {o.date}, {o.originalOrder}, {o.totalPrice}
FROM   {Order AS o}
WHERE  {o.originalOrder} IS NOT NULL
  AND  NOT EXISTS ({{ SELECT 1 FROM {PromotionResult AS pr}
                      WHERE {pr.order}={o.pk} }})

Любой потомок обмена с существенно большим итогом, чем у родителя, и без унаследованного промо — кандидат. Также запросите PromotionOrderEntryConsumed на скидки, «потраченные» на товар, которого у покупателя уже нет, и проаудируйте цепочки чеков POS со сбрасывающимися датами и без обратной ссылки на исходный веб-заказ. Если связки в цепочке нет вовсе — это отсутствие и есть находка.

Тест. Купите по промо −40% онлайн; после окончания промо обменяйте в магазине на более дорогой SKU, затем обменяйте ещё раз. Затем проверьте три вещи: считалась ли каждая доплата от изначально оплаченной цены или от текущей магазинной цены возвращаемого товара; несёт ли финальный заказ хоть какой-то след исходного промо; и какова дата начала окна возврата на финальном товаре — исходной покупки или последнего обмена? «Покажите ссылку чека обмена обратно на исходный веб-заказ и какое ценовое поле вычитает движок разницы». Нет обратной ссылки и текущее каталожное поле — значит, открыты обе протечки: перебазирование и сброс окна.

Как предотвратить. Трактуйте обмен как полноценную связанную транзакцию — а не как «возврат + новая продажа», потому что именно это разложение и рвёт родословную. Резолвите первый заказ в цепочке и для базы оплаченной цены, и для начала окна возврата; никогда не давайте им сброситься на переходе. Сделайте один сервис разницы — читающий оплаченную цену за вычетом потреблённых промо — единственным путём, который вызывают и POS, и веб, и ограничьте общее число обменов на цепочку. Где POS работает офлайн — сверяйте и отзывайте при синхронизации.

Что осталось за рамками десятки

Эти десять выбраны за то, насколько чисто они показывают паттерн «две системы, один объект, разное предположение», — а не потому, что исчерпывают тему. Несколько известных родственников, которым место в любом реальном аудите:

Каждый — той же формы, и каждый достоин строки в вашей собственной версии таблицы ниже.

Кто отвечает за исправление

Десять дыр распределены по трём вердиктам неравномерно, и само распределение — это и есть главный вывод. SAP Commerce Cloud силён в записи заказа — он фиксирует оплаченную цену, распределяет скидки, моделирует граф возвратов и результаты промо. Он слаб ровно там, где омниканальному бизнесу это нужнее всего: возврат оплаченной цены вместо дефолта по базовой цене, пересчёт условных промо при возврате, возвраты по нескольким инструментам, идентичность сериализованной единицы, межканальная блокировка возвратов, количество «доставлено-за-вычетом-претензии» и родословная обмена. А несколько самых дорогих дыр — вообще не его работа.

Дыра Платформа Кастом Не платформа
1 Возврат по полочной цене Фиксирует оплаченную цену в заказе Переопределить дефолт по базовой цене; резолвинг цены POS↔Commerce; RMA-first Каталог POS, кнопка override
2 Перераспределение корзинной скидки Хранит распределение скидки и PromotionResult Перезапуск движка промо на оставшуюся корзину Политика «закреплено однажды заработанным»
3 Отзыв лояльности / кэшбэка Может инициировать отзыв при возврате Интеграция пропорционального отзыва Реестры лояльности и кэшбэка
4 Неденежное → наличные Коллекция платёжных транзакций Реестр инструментов; возврат на исходный Подарочная карта, BNPL, PSP
5 Подмена единицы Идентичность по типу, варианты, кол-во в стоке Сериализация на фулфилменте; гейт по серийнику Физический осмотр, серийники в ERP
6 Комплекты / юрлица Модуль комплектов; оплаченная оценка Целостность комплекта и отзыв скидки при возврате Межфирменные расчёты, договоры
7 Двойной возврат (асинхронность) Стейт-машина статусов возврата Межканальная блокировка; единый реестр возвратов Рельсы POS, сеть чарджбэков
8 Выплата по сканированию Процессы отмены и возврата Гейт верифицированной приёмки; двухфазный статус Системы сканирования курьера/ПВЗ
9 «Недостача» + возврат Заказанное кол-во, статус доставки Возвратное кол-во «доставлено-за-вычетом-претензии» Реестр претензий CRM/CCaaS
10 Отмывание через обмен PromotionResult привязан к заказу Связанный обмен; наследование родословной и окна Чек/каталог обмена на POS

Два паттерна стоит проговорить прямо. Во-первых, колонка почти всегда один и тот же ход — назначить единый авторитетный сервис для спорной величины (возвратное количество, сумма возврата, разбивка по инструментам, идентичность единицы) и заставить каждый канал вызывать его. Во-вторых, колонка никогда не решается фичей платформы — она решается интеграционным контрактом с идемпотентной, пропорциональной, осведомлённой об инструментах, привязанной к верифицированной приёмке семантикой плюс ежедневной сверкой между реестрами, каждый из которых держит локально-верную часть истины.