← Files WixARCHIVED FILE

skills/wix-headless-templates/pricing-plans/seed/seed-pricing-plans.mjs

13.2 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Pricing Plans seed — a BUILD-TIME script, never shipped in the app. Run from the project
// root (where wix.config.json lives) with a plan file:
//
//   node <SKILL_ROOT>/templates/pricing-plans/seed/seed-pricing-plans.mjs plan.json
//
// It mints its own site token via the Wix CLI, installs the Wix Pricing Plans app if needed,
// creates the plans (Plans V3 — one create call per plan; there is no bulk-create), and —
// only when a plan carries bookings service ids — wires the plan to COVER those services
// through the Benefit Programs API. Prints a JSON result to stdout.
//
// Plan shape (see SEED.md):
//   { "plans": [{ "name", "description"?, "price"? (decimal string; a number is stringified),
//                 "type"?: "recurring"|"one-time"|"free",
//                 "billingCycle"?: { "period": "DAY"|"WEEK"|"MONTH"|"YEAR", "count" } | null,
//                 "freeTrialDays"?: 14, "setupFee"?: { "name", "amount" } | "fees"?: [{ "name", "amount" }],
//                 "perks"?: ["..."], "termsAndConditions"?, "visibility"?, "buyable"?,
//                 "coveredServiceIds"?: ["<bookings service id>"],
//                 "bookingsCoverage"?: { "serviceIds"?, "creditAmount"? } }] }
//
// Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
// read the live API reference; every call below
// carries a docs: line with its reference page.
import { setSiteCurrency } from "../../shared/seed/site.mjs";
import { seedSiteId } from "../../shared/seed/site-context.mjs";
import { wixToken } from "../../shared/seed/wix-cli.mjs";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

const API = "https://www.wixapis.com";
const PRICING_PLANS_APP_ID = "1522827f-c56c-a5c9-2ac9-00f9e6ae12d3";
const BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97"; // coverage providerAppId — NOT the Plans id
const PP_NAMESPACE = "@wix/pricing-plans"; // literal, with the @ and the slash, in every coverage call

export function makeCtx({ cwd = process.cwd() } = {}) {
  // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
  // a seed there unless --allow-parent is passed after the user confirmed).
  const siteId = seedSiteId({ cwd, argv: process.argv });
  const token = wixToken(siteId, cwd);
  return { token, siteId };
}

async function req(ctx, path, { method = "POST", body } = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${ctx.token}`,
      "wix-site-id": ctx.siteId,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
  return json;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// billingTerms selects the plan type (recurring | one-time | free). recurring: billingCycle
// {period,count} (default MONTH/1) + ON_PURCHASE + UNTIL_CANCELLED. one-time: endType
// CYCLES_COMPLETED + billingCycleCount 1 (bill once, then ends); an unlimited one-time
// instead sets billingCycle:null + UNTIL_CANCELLED. free: MUST be the no-cycle unlimited
// shape — a $0 variant with a billingCycle is rejected ("Free pricing variant cannot be
// recurring").
function buildBillingTerms(plan) {
  const type = plan.type || "recurring";
  if (type === "free") {
    return { billingCycle: null, startType: "ON_PURCHASE", endType: "UNTIL_CANCELLED" };
  }
  if (type === "one-time") {
    if (plan.billingCycle === null) {
      return { billingCycle: null, startType: "ON_PURCHASE", endType: "UNTIL_CANCELLED" };
    }
    const cycle = plan.billingCycle || { period: "MONTH", count: 1 };
    return {
      billingCycle: cycle,
      startType: "ON_PURCHASE",
      endType: "CYCLES_COMPLETED",
      cyclesCompletedDetails: { billingCycleCount: 1 },
    };
  }
  // recurring
  const cycle = plan.billingCycle || { period: "MONTH", count: 1 };
  return { billingCycle: cycle, startType: "ON_PURCHASE", endType: "UNTIL_CANCELLED" };
}

// Additional fees on the variant (up to 5): each { name (1–40 chars), priceType FIXED_AMOUNT,
// fixedAmountOptions: { amount } (decimal string, > 0), appliedAt FIRST_PAYMENT — the only value:
// a fee is added to the first payment }. `setupFee` is the one-fee shorthand of `fees`. The
// frontend reads them back as fees[].fixedAmountOptions.amount and shows them with the price.
// The id is a client-supplied GUID like the variant's and the perks'.
function buildFees(plan) {
  const fees = [...(plan.setupFee ? [plan.setupFee] : []), ...(plan.fees ?? [])];
  return fees.map((f) => ({
    id: randomUUID(),
    name: f.name || "Setup fee",
    priceType: "FIXED_AMOUNT",
    fixedAmountOptions: { amount: String(f.amount) },
    appliedAt: "FIRST_PAYMENT",
  }));
}

// One pricingVariant with one pricingStrategy (schema allows ≤20 but is "currently limited
// to 1"). Amounts are decimal STRINGS ("20.00"); the variant id is a REQUIRED
// client-supplied GUID — omitting it returns 400. freeTrialDays (a number, ≤ 999) is the trial
// before the first charge — a paid plan's field (nothing to trial on a free plan); fees ride on
// the variant too.
function buildPricingVariant(plan) {
  const free = (plan.type || "recurring") === "free";
  const amount = free ? "0" : String(plan.price);
  const fees = free ? [] : buildFees(plan);
  return {
    id: randomUUID(),
    name: plan.variantName || "Standard",
    billingTerms: buildBillingTerms(plan),
    pricingStrategies: [{ flatRate: { amount } }],
    ...(!free && plan.freeTrialDays ? { freeTrialDays: Number(plan.freeTrialDays) } : {}),
    ...(fees.length ? { fees } : {}),
  };
}

// plain plan -> Plans V3 create body. status:"ACTIVE" is REQUIRED (omitting → 400
// "status value is required"); currency is NOT sent (read-only, site-derived); perks are
// display-only bullets, each with a REQUIRED client-supplied GUID.
function buildPlan(plan) {
  const free = (plan.type || "recurring") === "free";
  return {
    name: plan.name,
    description: plan.description,
    status: "ACTIVE",
    visibility: plan.visibility || "PUBLIC",
    buyable: plan.buyable ?? true,
    buyerCanCancel: plan.buyerCanCancel ?? true,
    ...(plan.termsAndConditions ? { termsAndConditions: plan.termsAndConditions } : {}),
    pricingVariants: [buildPricingVariant(plan)],
    perks: (plan.perks ?? []).map((p) => ({
      id: randomUUID(),
      description: typeof p === "string" ? p : p.description,
    })),
    // free: cap lifetime reuse. Array + maxCount per the SDK schema (PurchaseLimit[]); the
    // recipe's older `{ type, count }` object form predates it.
    ...(free ? { purchaseLimits: [{ type: "PER_MEMBER_LIFETIME", maxCount: 1 }] } : {}),
  };
}

// ---- operations ----------------------------------------------------------------------------------

// docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
export async function installPricingPlansApp(ctx) {
  try {
    await req(ctx, "/apps-installer-service/v1/app-instance/install", { body: {
      tenant: { tenantType: "SITE", id: ctx.siteId },
      appInstance: { appDefId: PRICING_PLANS_APP_ID, enabled: true },
    } });
  } catch {
    /* already installed is fine */
  }
}

// Create the plan(s) — Plans V3 has NO bulk-create: one POST per plan. Keep plan.id: the
// frontend orders by it AND it is the coverage externalId.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/pricing-plans/plans-v3/create-plan.md
export async function createPlans(ctx, plans) {
  const out = [];
  for (const plan of plans) {
    const r = await req(ctx, "/pricing-plans/v3/plans", { body: { plan: buildPlan(plan) } });
    out.push({
      id: r.plan?.id,
      slug: r.plan?.slug,
      name: plan.name,
      coveredServiceIds: plan.coveredServiceIds,
    });
  }
  return out;
}

// ---- bookings coverage via Benefit Programs (SKIP when no bookings in the run) -------------------
// Strictly ordered: plan.id -> programDefinition.id -> itemSetId -> items. Do NOT parallelize.

// 2a: READ the auto-created program definition (the Plans app creates it; you never create
// it). Provisioning is ~immediate but async — retry ONCE after a short backoff, don't loop.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/program-definitions/introduction.md
export async function getProgramDefinition(ctx, planId) {
  const path = `/benefit-programs/v1/program-definitions/by-namespace-and-external-id?externalId=${planId}&namespace=${encodeURIComponent(PP_NAMESPACE)}`;
  try {
    const r = await req(ctx, path, { method: "GET" });
    if (r.programDefinition?.id) return r.programDefinition.id;
  } catch {
    /* fall through to the single insurance retry */
  }
  await sleep(1000);
  const r = await req(ctx, path, { method: "GET" });
  return r.programDefinition?.id;
}

// 2b: create ONE pool definition with EXACTLY ONE benefit naming Bookings as the provider.
// price "0" = unlimited (default; no creditConfiguration). Limited pack: pass creditAmount →
// price "1" + details.creditConfiguration (a SIBLING of benefits[], NOT inside a benefit —
// nesting it 400s with "Price should be 0 when credit pool is not set up").
// docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/pool-definitions/create-pool-definition.md
export async function createPoolDefinition(ctx, programDefinitionId, { creditAmount } = {}) {
  const limited = creditAmount != null;
  const details = {
    ...(limited ? { creditConfiguration: { amount: String(creditAmount) } } : {}),
    benefits: [
      {
        benefitKey: randomUUID(),
        displayName: "Bookings sessions",
        providerAppId: BOOKINGS_APP_ID,
        price: limited ? "1" : "0",
      },
    ],
  };
  const r = await req(ctx, "/benefit-programs/v1/pool-definitions", {
    body: {
      poolDefinition: {
        namespace: PP_NAMESPACE,
        displayName: "Bookings benefit",
        programDefinitionIds: [programDefinitionId],
        details,
      },
      cascade: "IMMEDIATELY",
    },
  });
  // itemSetId lives at poolDefinition.details.benefits[i].itemSetId — one benefit here, so [0].
  return r.poolDefinition?.details?.benefits?.[0]?.itemSetId;
}

// 2c: bulk-create the benefit items — ONE item per covered service (externalId = bookings
// service id). Up to 100 per call; category is an empty string; namespace/providerAppId
// repeat 2b's values.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/items/bulk-create-items.md
export async function createBenefitItems(ctx, itemSetId, serviceIds) {
  return req(ctx, "/benefit-programs/v1/bulk/items/create", {
    body: {
      items: serviceIds.map((externalId) => ({
        namespace: PP_NAMESPACE,
        category: "",
        providerAppId: BOOKINGS_APP_ID,
        itemSetId,
        externalId,
      })),
      returnEntity: true,
    },
  });
}

/**
 * Wire a plan to COVER bookings services (per plan). Runs 2a → 2b → 2c in order.
 * serviceIds come from the BOOKINGS seed — seed bookings first.
 */
export async function attachBookingsCoverage(ctx, planId, serviceIds, { creditAmount } = {}) {
  const programDefinitionId = await getProgramDefinition(ctx, planId);
  if (!programDefinitionId) throw new Error(`No program definition for plan ${planId} — is the Pricing Plans app installed?`);
  const itemSetId = await createPoolDefinition(ctx, programDefinitionId, { creditAmount });
  await createBenefitItems(ctx, itemSetId, serviceIds);
  return { itemSetId, serviceIds };
}

/**
 * ONE-CALL seed: install → create plans (ids kept in memory) → per covering plan, wire
 * bookings coverage (2a → 2b → 2c, strictly ordered). The default path.
 */
export async function setupPricingPlans(ctx, { plans = [], currency } = {}) {
  // Before any plan exists: a plan's price is stored in the site currency.
  if (currency) await setSiteCurrency(ctx, currency);
  await installPricingPlansApp(ctx);
  const created = await createPlans(ctx, plans);
  const coverageAttached = [];
  for (let i = 0; i < created.length; i++) {
    const cov = plans[i].bookingsCoverage || {};
    const serviceIds = cov.serviceIds || created[i].coveredServiceIds;
    if (!serviceIds?.length) continue; // plans-only plan — coverage skipped
    const r = await attachBookingsCoverage(ctx, created[i].id, serviceIds, { creditAmount: cov.creditAmount });
    coverageAttached.push({ planId: created[i].id, ...r });
  }
  return {
    plans: created,
    coverageAttached,
    benefitsCreated: coverageAttached.reduce((n, c) => n + c.serviceIds.length, 0),
  };
}

// ---- CLI entry ----------------------------------------------------------------------------------

const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
if (invokedDirectly) {
  const planPath = process.argv[2];
  if (!planPath) {
    console.error("usage: node seed-pricing-plans.mjs <plan.json>   (run from the project root)");
    process.exit(1);
  }
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
  const ctx = makeCtx();
  setupPricingPlans(ctx, plan)
    .then((result) => console.log(JSON.stringify(result, null, 2)))
    .catch((e) => {
      console.error(e.message);
      process.exit(1);
    });
}

SHA-256: 7c871aa36834750d6260054b3ef9f3c80cea716485f8afb5fce467d101d2a549