Setting the file. One moment.
Seed Pricing Plans · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 7.100
Position 100 of 148
Type JavaScript
Size 12 KB
Lines 279 references/pricing-plans/seed/ seed-pricing-plans.mjs
JavaScript · 279 lines · 12 KB
// "billingCycle"?: { "period": "DAY"|"WEEK"|"MONTH"|"YEAR", "count" } | null,
15 // "perks"?: ["..."], "termsAndConditions"?, "visibility"?, "buyable"?,
16 // "coveredServiceIds"?: ["<bookings service id>"],
17 // "bookingsCoverage"?: { "serviceIds"?, "creditAmount"? } }] }
18 //
19 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
20 // read the live API reference; authoritative source recipe:
21 // wix-headless/references/inline-recipes/setup-pricing-plans.md.
22 import { execFileSync } from "node:child_process" ;
23 import { readFileSync } from "node:fs" ;
24 import { randomUUID } from "node:crypto" ;
25
26 const API = "https://www.wixapis.com" ;
27 const PRICING_PLANS_APP_ID = "1522827f-c56c-a5c9-2ac9-00f9e6ae12d3" ;
28 const BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97" ; // coverage providerAppId — NOT the Plans id
29 const PP_NAMESPACE = "@wix/pricing-plans" ; // literal, with the @ and the slash, in every coverage call
30
31 export function makeCtx ({ cwd = process. cwd () } = {}) {
32 const config = JSON . parse ( readFileSync ( `${ cwd }/wix.config.json` , "utf8" ));
33 const siteId = config.siteId ?? config.projectId;
34 if ( ! siteId) throw new Error ( "wix.config.json has no siteId — is this a Wix CLI project?" );
35 const token = execFileSync ( "npx" , [ "@wix/cli@latest" , "token" , "--site" , siteId], {
36 encoding: "utf8" ,
37 cwd,
38 }). trim ();
39 if ( ! token) throw new Error ( "The Wix CLI returned no token — run `npx @wix/cli@latest login` first." );
40 return { token, siteId };
41 }
42
43 async function req ( ctx , path , { method = "POST" , body } = {}) {
44 const res = await fetch ( API + path, {
45 method,
46 headers: {
47 Authorization: `Bearer ${ ctx . token }` ,
48 "wix-site-id" : ctx.siteId,
49 "Content-Type" : "application/json" ,
50 },
51 body: body ? JSON . stringify (body) : undefined ,
52 });
53 const json = await res. json (). catch (() => ({}));
54 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
55 return json;
56 }
57
58 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
59
60 // billingTerms selects the plan type (recurring | one-time | free). recurring: billingCycle
61 // {period,count} (default MONTH/1) + ON_PURCHASE + UNTIL_CANCELLED. one-time: endType
62 // CYCLES_COMPLETED + billingCycleCount 1 (bill once, then ends); an unlimited one-time
63 // instead sets billingCycle:null + UNTIL_CANCELLED. free: MUST be the no-cycle unlimited
64 // shape — a $0 variant with a billingCycle is rejected ("Free pricing variant cannot be
65 // recurring").
66 function buildBillingTerms ( plan ) {
67 const type = plan.type || "recurring" ;
68 if (type === "free" ) {
69 return { billingCycle: null , startType: "ON_PURCHASE" , endType: "UNTIL_CANCELLED" };
70 }
71 if (type === "one-time" ) {
72 if (plan.billingCycle === null ) {
73 return { billingCycle: null , startType: "ON_PURCHASE" , endType: "UNTIL_CANCELLED" };
74 }
75 const cycle = plan.billingCycle || { period: "MONTH" , count: 1 };
76 return {
77 billingCycle: cycle,
78 startType: "ON_PURCHASE" ,
79 endType: "CYCLES_COMPLETED" ,
80 cyclesCompletedDetails: { billingCycleCount: 1 },
81 };
82 }
83 // recurring
84 const cycle = plan.billingCycle || { period: "MONTH" , count: 1 };
85 return { billingCycle: cycle, startType: "ON_PURCHASE" , endType: "UNTIL_CANCELLED" };
86 }
87
88 // One pricingVariant with one pricingStrategy (schema allows ≤20 but is "currently limited
89 // to 1"). Amounts are decimal STRINGS ("20.00"); the variant id is a REQUIRED
90 // client-supplied GUID — omitting it returns 400.
91 function buildPricingVariant ( plan ) {
92 const free = (plan.type || "recurring" ) === "free" ;
93 const amount = free ? "0" : String (plan.price);
94 return {
95 id: randomUUID (),
96 name: plan.variantName || "Standard" ,
97 billingTerms: buildBillingTerms (plan),
98 pricingStrategies: [{ flatRate: { amount } }],
99 };
100 }
101
102 // plain plan -> Plans V3 create body. status:"ACTIVE" is REQUIRED (omitting → 400
103 // "status value is required"); currency is NOT sent (read-only, site-derived); perks are
104 // display-only bullets, each with a REQUIRED client-supplied GUID.
105 function buildPlan ( plan ) {
106 const free = (plan.type || "recurring" ) === "free" ;
107 return {
108 name: plan.name,
109 description: plan.description,
110 status: "ACTIVE" ,
111 visibility: plan.visibility || "PUBLIC" ,
112 buyable: plan.buyable ?? true ,
113 buyerCanCancel: plan.buyerCanCancel ?? true ,
114 ... (plan.termsAndConditions ? { termsAndConditions: plan.termsAndConditions } : {}),
115 pricingVariants: [ buildPricingVariant (plan)],
116 perks: (plan.perks ?? []). map (( p ) => ({
117 id: randomUUID (),
118 description: typeof p === "string" ? p : p.description,
119 })),
120 // free: cap lifetime reuse. Array + maxCount per the SDK schema (PurchaseLimit[]); the
121 // recipe's older `{ type, count }` object form predates it.
122 ... (free ? { purchaseLimits: [{ type: "PER_MEMBER_LIFETIME" , maxCount: 1 }] } : {}),
123 };
124 }
125
126 // ---- operations ----------------------------------------------------------------------------------
127
128 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
129 export async function installPricingPlansApp ( ctx ) {
130 try {
131 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
132 tenant: { tenantType: "SITE" , id: ctx.siteId },
133 appInstance: { appDefId: PRICING_PLANS_APP_ID , enabled: true },
134 } });
135 } catch {
136 /* already installed is fine */
137 }
138 }
139
140 // Create the plan(s) — Plans V3 has NO bulk-create: one POST per plan. Keep plan.id: the
141 // frontend orders by it AND it is the coverage externalId.
142 // docs: https://dev.wix.com/docs/api-reference/business-solutions/pricing-plans/plans-v3/create-plan.md
143 export async function createPlans ( ctx , plans ) {
144 const out = [];
145 for ( const plan of plans) {
146 const r = await req (ctx, "/pricing-plans/v3/plans" , { body: { plan: buildPlan (plan) } });
147 out. push ({
148 id: r.plan?.id,
149 slug: r.plan?.slug,
150 name: plan.name,
151 coveredServiceIds: plan.coveredServiceIds,
152 });
153 }
154 return out;
155 }
156
157 // ---- bookings coverage via Benefit Programs (SKIP when no bookings in the run) -------------------
158 // Strictly ordered: plan.id -> programDefinition.id -> itemSetId -> items. Do NOT parallelize.
159
160 // 2a: READ the auto-created program definition (the Plans app creates it; you never create
161 // it). Provisioning is ~immediate but async — retry ONCE after a short backoff, don't loop.
162 // docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/program-definitions/introduction.md
163 export async function getProgramDefinition ( ctx , planId ) {
164 const path = `/benefit-programs/v1/program-definitions/by-namespace-and-external-id?externalId=${ planId }&namespace=${ encodeURIComponent ( PP_NAMESPACE ) }` ;
165 try {
166 const r = await req (ctx, path, { method: "GET" });
167 if (r.programDefinition?.id) return r.programDefinition.id;
168 } catch {
169 /* fall through to the single insurance retry */
170 }
171 await sleep ( 1000 );
172 const r = await req (ctx, path, { method: "GET" });
173 return r.programDefinition?.id;
174 }
175
176 // 2b: create ONE pool definition with EXACTLY ONE benefit naming Bookings as the provider.
177 // price "0" = unlimited (default; no creditConfiguration). Limited pack: pass creditAmount →
178 // price "1" + details.creditConfiguration (a SIBLING of benefits[], NOT inside a benefit —
179 // nesting it 400s with "Price should be 0 when credit pool is not set up").
180 // docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/pool-definitions/create-pool-definition.md
181 export async function createPoolDefinition ( ctx , programDefinitionId , { creditAmount } = {}) {
182 const limited = creditAmount != null ;
183 const details = {
184 ... (limited ? { creditConfiguration: { amount: String (creditAmount) } } : {}),
185 benefits: [
186 {
187 benefitKey: randomUUID (),
188 displayName: "Bookings sessions" ,
189 providerAppId: BOOKINGS_APP_ID ,
190 price: limited ? "1" : "0" ,
191 },
192 ],
193 };
194 const r = await req (ctx, "/benefit-programs/v1/pool-definitions" , {
195 body: {
196 poolDefinition: {
197 namespace: PP_NAMESPACE ,
198 displayName: "Bookings benefit" ,
199 programDefinitionIds: [programDefinitionId],
200 details,
201 },
202 cascade: "IMMEDIATELY" ,
203 },
204 });
205 // itemSetId lives at poolDefinition.details.benefits[i].itemSetId — one benefit here, so [0].
206 return r.poolDefinition?.details?.benefits?.[ 0 ]?.itemSetId;
207 }
208
209 // 2c: bulk-create the benefit items — ONE item per covered service (externalId = bookings
210 // service id). Up to 100 per call; category is an empty string; namespace/providerAppId
211 // repeat 2b's values.
212 // docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/items/bulk-create-items.md
213 export async function createBenefitItems ( ctx , itemSetId , serviceIds ) {
214 return req (ctx, "/benefit-programs/v1/bulk/items/create" , {
215 body: {
216 items: serviceIds. map (( externalId ) => ({
217 namespace: PP_NAMESPACE ,
218 category: "" ,
219 providerAppId: BOOKINGS_APP_ID ,
220 itemSetId,
221 externalId,
222 })),
223 returnEntity: true ,
224 },
225 });
226 }
227
228 /**
229 * Wire a plan to COVER bookings services (per plan). Runs 2a → 2b → 2c in order.
230 * serviceIds come from the BOOKINGS seed — seed bookings first.
231 */
232 export async function attachBookingsCoverage ( ctx , planId , serviceIds , { creditAmount } = {}) {
233 const programDefinitionId = await getProgramDefinition (ctx, planId);
234 if ( ! programDefinitionId) throw new Error ( `No program definition for plan ${ planId } — is the Pricing Plans app installed?` );
235 const itemSetId = await createPoolDefinition (ctx, programDefinitionId, { creditAmount });
236 await createBenefitItems (ctx, itemSetId, serviceIds);
237 return { itemSetId, serviceIds };
238 }
239
240 /**
241 * ONE-CALL seed: install → create plans (ids kept in memory) → per covering plan, wire
242 * bookings coverage (2a → 2b → 2c, strictly ordered). The default path.
243 */
244 export async function setupPricingPlans ( ctx , { plans = [] } = {}) {
245 await installPricingPlansApp (ctx);
246 const created = await createPlans (ctx, plans);
247 const coverageAttached = [];
248 for ( let i = 0 ; i < created. length ; i ++ ) {
249 const cov = plans[i].bookingsCoverage || {};
250 const serviceIds = cov.serviceIds || created[i].coveredServiceIds;
251 if ( ! serviceIds?. length ) continue ; // plans-only plan — coverage skipped
252 const r = await attachBookingsCoverage (ctx, created[i].id, serviceIds, { creditAmount: cov.creditAmount });
253 coverageAttached. push ({ planId: created[i].id, ... r });
254 }
255 return {
256 plans: created,
257 coverageAttached,
258 benefitsCreated: coverageAttached. reduce (( n , c ) => n + c.serviceIds. length , 0 ),
259 };
260 }
261
262 // ---- CLI entry ----------------------------------------------------------------------------------
263
264 const invokedDirectly = process.argv[ 1 ] && import . meta .url. endsWith (process.argv[ 1 ]. split ( "/" ). pop ());
265 if (invokedDirectly) {
266 const planPath = process.argv[ 2 ];
267 if ( ! planPath) {
268 console. error ( "usage: node seed-pricing-plans.mjs <plan.json> (run from the project root)" );
269 process. exit ( 1 );
270 }
271 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
272 const ctx = makeCtx ();
273 setupPricingPlans (ctx, plan)
274 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
275 . catch (( e ) => {
276 console. error (e.message);
277 process. exit ( 1 );
278 });
279 }