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:
| Entity | Field | Meaning |
|---|
SellingPlanGroup | sellingPlanGroupId, name, position, storeId | The group definition (merchant-set per store). |
SellingPlanGroup.products | (many-to-many) | Which products this group is offered on. |
SellingPlan | sellingPlanId, name, billingInterval, billingIntervalCount, recurringDeliveries, trialDays, checkoutChargeValue, checkoutChargeType, category | The individual plan (subscription, prepaid, try_before_you_buy). |
SellingPlanPriceAdjustment | sellingPlanId, orderCount, adjustmentAmount, adjustmentPercentage | Per-billing-cycle pricing tweaks. |
OrderProduct | sellingPlanId | Foreign 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[])
| Field | Type | Description |
|---|
id | string | Unique group id. Format: spg-{productId} for MVP. |
name | string | Group label (e.g. "Subscribe and save"). |
app_id | string | null | App that registered the group, or null for merchant-set. Always null in MVP. |
options | array | Array of option definitions — name, position, and possible values. |
selling_plans | array | Array of plans in this group. |
selling_plan_selected | bool | true if a plan in this group is the active selection. |
Selling plan (group.selling_plans[])
| Field | Type | Description |
|---|
id | string | Unique plan id. |
name | string | Plan label. |
description | string | Optional plan description (used for trial info, etc.). |
recurring_deliveries | bool | true if this plan delivers on a schedule. |
selected | bool | true if this plan is the active selection. |
options | array | Array of option values selected for this plan. |
price_adjustments | array | Discount adjustments applied to recurring deliveries. |
checkout_charge | object | How much is charged at checkout ({ value, value_type }). |
Variant allocation (variant.selling_plan_allocations[])
| Field | Type | Description |
|---|
selling_plan | object | Reference to the plan this allocation belongs to ({ id, name }). |
selling_plan_group_id | string | Parent group id. |
price | string | Price of one delivery on this plan for this variant (presentment currency). |
compare_at_price | string | Original price for comparison strikethrough. |
per_delivery_price | string | Same as price for the typical case — explicit for clarity. |
checkout_charge_amount | string | Amount charged at checkout (subset of price if deferred). |
remaining_balance_charge_amount | number | Balance charged on the next renewal. 0 in MVP. |
price_adjustments | array | Plan-specific adjustments ({ order_count, adjustment_value: { adjustment_percentage | adjustment_amount } }). |
Cart-line context
| Field | Type | Description |
|---|
line.selling_plan_allocation | object | null | Present 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:
- Loads every
SellingPlan where recurringDeliveries = true.
- Pulls
OrderProduct rows from the last 400 days where
sellingPlanId IS NOT NULL and the parent order is not cancelled /
abandoned / refunded.
- Computes each row’s
nextBillingDate from the plan’s billingInterval
/ billingIntervalCount.
- 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 Filters —
money, 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.