Skip to main content

Selling Plans

Selling plans expose subscription / recurring-purchase data on a product in a standard, theme-friendly shape. Themes that render a “Subscribe & save” selector or display per-delivery pricing pick up the data automatically on LaunchMyStore — no theme changes required versus a Liquid-compatible theme. The data is hydrated from the backend at render time onto the product.selling_plan_groups and variant.selling_plan_allocations drops. The selected plan id flows through the cart, checkout, and order placement to be persisted on the resulting order line.
MVP status — renewals are dry-run. The full read-path is live: themes see the groups, the PDP selector renders, customers can pick a plan at checkout, and the order is recorded with the selected selling_plan_id. What is not yet live is automatic charge of subsequent renewals — the renewal-discovery cron runs nightly but only logs what would be charged. Payment-capture wiring is the next planned milestone — see Renewals below.

How it flows end-to-end

Schema

product.selling_plan_groups is an array of groups. Each group has one or more selling plans. Each plan describes a recurring schedule and checkout charge.
[
  {
    "id": "spg-prod_8a7f...",
    "name": "Subscribe and save",
    "app_id": null,
    "options": [
      {
        "name": "Delivery every",
        "position": 1,
        "values": ["1 Month"]
      }
    ],
    "selling_plans": [
      {
        "id": "sp-prod_8a7f...",
        "name": "Delivery every 1 Month",
        "description": "",
        "recurring_deliveries": true,
        "selected": false,
        "billing_policy": { "interval": "month", "interval_count": 1 },
        "delivery_policy": { "interval": "month", "interval_count": 1 },
        "deliveries_per_cycle": 1,
        "options": [
          {
            "name": "Delivery every",
            "position": 1,
            "value": "1 Month"
          }
        ],
        "price_adjustments": [],
        "checkout_charge": {
          "value": 100,
          "value_type": "percentage"
        }
      }
    ],
    "selling_plan_selected": false
  }
]
On each variant, variant.selling_plan_allocations carries the per-variant resolved pricing for each plan in the groups above:
[
  {
    "selling_plan": { "id": "sp-prod_8a7f...", "name": "Delivery every 1 Month" },
    "selling_plan_group_id": "spg-prod_8a7f...",
    "price": "24.00",
    "compare_at_price": "24.00",
    "per_delivery_price": "24.00",
    "checkout_charge_amount": "24.00",
    "remaining_balance_charge_amount": 0,
    "price_adjustments": []
  }
]
The split between selling_plan_groups (definition) and selling_plan_allocations (per-variant pricing) follows the canonical Liquid selling-plan model — group-level definitions plus per-variant resolved pricing — so theme code that reads either works as expected.

Delivery policies (deliver daily, bill weekly)

A plan can deliver more often than it bills — the classic prepaid pattern: a bakery box delivered every morning, charged once a week. This is modelled with two policies on the plan:
  • billing_policy — how often the customer is charged (e.g. every 7 days).
  • delivery_policy — how often goods are delivered (e.g. every 1 day).
The platform enforces two rules when the merchant saves the plan: both policies must use the same interval unit, and the billing interval count must be a multiple of the delivery interval count, so one charge always covers a whole number of deliveries. The quotient is exposed as deliveries_per_cycle.
{
  "id": "sp-...",
  "name": "Daily loaf — billed weekly",
  "billing_policy": { "interval": "day", "interval_count": 7 },
  "delivery_policy": { "interval": "day", "interval_count": 1 },
  "deliveries_per_cycle": 7
}
Pricing follows automatically — the allocation’s price is the amount charged per billing cycle (per-delivery price × deliveries per cycle, after any plan discount), while per_delivery_price stays the single-delivery amount:
{
  "selling_plan": { "id": "sp-...", "name": "Daily loaf — billed weekly" },
  "price": "1190.70",
  "compare_at_price": "1323.00",
  "per_delivery_price": "170.10"
}
The canonical PDP rendering for such a plan:
{% for allocation in current_variant.selling_plan_allocations %}
  {% if allocation.selling_plan.id == plan.id %}
    {% if plan.deliveries_per_cycle > 1 %}
      <strong>{{ allocation.per_delivery_price | money }}</strong> per delivery
      &middot; billed {{ allocation.price | money }}
      every {{ plan.billing_policy.interval_count }} {{ plan.billing_policy.interval }}s
    {% else %}
      <strong>{{ allocation.per_delivery_price | money }}</strong> / delivery
    {% endif %}
  {% endif %}
{% endfor %}
Cart lines and checkout totals charge the per-cycle amount; the line’s selling_plan_allocation.per_delivery_price divides it back down for display. Recurring billing (Stripe) charges the same per-cycle amount on the billing cadence.

Theme usage

The canonical pattern: gate the selector on product.selling_plan_groups.size > 0, then offer the merchant’s plans alongside the one-time purchase option.

Subscribe & save selector

<div class="purchase-options">
  {% if product.selling_plan_groups.size > 0 %}
    <label class="purchase-option">
      <input type="radio" name="purchase_type" value="one_time" checked />
      <span>One-time purchase</span>
      <strong>{{ product.selected_or_first_available_variant.price | money }}</strong>
    </label>

    {% for group in product.selling_plan_groups %}
      <fieldset class="selling-plan-group">
        <legend>{{ group.name }}</legend>

        {% for plan in group.selling_plans %}
          <label class="purchase-option">
            <input
              type="radio"
              name="selling_plan"
              value="{{ plan.id }}"
              data-plan-id="{{ plan.id }}"
            />
            <span>{{ plan.name }}</span>

            {% comment %}
              Look up the allocation for the currently-selected variant
              so the customer sees the actual price they'll pay per
              delivery.
            {% endcomment %}
            {% assign current_variant = product.selected_or_first_available_variant %}
            {% for allocation in current_variant.selling_plan_allocations %}
              {% if allocation.selling_plan.id == plan.id %}
                <strong>{{ allocation.per_delivery_price | money }} / delivery</strong>
                {% if allocation.compare_at_price != allocation.price %}
                  <s>{{ allocation.compare_at_price | money }}</s>
                {% endif %}
              {% endif %}
            {% endfor %}

            {% if plan.description != blank %}
              <small class="plan-description">{{ plan.description }}</small>
            {% endif %}
          </label>
        {% endfor %}
      </fieldset>
    {% endfor %}
  {% endif %}
</div>

<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}" />
The <input name="selling_plan"> radio value flows into the cart line as selected_selling_plan_id — the Liquid form serializer reads it automatically when the form is submitted.

Per-delivery price for the selected variant

variant.selling_plan_allocations is the structured way to read the price applied when a customer subscribes to a particular plan on that variant.
{% assign current_variant = product.selected_or_first_available_variant %}

{% for allocation in current_variant.selling_plan_allocations %}
  <div class="plan-price" data-plan-id="{{ allocation.selling_plan.id }}">
    {{ allocation.selling_plan.name }}:
    <strong>{{ allocation.per_delivery_price | money }}</strong> per delivery
  </div>
{% endfor %}

Showing total checkout charge

Some plans charge less than the full price at checkout (deferred billing). Read allocation.checkout_charge_amount for what gets charged today and allocation.remaining_balance_charge_amount for what will be billed on subsequent renewals.
{% for allocation in variant.selling_plan_allocations %}
  <p class="plan-charge-breakdown" data-plan-id="{{ allocation.selling_plan.id }}">
    Charged today: <strong>{{ allocation.checkout_charge_amount | money }}</strong>
    {% if allocation.remaining_balance_charge_amount > 0 %}
      — Remaining {{ allocation.remaining_balance_charge_amount | money }} per renewal.
    {% endif %}
  </p>
{% endfor %}

Detecting subscription-only products

A product that requires a selling plan (no one-time purchase option) exposes requires_selling_plan: true. The MVP does not yet flip this flag — every subscription-enabled product currently allows both one-time and recurring purchase.
{% if product.requires_selling_plan %}
  <p class="notice">This product is sold by subscription only.</p>
{% endif %}

Cart-line context

In cart and order templates, a line that has a selected selling plan carries the plan reference at line.selling_plan_allocation:
{% for line in cart.items %}
  <div class="cart-line">
    {{ line.title }}
    {% if line.selling_plan_allocation %}
      <span class="plan-badge">
        {{ line.selling_plan_allocation.selling_plan.name }}
      </span>
      <small>{{ line.selling_plan_allocation.per_delivery_price | money }} / delivery</small>
    {% endif %}
  </div>
{% endfor %}

Dynamic price calculation

There are two ways a plan can affect price, and themes should be prepared for both:

1. Allocation override

The simplest case: the merchant sets a fixed per-delivery price for the plan that’s lower than the variant price. The platform pre-computes this and surfaces it as allocation.per_delivery_price. No theme math is required — render the allocation price directly.

2. Per-billing-cycle adjustments

Some plans charge a different amount on the first delivery vs. subsequent renewals (e.g. “first month free”, “$10 off your second delivery”). These are surfaced as allocation.price_adjustments[]:
{
  "price_adjustments": [
    { "order_count": 1, "adjustment_value": { "adjustment_percentage": 100 } },
    { "order_count": 2, "adjustment_value": { "adjustment_amount": 10 } }
  ]
}
Themes generally do not render the full adjustment ladder on the PDP — show just the headline price and let the customer learn the details on checkout. If you do want to surface them:
{% for allocation in variant.selling_plan_allocations %}
  {% if allocation.price_adjustments.size > 0 %}
    <ul class="plan-adjustments" data-plan-id="{{ allocation.selling_plan.id }}">
      {% for adj in allocation.price_adjustments %}
        <li>
          Delivery {{ adj.order_count }}:
          {% if adj.adjustment_value.adjustment_percentage %}
            {{ adj.adjustment_value.adjustment_percentage }}% off
          {% elsif adj.adjustment_value.adjustment_amount %}
            {{ adj.adjustment_value.adjustment_amount | money }} off
          {% endif %}
        </li>
      {% endfor %}
    </ul>
  {% endif %}
{% endfor %}

Checkout integration

When the customer adds the product to cart with a selling plan selected, the cart API persists the selected_selling_plan_id on the line. From there the checkout receives the plan id alongside the variant and quantity, and order placement writes the plan id onto the resulting order line. The checkout UI surfaces the plan name beside the line item the same as any Liquid-rendered checkout — no theme-side wiring needed.

Order-placement persistence

Once the order is placed, the platform records:
  • The selling plan group definition (name, position, the products it is offered on).
  • Each selling plan (name, billing and delivery cadence, recurring flag, trial days, checkout charge, category — subscription, prepaid, or try_before_you_buy).
  • Any per-cycle price adjustments.
  • The selected plan on each order line — the authoritative record of which plan was chosen, used to find renewals due.

Field reference

Selling plan group (product.selling_plan_groups[])

FieldTypeDescription
idstringUnique group id. Format: spg-{productId} for MVP.
namestringGroup label (e.g. "Subscribe and save").
app_idstring | nullApp that registered the group, or null for merchant-set. Always null in MVP.
optionsarrayArray of option definitions — name, position, and possible values.
selling_plansarrayArray of plans in this group.
selling_plan_selectedbooltrue if a plan in this group is the active selection.

Selling plan (group.selling_plans[])

FieldTypeDescription
idstringUnique plan id.
namestringPlan label.
descriptionstringOptional plan description (used for trial info, etc.).
recurring_deliveriesbooltrue if this plan delivers on a schedule.
selectedbooltrue if this plan is the active selection.
billing_policyobjectHow often the customer is charged ({ interval, interval_count }).
delivery_policyobjectHow often goods are delivered ({ interval, interval_count }). Same as billing_policy unless the merchant set a delivery frequency.
deliveries_per_cyclenumberDeliveries covered by one charge (billing ÷ delivery). 1 for ordinary subscriptions.
optionsarrayArray of option values selected for this plan.
price_adjustmentsarrayDiscount adjustments applied to recurring deliveries.
checkout_chargeobjectHow much is charged at checkout ({ value, value_type }).

Variant allocation (variant.selling_plan_allocations[])

FieldTypeDescription
selling_planobjectReference to the plan this allocation belongs to ({ id, name }).
selling_plan_group_idstringParent group id.
pricestringAmount charged per billing cycle for this variant (presentment currency). Equals one delivery for ordinary plans; per-delivery × deliveries_per_cycle for prepaid delivery-policy plans.
compare_at_pricestringOne-time price for the same number of deliveries — for the strikethrough.
per_delivery_pricestringPrice of a single delivery. Same as price unless the plan has a delivery policy.
checkout_charge_amountstringAmount charged at checkout (subset of price if deferred).
remaining_balance_charge_amountnumberBalance charged on the next renewal. 0 in MVP.
price_adjustmentsarrayPlan-specific adjustments ({ order_count, adjustment_value: { adjustment_percentage | adjustment_amount } }).

Cart-line context

FieldTypeDescription
line.selling_plan_allocationobject | nullPresent on lines whose customer picked a plan at PDP. Carries the same shape as variant.selling_plan_allocations[].

Renewals (MVP behaviour)

A renewal-discovery job runs daily. For each subscription order line whose computed next billing date is on/before today and which does not yet have a child renewal order, the job records the renewal that would happen — it does not yet create a child order or capture payment. What the job currently does:
  1. Finds recent order lines placed with a recurring selling plan whose parent order is not cancelled / abandoned / refunded.
  2. Computes each line’s next billing date from the plan’s billing cadence.
  3. Flags every line due on/before today that has not yet been renewed.
What it does not do yet:
  • Charge the customer’s saved payment method.
  • Create a child order for the renewal.
  • Decrement inventory.
  • Send a renewal email.
  • Advance the line’s next billing date.
This is intentional — the discovery logic ships first so it can be validated in production without risking double-charges. Phase 2 wires payment capture (Stripe off-session) and order creation on top of the same discovery pass.

What works today

  • Themes render the selector and customers can place orders with a selling_plan_id attached.
  • The recurring schedule is recorded on the order line.
  • The plan is visible on the order detail page (admin), the customer account order history, and via the storefront order drop.
  • Subscription-management UI in the admin (cancel, skip, update cadence) reads the plan recorded on each order line.

What doesn’t work yet

  • Subsequent deliveries are not automatically charged or shipped.
  • No webhook fires on renewal due-date.
  • Failed-payment retry / dunning is not wired.
Until the cron’s payment-capture branch ships, merchants who launch subscriptions need to charge renewals manually (or via a third-party subscription app that bypasses this cron). This is the foundation Phase 2 will build on — once payment capture is wired into the cron, the same theme code will start producing real renewal orders without any change on the theme side.

See also

  • Aqua Objects — full reference for product, variant, and other globals.
  • Aqua Filtersmoney, default, and other filters used in the examples above.
  • Metafields in Aqua — for storing app-specific data alongside selling plans (e.g. anchor day, prepaid count).
  • Order drop — for reading the selected plan on past orders in the customer account.