Перейти к содержимому

Инженерные истории · 01

Собственный buy box подписки на нативных selling plans

  • Selling plans
  • Recharge

Строю buy box с выбором подписка/разовая покупка на нативных selling plans Shopify, с Recharge в роли обработчика ниже по потоку, чтобы цена, которую видит покупатель, всегда была ценой, по которой его списывают.

Shopify OS 2.0 · Native selling plans · Recharge as processor · Vanilla JS · Subscription commerce

Дополнение к флагманскому кейсу, где один механизм разобран полностью: buy box подписки на странице товара.


Почему подписка была той системой, которую нужно было сделать правильно

Это американский wellness-бренд добавок, продающий напрямую потребителю на Shopify. Каталог небольшой, а цена такая, что покупку обдумывают, поэтому экономика сильно завязана на повторные покупки. Разовый заказ стоит малую долю того, что приносит подписчик за год. Подписка это движок удержания и большая часть пожизненной ценности клиента, из-за чего вариант с подпиской это самый коммерчески важный элемент управления на странице.

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

Поэтому задача была не "добавить подписки". Она звучала так: продавать наборы из 1, 2 и 3 банок и как разовую покупку, и как подписку, с ценой, которой покупатель может доверять, на странице, которая должна быть pixel-perfect.


Почему я не использовал виджет Recharge

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

Он рендерится внутри shadow DOM, границы, сквозь которую типографика, отступы и правила взаимодействия темы не проходят, а pixel-perfect был здесь жёстким требованием, а не пожеланием. Он владеет собственным состоянием цены и плана, но в buy box уже было два других элемента управления, которые двигают цену (селектор набора 1/2/3 банки и отображение цены за банку против общей цены набора), и мне нужно было, чтобы видимая цена, выбранный набор и списываемый план шли синхронно, пока покупатель переключается. Передать состояние плана компоненту за границей shadow DOM означало согласовывать два источника истины на каждом взаимодействии. И он подтягивает собственный JavaScript Recharge, который я не хотел добавлять на самую важную мобильную страницу магазина ради компонента, который всё равно собирался переоформить.

Инсайт, который открыл альтернативу: Recharge не нужен его виджет, чтобы делать свою работу. Recharge делает заказ регулярным, когда в строке есть нативный Shopify selling_plan. Это весь контракт. Запиши правильный selling_plan в строку корзины, и checkout остаётся на Shopify, заказ создаётся нативно, а Recharge подхватывает его ниже по потоку как обработчик. Виджет это один из способов получить это свойство строки. Не единственный.


Ограничения, под которые я проектировал

  • Pixel-perfect к дизайну, включая карточку подписки и бейдж экономии.
  • Один buy box, три подвижные части. Селектор набора, тип покупки и добавление в корзину не разделяют DOM, но должны сходиться в цене в каждый момент. Контракт, который держит их согласованными, это deep dive 02.
  • Редактируемое мерчантом, ничего не захардкожено. Контент, цены и планы живут в конфигурации.
  • Переживать переконфигурацию Recharge. Переименование, удаление или пересоздание плана никогда не должно требовать деплоя кода.
  • Никакого нового вендорского JavaScript в buy box.

Архитектура, которую я выбрал

Полностью собственный UI подписка/разовая покупка, построенный на нативных Shopify selling plans, с Recharge в роли обработчика и checkout на Shopify. Никакого Recharge JS или SDK нигде в buy box. Единственное присутствие Recharge в репозитории это немного CSS, переоформляющего iframe его клиентского портала, косметика, не связанная с этим боксом.

Элемент управления типом покупки рендерит две radio-карточки, разовая покупка и подписка. Карточка подписки появляется только когда у товара действительно есть планы:

{% if product.selling_plan_groups.size > 0 %}
  {# render the subscribe card #}
{% endif %}

У каждого товара три плана: 1-месячный, 2-месячный и 3-месячный, со скидкой 15%. Эти 15% не промокод и не число, которое я вписал в JavaScript. Оно живёт внутри selling plan как корректировка цены, и UI считывает его обратно.

Бокс общается с остальным buy box через глобальные переменные window и DOM-события, а не через общий DOM: разрешая состояние подписки, он публикует window.__lumResolvedSub (план и цена для выбранного набора) и заранее вычисляет window.__lumSubByVariant (цена подписки каждого набора), так что карточки наборов рисуют свои бейджи экономии, а sticky-бар показывает ту же цену, не пересчитывая её. Это разделение описано в deep dive 02.


Сложная часть: разрешение плана без захардкоженных ID

Центр этой системы одна функция, resolvePlanForVariant(). Её задача звучит тривиально, но таковой не является: по набору, который выбрал покупатель, вернуть правильный план подписки и правильную цену подписки.

Наивная версия хардкодит его. Набор один использует план 680..., набор два план 681..., и так далее. Это работает до первого раза, когда кто-то в Recharge переименует план, удалит и пересоздаст его (что порождает новый ID) или переупорядочит группу. Тогда buy box указывает на мёртвый ID, и никто этого не замечает, пока подписчику не выставят неправильный счёт. На живом магазине, который редактируют не инженеры, захардкоженные ID это бомба замедленного действия.

Поэтому маппинг выводится из текста плана, а не ID, в три шага.

Позиция набора превращается в нужные месяцы. Набор из N банок должен подписываться на N-месячный план. Позиция это индекс варианта, variantIndex(v) + 1: первый набор хочет один месяц, второй два, третий три.

Текст плана превращается в интервал. Для любого плана я собираю haystack из всего, что на нём можно прочитать (значения опций, имена опций и имя плана), затем вытаскиваю первое число, похожее на количество месяцев:

var m = hay.match(/(\d+)\s*month/i) || hay.match(/(\d+)/);
return m ? parseInt(m[1], 10) : null;

План с именем "3 Month" разрешается в months = 3, как и "Every 3 months" или "3-month delivery". ID плана никогда не участвует в решении.

Месяцы превращаются в план. planForMonths(n) сканирует группу и возвращает план, чей выведенный из текста интервал равен n.

Этот выбор "текст, а не ID" несущий. Мерчант может переименовать, удалить или пересоздать план в Recharge, и пока в нём по-прежнему сказано, на сколько месяцев он повторяется, бокс продолжает правильно маппить наборы без изменения кода. Маппинг переживает ровно ту операцию, которая ломает захардкоженную интеграцию.

Аккуратный шаг вниз, когда плана нет

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

for (var mm = pos; mm >= 1 && !plan; mm--) plan = planForMonths(mm);
if (!plan) { var ks = Object.keys(plansById); if (ks.length) plan = plansById[ks[0]]; }

Он пробует точный интервал, затем спускается по одному месяцу за раз, и только если вообще ничего не совпало, откатывается к первому плану в группе. Отсутствующий план деградирует к разумному соседу, а не к мёртвому элементу управления.

Два режима и запасной выход

Резолвер работает в одном из двух режимов, считываемом из data-атрибута. mapped (по умолчанию) это логика "позиция в интервал" выше. auto читает собственные selling_plan_allocations варианта, то, что Recharge прикрепляет, когда планы назначены на уровне варианта, и использует их напрямую. Две необязательные настройки схемы, monthly_plan_id и bimonthly_plan_id, явно закрепляют позиции один и два: запасной выход для товара с нечисловыми именами планов, по умолчанию выключен и используется только когда путь через текст не справляется.

Читать скидку, никогда её не записывать

Цена подписки берётся из собственной корректировки цены плана, какого бы типа она ни была:

if (a.value_type === 'percentage')   return Math.round(price * (1 - a.value / 100));
if (a.value_type === 'fixed_amount') return Math.max(0, price - a.value);
if (a.value_type === 'price')        return a.value;

Эти 15% читаются из value_type: 'percentage' на живом плане. Смени предложение на 20% или фиксированную цену, и бокс следует за этим без деплоя. В скидке нет ничего захардкоженного, что оставляет мерчанту контроль над собственным ценообразованием.


Предотвращение двойной скидки

Вот баг, который никогда не попал в прод, потому что архитектура его предотвратила.

У buy box есть слой отображения, который форматирует цены для карточек, кнопки и sticky-бара, и он умеет применять корректировку цены плана. Но к моменту, когда цена подписки доходит до этого слоя, resolvePlanForVariant() уже применил 15%. Если бы слой отображения снова применил процент плана, покупатель увидел бы скидку 15% от уже уценённого числа, и цена ползла бы вниз на каждом рендере.

Исправление это синтетический контекст цены. Когда выбрана подписка, я передаю слою отображения сфабрикованный объект плана, который несёт разрешённую цену как абсолютное значение, а не процент:

var synthetic = { id: r.planId, priceAdjustments: [{ value_type: 'price', value: r.subPrice }] };
window.Elixir_SetSubscriptionContext(r.planId, v, synthetic);

Поскольку синтетическая корректировка это value_type: 'price', слой отображения возвращает значение дословно (ветка return a.value выше) и не может повторно наложить процент сверху. Цена вычисляется один раз, в резолвере, и каждый нижестоящий потребитель считает её финальной. Два пути кода, одно число.


Как selling_plan доходит до checkout

Всё это разрешение не имеет значения, если план не попадает в заказ. Вот где ставка на нативные selling plans окупается: контракт с checkout это единственный скрытый input.

Компонент добавления в корзину рендерит настоящую форму товара Shopify и помечает её классом:

{%- form 'product', product, class: 'product-buy-form-pdp ...' -%}
  <input type="hidden" name="selling_plan" value="">

Когда покупатель выбирает подписку, небольшой хелпер записывает разрешённый ID плана в этот input, ограниченный собственной формой buy box и никакой другой:

var form = document.querySelector('form[action*="cart/add"].product-buy-form-pdp');
var input = form.querySelector('input[name="selling_plan"]'); // created if missing
input.value = String(sellingPlanId);

Добавление в корзину это AJAX-отправка: она собирает FormData из этой формы и делает POST на /cart/add.js. Поскольку поле selling_plan находится внутри формы, оно едет вместе с payload, и строка создаётся против управляемого Recharge плана, который Recharge затем делает регулярным. Переключение обратно на разовую покупку очищает input, так что разовая строка не несёт плана и никогда не повторяется. Ограничение формы (почему это .product-buy-form-pdp, а не запрос по всему документу) существует потому, что вторая, скрытая форма товара на лендингах хватала неправильный input. Эта история живёт в deep dive 02.

Результат это бокс подписки с нулём кода Recharge внутри, который всё равно правильно управляет Recharge, потому что говорит на собственном языке платформы.


Разовая покупка против подписки, и два вида цены

Бокс держит два состояния цены и два вида, и все четыре должны оставаться согласованными. Состояния это разовая покупка и подписка. Переключение пересчитывает и перекрашивает бейдж экономии для каждого, потому что математика различается (набор "купи 2 получи 1" экономит иначе, чем подписка). Виды это цена за банку и общая цена набора: карточки показывают цену за банку, потому что покупатель, сравнивающий наборы, хочет видеть, как единица дешевеет по мере роста набора, тогда как кнопка и sticky-бар показывают общую цену набора, потому что именно она списывается. Держать карточки "за банку" и кнопку "цена набора" в согласии между обоими состояниями это ровно та причина, по которой цена подписки для каждого набора вычисляется заранее, а не выводится ad hoc в трёх местах.


v1 и v2: две коммерческие модели на одной поверхности выручки

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

Что продавала v1

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

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

Тяжёлой v1 делала не модель, а обвязка вокруг неё, и вся она была в руках магазина. Каждая карточка набора настраивалась в редакторе темы: платные банки, бесплатные банки, подпись, подзаголовок, необязательный бейдж вроде «Best Deal» или «Family Pack» и строка экономии, которую можно было вписать руками или посчитать автоматически: цена сравнения, умноженная на общее число банок. Карточка подписки несла до шести строк преимуществ, у каждой свой флажок «только для наборов»: бесплатная доставка показывалась на двух и трёх банках и сама пряталась на одной. У резолвера планов был переключатель режима, настраиваемый порог, явные ID планов как переопределение и формат подписи интервала. Магазин мог переоценить всё предложение без разработчика. Цена этому: каждая из настроек должна была сходиться со всеми остальными в трёх сниппетах, которые не делят состояние.

Почему модель поменяли

Решение сменить модель принимал магазин, не я, и речь шла о риске, а не о коде. Бесплатные банки это реальный товар, который уезжает со склада; команда не хотела и дальше его раздавать и выбрала модель с предсказуемой маржой. Моё мнение было другим: у v1 крючок сильнее. Бесплатная банка это самое понятное предложение, какое можно дать покупателю, и конвертировать она вполне могла лучше. Я это сказал, а потом собрал то, что магазин попросил. Подарочные банки убрали, ценообразование по наборам пересчитали, добавили трёхмесячный план и сопоставили с ним наборы из трёх банок, ту же логику распространили на остальные шаблоны товаров. Новая модель проще формулируется: набор из N банок подписывается на N-месячный план, и следующее списание приходит, когда запас кончается.

Что v2 починила по дороге

Ничто из этого не было причиной смены модели. Это то, что я исправил, пока пересобирал компонент, и поэтому v2 лучше как инженерия, даже если v1 была лучше как предложение.

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

События цены не несли тип покупки. Событие говорило "цена изменилась", но не говорило, разовая это покупка или подписка. У набора и у подписки математика экономии разная, а данных, чтобы выбрать нужную, у карточки не было, поэтому бейдж не перекрашивался. В v2 событие несёт type.

Сопоставление было порогом, а не отображением. Бинарная граница работает ровно до первого нового плана. Как только появился трёхмесячный, порог перестал описывать реальность. Маппинг по числу месяцев, выведенному из текста плана, пережил это изменение без единой правки в коде.

Как переключение сделали обратимым

Перед переработкой я снял снимок v1 в замороженный файл purchase-type-lum-v1.liquid и отдельную git-ветку. Это явный путь отката: если новая модель покажет себя хуже на живом трафике, возврат к старой становится откатом, а не реконструкцией по памяти. Разница между этими двумя словами измеряется днями простоя на странице, которая приносит деньги.

Две оговорки, без которых картина будет слишком гладкой. Замороженный файл не поддерживается: соседние сниппеты уезжают вперёд, и через несколько месяцев откат перестанет быть бесплатным, то есть у пути отката есть срок годности. И смену модели мы не мерили как эксперимент: это была замена, а не A/B на одном трафике, поэтому утверждать, что v2 продаёт лучше именно из-за модели, я не могу. Подарочная модель вполне могла продавать больше. Именно поэтому v1 заморожена, а не удалена: если магазин решит, что бесплатные банки стоят своих денег, возврат это один откат.

Замороженная v1 теперь лежит рядом с живой v2 в открытых выдержках кода: purchase-type-v1.liquid и purchase-type.liquid в папке buy-box.

Что бы я сделал иначе

v1 и так была настраиваемой до последнего винта, поэтому урок не в том, чтобы «вынести в настройки». Ни одна настройка не могла поменять форму правила: наборы определялись подарками, интервалы порогом. Смена формы означала релиз. В следующий раз я бы описал само правило как данные: набор сопоставлен с планом, а подарки это необязательное свойство набора. Тогда подарочная и интервальная модели это две строки одной таблицы, а не две версии компонента.


Компромиссы, на которые я пошёл

Ни одно решение не бесплатно. Три издержки стоит назвать прямо.

Разбор текста регэкспом хрупок к нечисловым именам планов. Назови план "Monthly" без цифры, и регэксп интервала не находит ничего, поэтому резолвер откатывается. Это реальная хрупкость, смягчённая переопределениями monthly_plan_id / bimonthly_plan_id, но смягчение ручное. Ставка в том, что числовые имена это норма, а переопределение покрывает исключения.

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

Связанность через глобальные переменные чувствительна к таймингу. Три компонента координируются через глобальные переменные window и события, поэтому порядок инициализации важен, а поздние сторонние скрипты или перерисовки Theme Editor могут вмешаться. Бокс переинициализируется с небольшим временным разбросом и по событию загрузки секции Shopify, чтобы оставаться корректным через них. Это работает, но это координация, которую платформа не обеспечивает.

Ни одно из этого не спрятано. Это цена, которую я заплатил за бокс, который pixel-perfect, не несёт вендорского JS и переживает правки Recharge, и я снова пошёл бы на тот же обмен.


Как я это проверял

Бокс проверяется от начала до конца в чистом headless Chrome с харнессом на Puppeteer, а не кликаньем в собственном браузере (в моём профиле стоит расширение, которое мешает выдвижной корзине, так что ручное тестирование там врёт). Харнесс проходит всю матрицу, каждый набор умножить на разовую покупку и подписку, проверяя, что форма несёт правильный вариант, что разрешённый selling_plan присутствует и верен, и что цены сходятся до цента между карточками, кнопкой и sticky-баром. Он гоняется на странице товара и на лендингах, где размещён тот же бокс, давая мне проверенную техническую уверенность: добавление в корзину записывает правильный план, двойной скидки не возникает, и четыре поверхности цены никогда не расходятся.

Миграция живых подписчиков магазина на Recharge, вторая половина истории с Recharge, имеет собственное описание в deep dive 04.


Итог и влияние на бизнес

Проверенный технический итог: buy box подписки на нативных selling plans, с маппингом "набор в интервал", разрешаемым из текста плана и без захардкоженных ID планов, без JavaScript Recharge в боксе, без двойной скидки и с корректным selling_plan, доходящим до checkout, так что Recharge делает заказ регулярным, всё подтверждено в изолированном харнессе.

Ожидаемую бизнес-ценность я формулирую как защиту и возможности, а не как опубликованную цифру: коммерческие показатели магазина принадлежат бренду и находятся под NDA. Бокс защищает то, от чего зависит выручка подписки: цена, которую видит покупатель, это цена, которую списывает Recharge, а набор, который он выбирает, это тот ритм, который он получает. Это совпадение снижает риск ошибок регулярных заказов и оттока, которые тихо обескровливают подписочный бизнес. Чтение скидки из плана позволяет операционной команде менять предложение без инженера, а вывод маппинга из текста плана держит бокс в рабочем состоянии через рутинные правки Recharge, которые иначе стали бы инцидентами в проде.


Что я возьму в следующую сборку

Читай конфигурацию, не зашивай её. Что делает этот бокс живучим, так это то, что он выводит маппинг "набор в план" из данных, которыми управляет мерчант (текст плана и корректировки цены), а не из констант, которыми управляет инженер. Он переживает правки, ломающие захардкоженные интеграции, и возвращает контроль над ценообразованием людям, которые владеют предложением.

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

Говори на контракте платформы, а не на SDK вендора. Архитектура опирается на один факт: Recharge делает регулярным всё, что несёт нативный selling_plan. Построение под этот контракт вместо виджета купило pixel-perfect контроль, ноль добавленного вендорского JavaScript и нативный checkout, ценой правильной записи одного скрытого input. А то, что v1 сначала заморозили, сделало изменение живой поверхности выручки обратимым экспериментом, что для всего, что касается денег, ответственный вариант по умолчанию.


Технические ссылки

Файлы, из которых состоит эта система:

  • snippets/purchase-type-lum.liquid: карточки подписка/разовая покупка и резолвер (resolvePlanForVariant, intervalMonthsFromPlan, planForMonths, applyAdjustment, синтетический контекст).
  • snippets/purchase-type-lum-v1.liquid: замороженная модель v1 "подарочные банки", сохранённая со своей git-веткой как путь отката.
  • snippets/bundle-selector-lum.liquid: селектор набора 1/2/3 банки, который устанавливает вариант и публикует window.__lumBundlePack.
  • snippets/subscription-plans-data.liquid: данные планов, питающие бокс.
  • snippets/add-to-cart-lum.liquid: настоящая форма товара (.product-buy-form-pdp), AJAX-добавление и sticky-бар цены.
  • assets/product-form-controller.js: Elixir_SetProductFormSellingPlan, который записывает скрытый input selling_plan в ограниченную форму.
  • assets/theme-loaders.js: Elixir_SetSubscriptionContext, точка входа слоя отображения, потребляющая синтетический контекст цены.
КодОткрыть обезличенный исходник, о котором эта статьяНазад к кейсуWellness DTC-бренд

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

Пришлите задачу и цель. Дальше разберусь в коде, доведу работу до релиза и проверю сам.

Начать разговор