Adding selling plan options to your Shopify product pages
This guide will help you integrate Selling Plan options into your Shopify store’s product pages and add an additional checkout button for SEPA payment methods for subscriptions.
Before you begin
First, follow the setup guide to connect your store to Firmhouse
Let's get started
This article is based on Dawn theme v15. If you are using another theme or version, the steps might be slightly different.
To update your theme without affecting the live version, duplicate it, make your changes, and publish the updated version when ready.

Add the Selling Plan Selector to Your Product Page Template

Create
/snippets/selling-plans-integration.liquid
. This snippet defines the selling plan selector.
/snippets/selling-plans-integration.liquid
{% assign firmhouse_app_id = '4631435' %}
{% if product.selling_plan_groups.size > 0 %}
{% liquid
assign app_string = 'app--' | append: firmhouse_app_id | append: '--firmhouse'
assign native_selling_plans = shop.metaobjects[app_string].subscriptions.selling_plan_mapping.value | map: 'native'
assign current_variant = product.selected_or_first_available_variant | default: product.variants.first
%}
<div class="selling_plan_app_container" data-section-id="{{ section.id }}">
<script src="{{ 'selling-plans-integration.js' | asset_url }}" defer></script>
<style>
.selling_plan_theme_integration--hidden {
display: none;
}
.selling_plan_app_plans_list {
list-style: none;
padding: 0;
margin: 0;
}
</style>
{% for variant in product.variants %}
{% liquid
assign variant_price = variant.price | money_with_currency | escape
assign variant_compared_at_price = variant.compare_at_price | money_with_currency | escape
%}
{% if variant.selling_plan_allocations.size > 0 %}
<section
data-variant-id="{{ variant.id }}"
class="selling_plan_theme_integration {% if variant.id != current_variant.id %}selling_plan_theme_integration--hidden{% endif %}"
>
<fieldset>
<legend>
Frequency
</legend>
<div>
<div>
<ul class="selling_plan_app_plans_list">
{% unless product.requires_selling_plan %}
<li>
<input
id="sp-one-time-purchase"
aria-label="One-time purchase. Product price {{ variant_price }}"
type="radio"
name="purchaseOption_{{ section.id }}_{{ variant.id }}"
{% if variant.available == false %}
disabled
{% endif %}
id="{{ section.id }}_one_time_purchase"
data-radio-type="one_time_purchase"
data-variant-id="{{ variant.id }}"
data-variant-price="{{ variant_price }}"
data-variant-compare-at-price="{{ variant_compared_at_price }}"
checked
>
<label for="sp-one-time-purchase">One-time purchase</label>
</li>
{% endunless %}
{% assign group_ids = variant.selling_plan_allocations | map: 'selling_plan_group_id' | uniq %}
{% for group_id in group_ids %}
{% liquid
assign group = product | map: 'selling_plan_groups' | where: 'id', group_id | first
assign allocations = variant | map: 'selling_plan_allocations' | where: 'selling_plan_group_id', group_id
if forloop.first
assign first_selling_plan_group = true
else
assign first_selling_plan_group = false
endif
%}
{% for allocation in allocations %}
{% assign selling_plan_id = 'gid://shopify/SellingPlan/' | append: allocation.selling_plan.id %}
{% unless native_selling_plans contains selling_plan_id %}
{% continue %}
{% endunless %}
{% liquid
if forloop.first and product.requires_selling_plan and first_selling_plan_group
assign plan_checked = 'checked'
else
assign plan_checked = null
endif
assign allocation_price = allocation.price | money_with_currency | escape
assign allocation_compared_at_price = allocation.compare_at_price | money_with_currency | escape
assign input_id = 'sp-' | append: variant.id | append: '-' | append: allocation.selling_plan.id
%}
<li>
<input
id="{{ input_id }}"
type="radio"
{% if variant.available == false %}
disabled
{% endif %}
aria-label="{{ allocation.selling_plan.name }}. Product price {{ allocation_price }}"
name="purchaseOption_{{ section.id }}_{{ variant.id }}"
data-radio-type="selling_plan"
data-selling-plan-id="{{ allocation.selling_plan.id }}"
data-selling-plan-group-id="{{ section.id }}_{{ group_id }}_{{ variant.id }}"
data-selling-plan-adjustment="{{ allocation.selling_plan.price_adjustments.size }}"
data-variant-price="{{ allocation_price }}"
data-variant-compare-at-price="{{ allocation_compared_at_price }}"
{{ plan_checked }}
>
<label for="{{ input_id }}">
{{ allocation.selling_plan.name }}
</label>
</li>
{% endfor %}
{% endfor %}
</ul>
</div>
</div>
</fieldset>
</section>
{% endif %}
{% endfor %}
</div>
<input
name="selling_plan"
class="selected-selling-plan-id"
type="hidden"
>
{% endif %}
Create
/assets/selling-plans-integration.js
. This file contains the logic for the selling plan selector.
/assets/selling-plans-integration.js
const hiddenClass = 'selling_plan_theme_integration--hidden';
class SellingPlansWidget {
constructor(sellingPlansWidgetContainer) {
this.enablePerformanceObserver();
this.sellingPlansWidgetContainer = sellingPlansWidgetContainer;
this.appendSellingPlanInputs();
this.updateSellingPlanInputsValues();
this.listenToVariantChange();
this.listenToSellingPlanFormRadioButtonChange();
this.updatePrice();
}
get sectionId() {
return this.sellingPlansWidgetContainer.getAttribute('data-section-id');
}
get shopifySection() {
return document.querySelector(`#shopify-section-${this.sectionId}`);
}
/*
We are careful to target the correct form, as there are instances when we encounter an installment form that we specifically aim to avoid interacting with.
*/
get variantIdInput() {
return (
this.addToCartForms[1]?.querySelector(`input[name="id"]`) ||
this.addToCartForms[1]?.querySelector(`select[name="id"]`) ||
this.addToCartForms[0].querySelector(`input[name="id"]`) ||
this.addToCartForms[0].querySelector(`select[name="id"]`)
);
}
get priceElement() {
return this.shopifySection.querySelector('.price');
}
get comparedAtPrice() {
return this.shopifySection.querySelector('.price__sale');
}
get visibleSellingPlanForm() {
return this.shopifySection.querySelector(
`section[data-variant-id^="${this.variantIdInput.value}"]`,
);
}
get isVariantAvailable() {
return this.selectedPurchaseOption.getAttributeNames().includes('disabled');
}
get sellingPlanInput() {
return this.shopifySection.querySelector('.selected-selling-plan-id');
}
get addToCartForms() {
return this.shopifySection.querySelectorAll('[action*="/cart/add"]');
}
/*
To enable the addition of a selling plan to a cart, it's necessary to include an input with the name "selling_plan", which will carry the selling ID as its value. When a buyer clicks on 'add to cart', the appropriate selling plan ID is added to their cart.
*/
appendSellingPlanInputs() {
this.addToCartForms.forEach((addToCartForm) => {
addToCartForm.appendChild(this.sellingPlanInput.cloneNode());
});
}
showSellingPlanForm(sellingPlanFormForSelectedVariant) {
sellingPlanFormForSelectedVariant?.classList?.remove(hiddenClass);
}
hideSellingPlanForms(sellingPlanFormsForUnselectedVariants) {
sellingPlanFormsForUnselectedVariants.forEach((element) => {
element.classList.add(hiddenClass);
});
}
/*
Each product variant comes with a selling plan selection box that the buyer can interact with.
When a buyer chooses a different variant, we ensure that only the relevant selling plan selection box is displayed.
This guarantees that only the selling plan associated with the selected variant is shown.
*/
handleSellingPlanFormVisibility() {
const sellingPlanFormForSelectedVariant = this.shopifySection.querySelector(
`section[data-variant-id="${this.variantIdInput.value}"]`,
);
const sellingPlanFormsForUnselectedVariants =
this.shopifySection.querySelectorAll(
`.selling_plan_theme_integration:not([data-variant-id="${this.variantIdInput.value}"])`,
);
this.showSellingPlanForm(sellingPlanFormForSelectedVariant);
this.hideSellingPlanForms(sellingPlanFormsForUnselectedVariants);
}
handleVariantChange() {
this.handleSellingPlanFormVisibility();
this.updateSellingPlanInputsValues();
this.listenToSellingPlanFormRadioButtonChange();
}
/*
The functions listenToVariantChange() and listenToAddToCartForms() are implemented to track when a product variant is altered or when the product form is updated.
The identification of the variant is crucial as it dictates which selling plan box should be displayed.
*/
listenToVariantChange() {
this.listenToAddToCartForms();
if (this.variantIdInput.tagName === 'INPUT') {
const variantIdObserver = new MutationObserver((mutationList) => {
mutationList.forEach((mutationRecord) => {
this.handleVariantChange(mutationRecord.target.value);
});
});
variantIdObserver.observe(this.variantIdInput, {
attributes: true,
});
}
}
listenToAddToCartForms() {
this.addToCartForms.forEach((addToCartForm) => {
addToCartForm.addEventListener('change', () => {
this.handleVariantChange();
});
});
}
get regularPriceElement() {
return this.shopifySection.querySelector('.price__regular');
}
get salePriceElement() {
return this.shopifySection.querySelector('.price__sale');
}
get salePriceValue() {
return this.salePriceElement.querySelector('.price-item--sale');
}
get regularPriceValue() {
return this.salePriceElement.querySelector('.price-item--regular');
}
get sellingPlanAllocationPrice() {
return document.getElementById(
`${this.selectedPurchaseOption.dataset.sellingPlanGroupId}_allocation_price`,
);
}
get selectedPurchaseOptionPrice() {
return this.selectedPurchaseOption.dataset.variantPrice;
}
get selectedPurchaseOptionComparedAtPrice() {
return this.selectedPurchaseOption.dataset.variantCompareAtPrice;
}
get price() {
return this.sellingPlanAllocationPrices.price ?? null;
}
/*
We aim to ascertain whether a compared price exists, which would indicate that the currently selected input has a discount applied to it.
If a discount is detected, the discounted price is displayed; otherwise, the regular price is shown.
*/
updatePrice() {
if (
!this.selectedPurchaseOptionComparedAtPrice ||
this.selectedPurchaseOptionComparedAtPrice ===
this.selectedPurchaseOptionPrice
) {
this.showRegularPrice();
this.hideSalePrice();
this.priceElement.classList.remove('price--on-sale');
} else {
this.showSalePrice();
this.hideRegularPrice();
this.priceElement.classList.add('price--on-sale');
}
}
hideSalePrice() {
this.salePriceElement.style.display = 'none';
}
hideRegularPrice() {
this.regularPriceElement.style.display = 'none';
}
showRegularPrice() {
this.regularPriceElement.style.display = 'block';
this.shopifySection.querySelector('.price__sale').style.display = 'none';
}
showSalePrice() {
this.salePriceElement.style.display = 'block';
this.regularPriceValue.innerHTML =
this.selectedPurchaseOptionComparedAtPrice;
this.salePriceValue.innerHTML = this.selectedPurchaseOptionPrice;
}
get sellingPlanInputs() {
return this.shopifySection.querySelectorAll('.selected-selling-plan-id');
}
updateSellingPlanInputsValues() {
this.sellingPlanInputs.forEach((sellingPlanInput) => {
sellingPlanInput.value = this.sellingPlanInputValue;
});
}
get sellingPlanInputValue() {
return this.selectedPurchaseOption?.dataset.sellingPlanId ?? null;
}
get selectedPurchaseOption() {
return this.visibleSellingPlanForm?.querySelector(
'input[type="radio"]:checked',
);
}
set selectedPurchaseOption(selectedPurchaseOption) {
this._selectedPurchaseOption = selectedPurchaseOption;
}
handleRadioButtonChange(selectedPurchaseOption) {
this.selectedPurchaseOption = selectedPurchaseOption;
this.updateSellingPlanInputsValues();
this.updatePrice();
}
listenToSellingPlanFormRadioButtonChange() {
this.visibleSellingPlanForm
?.querySelectorAll('input[type="radio"]')
.forEach((radio) => {
radio.addEventListener('change', (event) => {
this.handleRadioButtonChange(event.target);
});
});
}
enablePerformanceObserver() {
const performanceObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.initiatorType !== 'fetch') return;
const url = new URL(entry.name);
/*
When a buyer selects a product variant, a fetch request is initiated.
Upon completion of this fetch request, we update the price to reflect the correct value.
*/
if (url.search.includes('variant') || url.search.includes('variants')) {
this.updatePrice();
}
});
});
performanceObserver.observe({entryTypes: ['resource']});
}
}
document
.querySelectorAll('.selling_plan_app_container')
.forEach((sellingPlansWidgetContainer) => {
new SellingPlansWidget(sellingPlansWidgetContainer);
});
Add the selling plan selector to
sections/main-product.liquid
, ideally before the quantity selector. Search for 'quantity_selector' to find the block and insert the snippet at the start
{% render 'selling-plans-integration', product: product, section: section %}

sections/main-product.liquid
Now you should have a working selling plan selector on your product page.
Customizations for using SEPA based payment methods during checkout
To offer SEPA payment methods during checkout, add a SEPA checkout button to your cart.
You can skip this section if SEPA payment methods aren't needed.

Create
snippets/sepa-checkout-button.liquid
to define the button template for the SEPA checkout button.
snippets/sepa-checkout-button.liquid
<button
class="button button--full-width fh-sepa-checkout"
onclick="fhSepaCheckout()"
>
Check out with SEPA
</button>
Create
/assets/sepa-checkout.js
. This file contains the logic for the SEPA checkout button.
/assets/sepa-checkout.js
async function initializeSEPACheckout() {
if(window._sepa_checkout_initialized) {
return;
}
window._sepa_checkout_initialized = true;
const apiBaseUrl = window.Shopify.routes.root;
let cachedCart = null;
const getSellingPlanId = (shopifyId) => shopifyId.split("/").pop();
const sepaToNative = Object.fromEntries(
window.sellingPlanMapping.map((m) => [
getSellingPlanId(m["sepa"]),
getSellingPlanId(m["native"])
])
);
const getCart = async () => cachedCart ?? (cachedCart = (await fetchCart()));
const fetchCart = async () => (await fetch(`${apiBaseUrl}cart.js`)).json();
const updateCart = async (payload) => (await fetch(`${apiBaseUrl}cart/update.js`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})).json();
const clearCart = async () => fetch(`${apiBaseUrl}cart/clear.js`, { method: "POST" });
const addToCart = async (payload) => (await fetch(`${apiBaseUrl}cart/add.js`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})).json();
const updateCheckoutButtonDisabledState = (disabled) => {
document.querySelectorAll("button.fh-sepa-checkout, shopify-buy-it-now-button>button, button[name='checkout']").forEach((button) => {
button.disabled = disabled;
});
};
const replacePlans = async (planMapping) => {
const cart = await fetchCart();
if (!cart.items.some(item => item.selling_plan_allocation && planMapping[item.selling_plan_allocation.selling_plan.id])) {
return false
}
const items = cart.items.map((item) => ({
id: item.variant_id,
quantity: item.quantity,
...(item.selling_plan_allocation
? {
selling_plan:
planMapping[item.selling_plan_allocation.selling_plan.id] ??
item.selling_plan_allocation.selling_plan.id,
}
: {}),
properties: item.properties,
}));
await clearCart();
await addToCart({ items, attributes: cart.attributes });
return true
};
const revertSEPAPlans = async () => {
const cart = await getCart();
if (!cart.items.some(item => item.selling_plan_allocation && sepaToNative[item.selling_plan_allocation.selling_plan.id])) {
return
}
updateCheckoutButtonDisabledState(true);
if (await replacePlans(sepaToNative)) {
window.location.reload();
} else {
updateCheckoutButtonDisabledState(false);
}
};
const observeCartChanges = () => {
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if((entry.initiatorType === "xmlhttprequest" || entry.initiatorType === "fetch") && entry.name.includes("\/cart\/")) {
cachedCart = null;
checkCart();
}
})
}).observe({ entryTypes: ["resource"] });
}
const checkCart = async () => {
const cart = await getCart();
await revertSEPAPlans();
}
checkCart();
observeCartChanges();
const planMapping = Object.fromEntries(
window.sellingPlanMapping.map((m) => [
getSellingPlanId(m["native"]),
getSellingPlanId(m["sepa"])
])
);
const getCartInputs = () => {
const attributes = document.querySelectorAll("input[name^='attributes['], textarea[name^='attributes[']");
const note = document.querySelector("textarea[name='note'], input[name='note']");
const payload = {}
if(note) {
payload.note = note.value;
}
if(attributes) {
payload.attributes = Object.fromEntries(Array.from(attributes).map((a) => [a.name.replace("attributes[", "").replace("]", ""), a.value]))
}
return payload;
}
const handleClick = async () => {
updateCheckoutButtonDisabledState(true);
await updateCart(getCartInputs());
await replacePlans(planMapping);
window.location.href = "/checkout";
};
window.fhSepaCheckout = handleClick;
}
window.addEventListener("pageshow", initializeSEPACheckout);
Add sepa checkout buttons on where you want them to appear. By default the checkout buttons will be visible on
sections/main-cart-footer.liquid
, cart drawer andsnippets/cart-drawer.liquid
.
{% render 'sepa-checkout-button' %}

sections/main-cart-footer.liquid

snippets/cart-drawer.liquid

snippets/cart-notification.liquid
Update
layout/theme.liquid
to include the SEPA checkout script and make the selling plan mapping available to the script. A good place to add this is right before the</head>
tag.
{% assign firmhouse_app_id = '4631435' %}
{% assign app_name = 'app--' | append: firmhouse_app_id | append: '--firmhouse' %}
{% if shop.metaobjects[app_name].subscriptions.selling_plan_mapping %}
<script>
window.sellingPlanMapping = {{ shop.metaobjects[app_name].subscriptions.selling_plan_mapping }}
</script>
{% endif %}
<script src="{{ 'sepa-checkout.js' | asset_url }}" defer="defer"></script>

layout/theme.liquid
Great job! You've successfully added Selling Plan options to your Shopify store. Customers can now subscribe to your products directly from the product page enhancing their shopping experience
Last updated
Was this helpful?