For discussing the development and integration of subscription-enabled shops using Shopify's Subscription APIs.
In our store we use ReCharge to manage our subscription program. There are 2 ways to activate subscription on a product. You can select subscription from the product page, then add it to your cart:
Or you can add a one-time product to your cart, and select the subscription option from the ajax cart drawer:
We also use the script editor to apply free shipping to subscription products. The issue we're having is that if you add a subscription product from the product page, the script editor detects that it's a subscription product and applies the free shipping. But if you add a one-time product to your cart and change it to subscription there, the script editor does not recognize it as a subscription product and therefor charges shipping. Although the script editor doesn't detect the subscription, the checkout does. The item in the checkout will have the subscription discount and the shipping cadence applied, just the free shipping script doesn't work.
I'm wondering if this issue is coming from the cart change API, which is what we use to change the product when someone selects subscription from the ajax cart drawer. Here is the javascript for that:
updateCartItem(line, value, name, target) {
//enable loading state on line item
this.enableLoading(line);
//set selling plan value
let selling_plan = null;
let properties = null;
//use default selling plan value when subscription checkbox is checked
if (name === "subscribe") {
if (target.checked) {
selling_plan = target.dataset.default;
properties = { _is_subscription: true };
} else {
properties = { _is_subscription: false };
}
//change selling plan value when frequency dropdown is changed
} else if (name === "selling_plan") {
selling_plan = value;
}
const body = JSON.stringify({
line,
sections: this.getSectionsToRender().map((section) => section.section),
sections_url: window.location.pathname,
//if change is not on subscription inputs, change the quantity
...(!["subscribe", "selling_plan"].includes(name) && { quantity: value }),
//if change is on the subscription inputs, change the selling plan
...(["subscribe", "selling_plan"].includes(name) && { selling_plan }),
//include subscription property for shipping company
...(properties && { properties }),
});
//call shopify cart change API
fetch(`${routes.cart_change_url}`, { ...fetchConfig(), ...{ body } })
.then((response) => response.text())
.then((state) => {
const parsedState = JSON.parse(state);
//get cart object, we are using cart-drawer
this.cart = document.querySelector("cart-notification") || document.querySelector("cart-drawer");
//toggle empty state depending on cart item count
this.classList.toggle("is-empty", parsedState.item_count === 0);
const cartDrawerWrapper = document.querySelector("cart-drawer");
const cartFooter = document.getElementById("main-cart-footer");
if (cartFooter) cartFooter.classList.toggle("is-empty", parsedState.item_count === 0);
if (cartDrawerWrapper) cartDrawerWrapper.classList.toggle("is-empty", parsedState.item_count === 0);
//replace cart elements that when line item changes
//elements to change: subtotal, line item, free shipping countdown, catch pay
this.replaceSections(parsedState);
this.updateLiveRegions(line, parsedState.item_count);
Catch.refresh();
//move focus to appropriate element after cart elements are updated
const lineItem = document.getElementById(`CartItem-${line}`) || document.getElementById(`CartDrawer-Item-${line}`);
if (lineItem && lineItem.querySelector(`[name="${name}"]`)) {
cartDrawerWrapper ? trapFocus(cartDrawerWrapper, lineItem.querySelector(`[name="${name}"]`)) : lineItem.querySelector(`[name="${name}"]`).focus();
} else if (parsedState.item_count === 0 && cartDrawerWrapper) {
trapFocus(cartDrawerWrapper.querySelector(".cart-empty"), cartDrawerWrapper.querySelector("a"));
} else if (document.querySelector(".cart-item") && cartDrawerWrapper) {
trapFocus(cartDrawerWrapper, document.querySelector(".cart-item__name"));
}
//disable loading state on line item
this.disableLoading();
})
.catch((error) => {
console.error(error);
this.querySelectorAll(".loading-overlay").forEach((overlay) => overlay.classList.add("hidden"));
const errors = document.getElementById("cart-errors") || document.getElementById("CartDrawer-CartErrors");
if (errors) {
errors.textContent = window.cartStrings.error;
}
this.disableLoading();
});
}
So when someone interacts with any form element on a line item in the ajax cart, we check whether that form element is subscription related (we have a checkbox to enable subscription and a dropdown to choose the shipping interval). If it is subscription related, we assign a value to selling_plan. This value is the id of the selling plan. Here's the liquid for the subscription checkbox and interval dropdown so you can see where the id values come from:
{%- if item.product.selling_plan_groups.size > 0 and hide_subscription == false -%}
<div class="cart-subscibe flex flex-col items-end h-full">
{%- liquid
comment
set default selling plan value to the second interval option (2 months)
if there is a custom value for the default selling plan set in metafields, change default
endcomment
assign selling_plans = item.product.selling_plan_groups[0].selling_plans
assign default_option = selling_plans[1].id
if item.product.metafields.custom.subscription_interval != blank
for plan in selling_plans
if plan.name contains item.product.metafields.custom.subscription_interval
assign default_option = plan.id
endif
endfor
endif
-%}
<label for="CartSubscribeCheckbox-{{ item.index | plus: 1 }}" class="cursor-pointer flex-1 flex items-center tracking-wide text-sm relative mb-1">
<input
id="CartSubscribeCheckbox-{{ item.index | plus: 1 }}"
type="checkbox"
class="w-4 h-4 box-content mr-1 input"
name="subscribe"
data-index="{{ item.index | plus: 1 }}"
data-default="{{ default_option }}"
{% if item.selling_plan_allocation %}checked{% endif %}
>
<span id="CartSubscribeCheckbox-label" class="whitespace-nowrap">{% if item.selling_plan_allocation != nil %}Subscribed{% else %}Subscribe & Save 10%{% endif %}</span>
</label>
<div class="relative text-seaweed-700 flex-1">
{%- if item.selling_plan_allocation != nil -%}
<label for="CartSubscribeSelect-{{ item.index | plus: 1 }}" class="absolute left-3 top-1/2 -translate-y-1/2 text-sm pointer-events-none">Ship every</label>
<select
id="CartSubscribeSelect-{{ item.index | plus: 1 }}"
class="input input-sm pl-[5rem] box-border"
name="selling_plan"
data-index="{{ item.index | plus: 1 }}"
>
{%- for option in selling_plans -%}
<option value="{{ option.id }}" {%- if option.id == item.selling_plan_allocation.selling_plan.id -%}selected{%- endif -%}>
{{- option.name | replace: 'Ship every ', '' | replace: 'Month', 'month' -}}
</option>
{%- endfor -%}
</select>
{%- endif -%}
</div>
</div>
{%- endif -%}
So here we get the array of selling plans from `item.product.selling_plan_groups[0].selling_plans`. In our case there is always just one selling plan group, so the selling_plan array is always the first array in the selling_plan_group array. We then set the default id to the second option, which is always 2 months in our case. We also have a metafield on each product that allows us to choose a custom default option, so that first chunk of liquid code is just looking for that metafield and changing the default if it exists. The interval dropdown just has the id as the value for each option in the menu.
In the cart API documentation for selling plans, it says that to add a selling plan you just need to include the property selling_plan with a value that is the id of the selling plan in the POST data. We are doing this, and it seems like it's working because the item has all its subscription properties in the checkout.
However, the shipping script in the script editor doesn't seem to agree. It's extra confusing because the script editor works fine if you add it as a subscription product from the beginning. The shipping script we use is pretty long, but here is the relevant snippet:
def freeShipping()
Input.shipping_rates.each do |shipping_rate|
name = shipping_rate.name.downcase
if(name == 'standard shipping')
shipping_rate.apply_discount(shipping_rate.price, message: "Free Shipping!")
end
end
end
def validateIsSubscription()
hasSubscription = false
Input.cart.line_items.each do |line_item|
if line_item.properties.has_key?("_is_subscription") && line_item.properties["_is_subscription"] == "true"
hasSubscription = true
end
end
if hasSubscription
freeShipping()
end
end
validateIsSubscription()
We have to include a hidden property for subscription on the line item for our logistics company (_is_subscription). We piggy back off that value to check if an item is a subscription item, and apply free shipping if it is. You can see in the javascript for the cart change that I am also updating this value on cart change, so it should be accurate and included for the script editor.
Any help would be greatly appreciated, I have been unable to figure this problem out for awhile.
Hi Osea-Malibu,
I've been following the steps you provided (adding the Undaria Algae Body Oil as a one-time purchase to the cart, then checking out Subscribe in the cart drawer, then proceeding to the checkout) and I correctly get Free shipping for the subscription product.
Are you still experiencing the problem?
Cheers,
Jerome
To learn more visit the Shopify Help Center or the Community Blog.
Yes it's still an issue. We offer free shipping on orders over $60 so you have to do this with a product that is less than $60 after discounting, this one works: https://oseamalibu.com/products/blemish-balm