Skip to content

Внешние скидки

Внешняя скидка считает сумму на вашем URL. Магазин сам отправляет туда POST с JSON заказа, когда покупатель открывает корзину или меняет данные, от которых скидка зависит. В ответ скрипт возвращает сумму и название либо список ошибок.

Скидка задаётся в бэк-офисе: Клиенты → Скидки → Внешние скидки, адрес https://SHOP/admin2/external_discounts. Те же поля доступны через /admin/external_discounts.json.

На витрине строка скидки появляется в корзине и на оформлении. Название из ответа сохраняется в созданном заказе.

Настройка

У записи четыре рабочих поля:

  • url — адрес скрипта, http или https. Адрес из частной сети форма не сохраняет: и бэк-офис, и POST /admin/external_discounts.json отвечают 422 с {"url":["Недопустимый URL"]}. Повтор того же URL даёт 422 и {"url":["уже существует"]}.
  • description — название для бэк-офиса и запасное название скидки на витрине. Длиннее 200 символов не сохраняется: 422, {"description":["Описание слишком большой длины (не может быть больше, чем 200 символов)"]}.
  • order_lines_depend — пересчитывать скидку при изменении состава корзины.
  • observed_fields — id дополнительных полей заказа. Пока выбранное поле пустое, запрос не уходит. После заполнения в теле появляется fields_values, и новое значение поля вызывает новый запрос.
  • active — скидка включена. Выключенная запись запрос не отправляет.

В форме бэк-офиса это поля «URL», «Описание», «Зависит от состава корзины», «Зависит от дополнительных полей заказа» и «Включено». Галочка «Включено» стоит по умолчанию.

Если состав корзины не отмечен и ни одно выбранное поле не заполнено, скидка молчит: запроса нет и сумма корзины не меняется.

API

Авторизация та же, что у остального Admin API: HTTP Basic, логин — идентификатор приложения, пароль — пароль приложения в этом магазине.

GET    /admin/external_discounts.json
GET    /admin/external_discounts/:id.json
POST   /admin/external_discounts.json
PUT    /admin/external_discounts/:id.json
DELETE /admin/external_discounts/:id.json

Создание:

{
  "external_discount": {
    "url": "https://discount.example/calculate",
    "description": "Скидка по карте",
    "order_lines_depend": true,
    "observed_fields": [185],
    "active": true
  }
}

Ответ 201 возвращает запись с id. Обновление отвечает 200 и той же записью. Удаление отвечает 200 и {"status":"ok"}, следующий GET этой записи — 404.

Запрос

На url уходит POST. Заголовок Content-Type: application/json. Отдельной авторизации в этом запросе нет. Тело — заказ целиком, в том числе пока это ещё корзина.

У корзины id, number и key пустые. Состав лежит в order_lines. Сумму товаров для расчёта берите из строк: sale_price умножить на quantity. В повторном запросе items_price, total_price заказа и массив discounts уже могут содержать ранее применённую внешнюю скидку, а sale_price строки остаётся ценой до неё.

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

{
  "id": null,
  "number": null,
  "key": null,
  "currency_code": "RUR",
  "locale": "ru",
  "items_price": 266.0,
  "total_price": 266.0,
  "discount": null,
  "discounts": [],
  "fields_values": [],
  "client": {
    "id": null,
    "email": null,
    "phone": null,
    "registered": false
  },
  "order_lines": [
    {
      "title": "товар 2",
      "product_id": 569,
      "variant_id": 2176487162,
      "sku": null,
      "quantity": 1,
      "sale_price": 200.0,
      "full_sale_price": 200.0,
      "total_price": 200.0,
      "full_total_price": 200.0,
      "discounts_amount": 0.0,
      "vat": -1,
      "weight": null,
      "barcode": null,
      "unit": "pce"
    },
    {
      "title": "товар 6",
      "product_id": 601,
      "variant_id": 2176487170,
      "sku": null,
      "quantity": 1,
      "sale_price": 66.0,
      "full_sale_price": 66.0,
      "total_price": 66.0,
      "full_total_price": 66.0,
      "discounts_amount": 0.0,
      "vat": -1,
      "weight": null,
      "barcode": null,
      "unit": "pce"
    }
  ]
}

В полном теле рядом лежат доставка, оплата, адрес, shipping_address, источник визита и cookies. Для суммы скидки они не нужны. cookies в расчёт не берите и у себя не сохраняйте.

Когда заполнено дополнительное поле заказа, в том же JSON появляется:

"fields_values": [
  {
    "field_id": 185,
    "name": "Номер карты",
    "type": "Текст",
    "value": "CARD-77",
    "handle": null
  }
]

На оформлении в client уже лежат имя, почта и телефон, которые покупатель ввёл. У корзины до создания заказа client.id может быть пустым.

Ответ

Нужен HTTP 200 и JSON-объект.

Сумма в валюте магазина:

{
  "discount": 100,
  "discount_type": "MONEY",
  "title": "Скидка по карте"
}

На корзине из товаров на 200 ₽ и 66 ₽ эта скидка даёт строку «Скидка по карте» на 100 ₽ и итог 166 ₽.

Процент считается от суммы товаров и показывается в рублях, без копеек. Десять процентов на корзине 200 ₽ и две штуки по 66 ₽ (332 ₽) дают 33 ₽, итог 299 ₽:

{
  "discount": 10,
  "discount_type": "PERCENT",
  "title": "Десять процентов"
}

Если title не передать, на витрине показывается description из настройки скидки. Ответ {"discount": 20, "discount_type": "MONEY"} при описании «Самый дешёвый товар» даёт строку с этим названием и суммой 20 ₽.

Сумма больше стоимости товаров обрезается до неё. Денежная скидка 10000 ₽ на корзину 332 ₽ оставляет итог 0 ₽.

Ноль с названием оставляет строку и прежний итог. Покупатель видит «Название» и «0 ₽», сумма товаров не меняется:

{
  "discount": 0,
  "discount_type": "MONEY",
  "title": "Карта принята"
}

Ноль с пустым title строки не рисует.

Отказ — массив строк. Скидка не применяется, текст виден у поля промокода:

{
  "errors": ["Карта не найдена"]
}

В стандартной корзине тот же текст доступен как cart.discount_errors:

{% for error in cart.discount_errors %}
  <div class="discount-error">{{ error }}</div>
{% endfor %}

Пример: самый дешёвый товар

При двух и более позициях скидка равна стоимости самой дешёвой строки, sale_price * quantity. На одной позиции скидки нет.

import json


def discount_for(order):
    lines = order.get("order_lines") or []
    if len(lines) < 2:
        return {"discount": 0, "discount_type": "MONEY", "title": ""}
    cheapest = min(
        float(line["sale_price"]) * float(line["quantity"])
        for line in lines
    )
    return {
        "discount": cheapest,
        "discount_type": "MONEY",
        "title": "Самый дешёвый товар",
    }

Для корзины из товаров 200 ₽ и 66 ₽ ответ такой:

{
  "discount": 66.0,
  "discount_type": "MONEY",
  "title": "Самый дешёвый товар"
}

В корзине строка «Самый дешёвый товар» на 66 ₽, итог 200 ₽.

Когда уходит новый запрос

Ответ запоминается. Повторное открытие той же корзины новый POST не вызывает.

Галочка «Зависит от состава корзины» включает новый запрос при смене количества, варианта или набора позиций. Без этой галочки смена количества остаётся на прошлом ответе: сумма скидки не пересчитывается.

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

Выключенная скидка (active: false) в этот обход не входит: на её URL запрос не уходит, даже если состав корзины изменился.

На оформлении момент запроса зависит от версии. Это разобрано ниже.

Долгий ответ корзину не открывает. Код не 200 скидку пропускает, страница при этом открывается. Пустое тело и невалидный JSON вместо корзины показывают ошибку разбора JSON. Отдавайте объект, описанный выше, с кодом 200.

Дополнительное поле

Поле заказа создаётся в бэк-офисе и отмечается в «Зависит от дополнительных полей заказа». На витрине его значение уходит вместе с корзиной. Рабочая отправка — POST на /cart_items, потому что форма с method="put" сама по себе до сервера не доходит. У поля «Номер карты» с id 185 тело такое:

_method=put
order[fields_values_attributes][185][field_id]=185
order[fields_values_attributes][185][value]=CARD-42

Нужен и CSRF-токен страницы. После сохранения уходит новый POST на URL скидки, и в fields_values приходит введённое значение. Новое значение того же поля вызывает ещё один запрос и новую сумму.

На оформлении то же поле показывается покупателю. Смена значения и сохранение контактов снова вызывают скрипт. В созданном заказе значение поля остаётся. Его видно в бэк-офисе и в GET /admin/orders/:id.json, в fields_values.

Версия чекаута

Тело запроса, ответ и конкуренция с купоном от версии не зависят. Меняется только момент, когда магазин вызывает URL, и адрес страницы.

Третья версия — /gocheckout. Открытие и повторная загрузка этой страницы новый POST не отправляют, пока не изменились состав корзины и наблюдаемые поля. На странице остаётся скидка, уже посчитанная в корзине. Новый запрос уходит при смене состава корзины или значения наблюдаемого поля, в том числе при сохранении контактов.

Вторая версия — /new_order. Каждый заход на страницу отправляет POST заново, даже если корзина не менялась. В сводке видна скидка из этого запроса. Смена способа доставки на этой странице новый запрос не вызывает. Кнопка «Подтвердить заказ» отправляет POST ещё раз, уже с контактами и выбранной доставкой.

Заказ хранит скидку одинаково на обеих версиях. Доставка в скидку не входит: товар 200 ₽ со скидкой 40 ₽ и доставкой 300 ₽ даёт товар 160 ₽, доставку 300 ₽ и сумму заказа 460 ₽. В GET /admin/orders/:id.json в discounts лежат description, amount и type_id 2. items_price и total_price уже с учётом скидки.

Корзина, оформление и заказ

Денежная скидка 100 ₽ на товарах 200 ₽ и 66 ₽ даёт название и итог 166 ₽. Десять процентов на товарах 332 ₽ дают строку 33 ₽ и итог 299 ₽. Текст из errors показывается под полем промокода. Нулевая скидка с названием остаётся строкой «0 ₽».

На оформлении видны та же строка и дополнительное поле. В заказе сохраняются название из title и итог с учётом скидки. Для товаров на 398 ₽ и ответа discount: 25, discount_type: "MONEY" итог заказа — 373 ₽. В личном кабинете у заказа есть строка скидки и значение дополнительного поля.

GET /admin/orders/:id.json отдаёт скидку в discounts: description — название из title, discount и amount — сумма, type_id 2 — денежная скидка. type_id 1 — процент.

Несколько скидок

Внешние скидки между собой и с купоном выбирают одну, с большей суммой. Вторая рядом не прибавляется.

Если включены две внешние скидки, запрос уходит на оба URL. В корзине остаётся одна, с большей суммой.

Купон с большей суммой заменяет внешнюю скидку. Купон с меньшей суммой в поле промокода сохраняется, в итоге остаётся внешняя скидка.

Если одна скидка возвращает errors, а другая — сумму, применяется сумма. Текст ошибки при этом виден у промокода.