Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt

Use this file to discover all available pages before exploring further.

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 Shopify-equivalent 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 OrderProduct row.
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,
        "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) mirrors Shopify exactly so theme code that reads either works as expected.

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, the same as Shopify.

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 React checkout receives the plan id alongside the variant and quantity, and addClientOrder writes the plan id onto the resulting OrderProduct row. The checkout UI surfaces the plan name beside the line item the same as any Shopify-style checkout — no theme-side wiring needed.

Order-placement persistence

Once the order is placed:
EntityFieldMeaning
SellingPlanGroupsellingPlanGroupId, name, position, storeIdThe group definition (merchant-set per store).
SellingPlanGroup.products(many-to-many)Which products this group is offered on.
SellingPlansellingPlanId, name, billingInterval, billingIntervalCount, recurringDeliveries, trialDays, checkoutChargeValue, checkoutChargeType, categoryThe individual plan (subscription, prepaid, try_before_you_buy).
SellingPlanPriceAdjustmentsellingPlanId, orderCount, adjustmentAmount, adjustmentPercentagePer-billing-cycle pricing tweaks.
OrderProductsellingPlanIdForeign key to the plan attached to this line.
The OrderProduct.sellingPlanId is the authoritative record of which plan was selected for which line. The renewal cron reads this column 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.
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.
pricestringPrice of one delivery on this plan for this variant (presentment currency).
compare_at_pricestringOriginal price for comparison strikethrough.
per_delivery_pricestringSame as price for the typical case — explicit for clarity.
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 cron runs daily at 01:00 server time (0 1 * * *). For each OrderProduct attached to a SellingPlan whose computed nextBillingDate is on/before today and which does not yet have a child renewal order, the cron logs the renewal that would happen — it does not create a child order, capture payment, or write any DB state.
[SellingPlanRenewalService] Registered renewal cron @ 0 1 * * * (dry-run)
[selling-plan-renewal] Running renewal discovery...
[selling-plan-renewal] Would renew OrderProduct op_abc... (plan sp-prod_8a7f...) at 2026-06-15T00:00:00.000Z
What the cron currently does:
  1. Loads every SellingPlan where recurringDeliveries = true.
  2. Pulls OrderProduct rows from the last 400 days where sellingPlanId IS NOT NULL and the parent order is not cancelled / abandoned / refunded.
  3. Computes each row’s nextBillingDate from the plan’s billingInterval / billingIntervalCount.
  4. For each row due on/before today and not yet renewed, logs the intended renewal.
What it does not do:
  • Charge the customer’s saved payment method.
  • Create a child Order for the renewal.
  • Decrement inventory.
  • Send a renewal email.
  • Update OrderProduct.nextBillingDate to push the next slot forward.
This is intentional — the discovery logic ships first so we can prove it picks the right rows in production logs 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 OrderProduct row.
  • 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 TeamInfra (cancel, skip, update cadence) reads from the OrderProduct.sellingPlanId and the SellingPlan row.

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.