Инженерные истории · 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
Дополнение к флагманскому кейсу Lumway, где один механизм разобран полностью: buy box подписки на странице товара.
Почему подписка была той системой, которую нужно было сделать правильно
Lumway продаёт жевательные добавки покупателям в США на Shopify. Каталог небольшой, а цена такая, что покупку обдумывают, поэтому экономика сильно завязана на повторные покупки. Разовый заказ жевательных добавок стоит малую долю того, что приносит подписчик за год. Подписка это движок удержания и большая часть пожизненной ценности клиента, из-за чего вариант с подпиской это самый коммерчески важный элемент управления на странице.
Ставка, к которой я постоянно возвращался, формулируется просто и легко ломается: предложение, которое видит покупатель, должно совпадать с предложением, по которому его списывают. Если страница показывает одну цену, а Recharge списывает другую, или покупатель выбирает набор из трёх банок, а списание идёт помесячно, магазин теряет заказ, стоящую за ним регулярную выручку и получает тикет в поддержку. На мобильных, куда приходит большая часть платного трафика Lumway, это доверие решается за несколько секунд внутри узкой вертикальной полосы (набор, подписка или нет, цена, экономия, добавить в корзину), и любой момент, когда цена выглядит неправильной, это момент, когда покупатель колеблется.
Поэтому задача была не "добавить подписки". Она звучала так: продавать наборы из 1, 2 и 3 банок и как разовую покупку, и как подписку, с ценой, которой покупатель может доверять, на странице, которая должна быть pixel-perfect.
Почему я не использовал виджет Recharge
Очевидный путь это виджет подписки Recharge. Он сделан ровно для этого, внедряется быстро, и для многих магазинов это правильный выбор. Для Lumway нет, по трём причинам, которые сводятся к той же ставке.
Он рендерится внутри 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 использовала модель "подарочные банки": различием между наборами были бесплатные банки и бонус бесплатной доставки, а маппинг плана был бинарным порогом (каждый набор помесячно или раз в два месяца, по настроенной границе). Она не вычисляла заранее цены подписки по наборам, и её события цены не несли тип покупки, так что карточки не могли перекрашивать свою экономию для подписки против разовой покупки. v2 (в проде) это настоящий маппинг по N месяцам, описанный выше, с шагом вниз, заранее вычисленным __lumSubByVariant и событиями цены, которые несут type, чтобы карточки перекрашивались правильно. Подарочные банки убрали, ценообразование по наборам пересчитали, добавили трёхмесячный план и смаппили на него наборы из трёх банок, и ту же логику распространили на остальные шаблоны товаров.
Часть, которую стоит подчеркнуть, это не изменение кода, а то, как я его выкатил. Перед переработкой я сделал снапшот v1 в замороженный файл (purchase-type-lum-v1.liquid) и отдельную git-ветку: явный путь отката, чтобы, если новая модель покажет себя хуже, возврат к старой был откатом, а не реконструкцией. Отношение к изменению ценового UX как к обратимому эксперименту, а не к двери в одну сторону, это разница между тем, чтобы менять живую поверхность выручки с уверенностью, и тем, чтобы делать это со скрещёнными пальцами.
Компромиссы, на которые я пошёл
Ни одно решение не бесплатно. Три издержки стоит назвать прямо.
Разбор текста регэкспом хрупок к нечисловым именам планов. Назови план "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, который записывает скрытый inputselling_planв ограниченную форму.assets/theme-loaders.js:Elixir_SetSubscriptionContext, точка входа слоя отображения, потребляющая синтетический контекст цены.
Нужен инженер Shopify, который работает по всему коммерческому пути?
Я работаю там, где сходятся UX витрины, продуктовая логика, ограничения платформы, измерения и доставка в продакшн.
Начать разговор