Subchapter 9.21
references/inline-recipes/how-to-code-pricing-plans.mdMarkdown18 KBView on GitHub
RECIPE: How to Code a Wix Pricing Plans Frontend (Plans V3 + members + the Bookings-membership integration)
A contract for the frontend code of a pricing-plans site: showing the plans grid, subscribing (ordering) a plan, a member’s “my subscription” surface, and — when the site also has Bookings — booking a service with an active membership. This recipe is the how (which modules, which calls, which fields), not the what — which plans to show, how the page looks, and the framework come from the request you’re fulfilling.
This recipe is for CODING, not seeding. It assumes a Plans V3 backend already exists (plans created, and — for the integration — bookings services attached to a plan via Benefit Programs; see
setup-pricing-plans.md). It says nothing about creating plans — only how to read and buy them from frontend code.
⚠️ Reading rule — append
.md?apiView=SDKto every doc link below. Wix docs render two views: the , the — the SDK is what your frontend calls. A surprising field name usually means you’re reading the REST view. Discover any shape not pinned here with , not by guessing a URL.
id?apiView=SDK view shows _idSearchWixSDKDocumentation⚠️ pricing-plans is a HARD dependency on members. Ordering a plan and the “my subscription” surface both require a logged-in member (browsing the grid is public). So this recipe is always paired with member auth — read the matching
how-to-code-members-astro.mdorhow-to-code-members-non-astro.mdfor the login flow. A logged-in member ordering their own plan needs noauth.elevateand noonBehalf.
⚠️ Two different packages — use the headless one. The Wix docs surface checkout.startOnlinePurchase() / checkout.createOnlineOrder() under @wix/site-pricing-plans — that is the Wix-site (Velo / $w page-code) package, and startOnlinePurchase drives the Wix Pay frontend UI that only exists inside a hosted Wix page. It is NOT the headless path — do not import @wix/site-pricing-plans in a headless frontend. Use @wix/pricing-plans (the universal/headless SDK), whose orders.createOnlineOrder creates the order and leaves payment to a redirect you drive (see Subscribing).
| Need | Package | Module / namespace |
|---|---|---|
| List / read plans (the grid) | @wix/pricing-plans | plansV3 (Plans V3 — queryPlans, getPlan) |
| Order a plan + read a member’s orders | @wix/pricing-plans | orders (createOnlineOrder, memberListOrders, memberGetOrder) |
| Member login / current member | @wix/members + @wix/sdk auth | see the members recipe (getCurrentMember, loggedIn()) |
| Book a service with a membership (integration) | @wix/bookings + @wix/ecom (+ @wix/redirects) | bookings (createBooking), ecom currentCartV2 (line-item membershipPayment) — see Booking with a membership |
Migrating from Cart V1 / Checkout V1? The code below is V2-only — see the migration guide (opens in a new tab) for the before/after.
Never import @wix/site-pricing-plans in headless code, and don’t reach for a V1 plans-collection query — Plans V3 (queryPlans → pricingVariants) is the shape the seed creates.
⚠️ The read module is
plansV3, notplans. In the@wix/pricing-plansSDK,queryPlans/getPlanlive on theplansV3namespace (import { plansV3, orders } from '@wix/pricing-plans'). Importingplansand callingplans.queryPlans()fails to type-check (Property 'queryPlans' does not exist).orderskeeps its own namespace.
Auth / client — framework split (same split as every other coding recipe):
plansV3 / orders directly from server components / src/pages/api/*. Member identity rides on the call automatically after login (how-to-code-members-astro.md). No createClient, no OAuthStrategy, no clientId. A member reading their own orders needs no auth.elevate.OAuthStrategy client the members/visitor flow already builds (don’t make a second one). After the member-login handshake sets member tokens on it, orders.* runs as that member:
import { createClient, OAuthStrategy } from '@wix/sdk';
import { plansV3, orders } from '@wix/pricing-plans';
const client = createClient({ modules: { plansV3, orders }, auth: OAuthStrategy({ clientId: /* public OAuth id */ }) });The connected site must be PUBLISHED — the Pricing Plans APIs return nothing / error against an unpublished site and don’t work in preview (same precondition as member login). Publish before testing.
Each subsection is self-contained — build only what the site uses.
const { items } = await plansV3.queryPlans()
.eq('visibility', 'PUBLIC')
.find(); // items[] of PlanPUBLIC plans; no login needed just to browse._id, not id — plan._id is what you order by and key React lists on. (plan.id is undefined in SDK code — you’re reading the REST view if you see id.)pricingVariants[], NOT a top-level price. Read the amount from plan.pricingVariants[0].pricingStrategies[0].flatRate.amount (a decimal string — parse before math) and the cycle from plan.pricingVariants[0].billingTerms.billingCycle ({ period: "MONTH", count: "1" }). A free plan has no flatRate amount; a one-time plan has different billingTerms (see the seed recipe). plan.currency is the site’s currency — format from it, don’t assume USD.buyable plans with a buy button. visibility: "PUBLIC" can still be buyable: false (assign-only) — render those without a subscribe action, or filter them out.plan.perks[] (each { _id, description }) — display-only text. (queryPlans returns them; if a summary trims them, plansV3.getPlan(planId) returns the full object.)Ordering is a member action. Gate the subscribe button on client.auth.loggedIn() (non-Astro) / a resolved member (Astro) and bounce anonymous users into the login flow first (members recipe). Then:
const { order } = await orders.createOnlineOrder(planId); // planId = plan._id; logged-in member ⇒ no onBehalf
// order.status: "DRAFT" (payment not yet made) | "ACTIVE" (free plan — already applied)onBehalf — the order is created on their behalf from the member identity. onBehalf.memberId is only for an app/admin identity ordering for someone (and needs elevation) — don’t reach for it in a normal member flow.DRAFT ≠ subscribed. createOnlineOrder orders but does not pay — a paid plan comes back status: "DRAFT" and is not active until payment completes. Do not show “you’re subscribed” off the createOnlineOrder return for a paid plan.status: "ACTIVE" (lastPaymentStatus: "NOT_APPLICABLE") directly — no payment step; render success immediately. Branch on the plan being free (no flatRate amount / total 0) to skip the redirect below.startOnlinePurchase that completes payment is the site-package method (above), unavailable headless; the headless createOnlineOrder returns a DRAFT order carrying a wixPayOrderId, and the member must be sent to a hosted payment flow to complete it. ⚠️ VERIFY IN A LIVE BUILD: confirm the exact headless redirect — whether you pass the order to @wix/redirects createRedirectSession({ paymentCheckout: { … } }), or a pricing-plans-specific redirect — before shipping the paid path. Do not assert a specific call here until a real build confirms it. (The origin/postFlowUrl allowlist + https-host rules from how-to-code-a-store.md/how-to-code-bookings.md apply to whatever redirect is used.) The free-plan path above is fully client-only and needs no redirect.createOnlineOrder + the payment redirect. STOP THERE. Do NOT try to complete or activate the purchase from code: don’t connect a payments provider (payments/v1/wix-payments-account/connect), don’t PATCH/update-plan to force a state, don't call mark-as-paid, and don't hunt for an "admin way to activate the order" or to "enable payments for a $0 order." Payment completing (a paid order flipping DRAFT → ACTIVE) happens out-of-band on Wix’s hosted flow / by the merchant configuring payments — it is not a frontend step, and chasing it is a rabbit hole (it burns the run and ships nothing extra). A free plan already returns ACTIVE with no payment; a paid plan is DRAFT until the member pays through the redirect. If the site has no payment provider configured, that’s a site-setup precondition (like the events paid-ticket precondition) — surface it, don’t try to fix it in code.// the logged-in member's own orders:
const res = await orders.memberListOrders(); // filtered: orders.memberListOrders({ planIds, orderStatuses, paymentStatuses, limit, offset })
// ⚠️ member-scoped reads are memberListOrders / memberGetOrder — there is NO `orders.listOrders`/`getOrder` (those are the admin `managementListOrders`/`managementGetOrder`, server/elevate only).
// ⚠️ filter args are TOP-LEVEL limit/offset, NOT a nested `paging` object.
// each order: { _id, planId, subscriptionId, status, lastPaymentStatus, startDate, endDate, currentCycle, planName, pricing, planPrice }status === "ACTIVE" AND now is within startDate…endDate. A CANCELED order can still be within its paid period until endDate; autoRenewCanceled: true means it won’t renew but may still be active now. Don’t treat CANCELED as “no access” without checking the date window.auth.elevate rule: a member reading their own orders is authorized under the member token. Listing all members’ orders is the admin/elevate axis (server-only) — not this.This is the payoff of the seed’s STEP 2: a member who holds a plan that covers a service books it with the membership instead of paying per booking. Build the normal Bookings flow from how-to-code-bookings.md (list services → pick a slot → collect the form → createBooking → ecom cart/checkout); the membership is a delta on that flow, not a separate path:
createBooking, set selectedPaymentOption: "MEMBERSHIP" (instead of ONLINE/OFFLINE). Enum values: ONLINE, OFFLINE, MEMBERSHIP (pay with a pricing plan), MEMBERSHIP_OFFLINE. Doc: https://dev.wix.com/docs/api-reference/business-solutions/bookings/bookings/bookings-writer-v2/create-booking.md?apiView=SDK (opens in a new tab)13d21c63-b5ec-5912-8397-c3a5ddb27a97) — the membership is applied on the cart line item, not on the booking object. Set membership selection on the line item via updateLineItemsInCurrentCart:
membershipPayment via currentCartV2.updateLineItemsInCurrentCart({ lineItems: [{ lineItemId: <booking line item id>, membershipPayment: { existingMembership: { membershipId, appId } } }] }) — membershipId/appId identify the member’s existing plan membership (the Pricing Plans app); the line item reads it back as paymentConfig.membership. When the membership covers the full price, the calculated total drops to 0.calculateCurrentCart → summary.paymentSummary.memberships lists memberships already applied, not candidates — so which plan to apply comes from the member’s plans + live coverage (see the LIVE-read note below). ⚠️ VERIFY IN A LIVE BUILD which appId/membershipId a headless member token must pass.
Doc: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/update-line-items.md?apiView=SDK (opens in a new tab)confirmBooking — for a membership payment Wix auto-confirms the booking on redemption (the same “no confirmBooking” rule as how-to-code-bookings.md; the membership drives confirmation).{ planId → serviceIds } map into the frontend. Since Cart V2 has no per-line “eligible memberships” read, compute eligibility from the member’s own plans against live coverage: list the member’s active orders (orders.memberListOrders, status: ACTIVE within the date window) and match each planId against the plan’s live Benefit-Program coverage (query the program’s items — the same API setup-pricing-plans.md STEP 2 wrote to — at request time; do not read a coverage map baked into code). Set selectedPaymentOption: "MEMBERSHIP" on the booking + the line item’s membershipPayment.existingMembership from the matched plan.MEMBERSHIP booking on a service the plan does NOT cover fails at checkout (unless skipSelectedPaymentOptionValidation, which needs elevation — don’t use in a member flow). Only offer “book with membership” for services actually covered by one of the member’s active plans; otherwise fall back to the normal paid booking (how-to-code-bookings.md).calculateCurrentCart/estimateCurrentCart. The summary lists the applied memberships (summary.paymentSummary.memberships), the covered line’s paymentConfig.membership carries redemptionCost (balance deducted for this booking; 0 = unlimited) + redemptionType (CREDITS/SESSIONS), and an exhausted/over-limit plan surfaces explicitly as a summary.violations entry (or an error at place-order) — treat that as “not eligible.” The ecom cart itself carries no remaining-balance read; the balance is owned by the plan’s Benefit-Program domain (the same source as the coverage check above), so if you need the actual number read it there — not off the cart. An unlimited plan (seed price:"0") has no ceiling.ACTIVE, “activating” a subscription server-side. Payment is the member’s hosted-flow step or a merchant dashboard/config task, never frontend code (see the SCOPE callout under Subscribing).setup-pricing-plans.md STEP 2); the frontend only reads/uses it, it doesn’t create or edit it.createOfflineOrder / mark-as-paid) — a merchant/admin flow, not a member-facing frontend one.A correct Pricing Plans frontend:
@wix/pricing-plans (plansV3, orders) — never @wix/site-pricing-plans (its startOnlinePurchase is Wix-site page-code, not headless);plansV3.queryPlans().eq('visibility','PUBLIC'), reads plan._id and price from pricingVariants[].pricingStrategies[].flatRate.amount (decimal string) — never a top-level price — and only shows a buy button on buyable plans;orders.createOnlineOrder(planId) (no onBehalf, no elevate), renders free plans as ACTIVE immediately, and drives paid plans through a payment redirect (exact headless redirect to be confirmed in a live build) — and stops at the redirect: no payments-account connect, no order activation / mark-as-paid from code (see Out of scope);selectedPaymentOption: "MEMBERSHIP" on createBooking, applies the membership on the ecom cart line item (membershipPayment.existingMembership via updateLineItemsInCurrentCart), never calls confirmBooking, and falls back to matching the member’s active-order planIds to the service’s coverage when the cart eligibility field isn’t readable client-side.