Setting the file. One moment.
Seed Pricing Plans · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.612
Position 612 of 828
Type JavaScript
Size 13 KB
Lines 302 pricing-plans/seed/ seed-pricing-plans.mjs
JavaScript · 302 lines · 13 KB
// "billingCycle"?: { "period": "DAY"|"WEEK"|"MONTH"|"YEAR", "count" } | null,
15 // "freeTrialDays"?: 14, "setupFee"?: { "name", "amount" } | "fees"?: [{ "name", "amount" }],
16 // "perks"?: ["..."], "termsAndConditions"?, "visibility"?, "buyable"?,
17 // "coveredServiceIds"?: ["<bookings service id>"],
18 // "bookingsCoverage"?: { "serviceIds"?, "creditAmount"? } }] }
19 //
20 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
21 // read the live API reference; every call below
22 // carries a docs: line with its reference page.
23 import { setSiteCurrency } from "../../shared/seed/site.mjs" ;
24 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
25 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
26 import { isMain } from "../../shared/seed/main.mjs" ;
27 import { readFileSync } from "node:fs" ;
28 import { randomUUID } from "node:crypto" ;
29
30 const API = "https://www.wixapis.com" ;
31 const PRICING_PLANS_APP_ID = "1522827f-c56c-a5c9-2ac9-00f9e6ae12d3" ;
32 const BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97" ; // coverage providerAppId — NOT the Plans id
33 const PP_NAMESPACE = "@wix/pricing-plans" ; // literal, with the @ and the slash, in every coverage call
34
35 export function makeCtx ({ cwd = process. cwd () } = {}) {
36 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
37 // a seed there unless --allow-parent is passed after the user confirmed).
38 const siteId = seedSiteId ({ cwd, argv: process.argv });
39 const token = wixToken (siteId, cwd);
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 // Additional fees on the variant (up to 5): each { name (1–40 chars), priceType FIXED_AMOUNT,
89 // fixedAmountOptions: { amount } (decimal string, > 0), appliedAt FIRST_PAYMENT — the only value:
90 // a fee is added to the first payment }. `setupFee` is the one-fee shorthand of `fees`. The
91 // frontend reads them back as fees[].fixedAmountOptions.amount and shows them with the price.
92 // The id is a client-supplied GUID like the variant's and the perks'.
93 function buildFees ( plan ) {
94 const fees = [ ... (plan.setupFee ? [plan.setupFee] : []), ... (plan.fees ?? [])];
95 return fees. map (( f ) => ({
96 id: randomUUID (),
97 name: f.name || "Setup fee" ,
98 priceType: "FIXED_AMOUNT" ,
99 fixedAmountOptions: { amount: String (f.amount) },
100 appliedAt: "FIRST_PAYMENT" ,
101 }));
102 }
103
104 // One pricingVariant with one pricingStrategy (schema allows ≤20 but is "currently limited
105 // to 1"). Amounts are decimal STRINGS ("20.00"); the variant id is a REQUIRED
106 // client-supplied GUID — omitting it returns 400. freeTrialDays (a number, ≤ 999) is the trial
107 // before the first charge — a paid plan's field (nothing to trial on a free plan); fees ride on
108 // the variant too.
109 function buildPricingVariant ( plan ) {
110 const free = (plan.type || "recurring" ) === "free" ;
111 const amount = free ? "0" : String (plan.price);
112 const fees = free ? [] : buildFees (plan);
113 return {
114 id: randomUUID (),
115 name: plan.variantName || "Standard" ,
116 billingTerms: buildBillingTerms (plan),
117 pricingStrategies: [{ flatRate: { amount } }],
118 ... ( ! free && plan.freeTrialDays ? { freeTrialDays: Number (plan.freeTrialDays) } : {}),
119 ... (fees. length ? { fees } : {}),
120 };
121 }
122
123 // plain plan -> Plans V3 create body. status:"ACTIVE" is REQUIRED (omitting → 400
124 // "status value is required"); currency is NOT sent (read-only, site-derived); perks are
125 // display-only bullets, each with a REQUIRED client-supplied GUID.
126 function buildPlan ( plan ) {
127 const free = (plan.type || "recurring" ) === "free" ;
128 return {
129 name: plan.name,
130 description: plan.description,
131 status: "ACTIVE" ,
132 visibility: plan.visibility || "PUBLIC" ,
133 buyable: plan.buyable ?? true ,
134 buyerCanCancel: plan.buyerCanCancel ?? true ,
135 ... (plan.termsAndConditions ? { termsAndConditions: plan.termsAndConditions } : {}),
136 pricingVariants: [ buildPricingVariant (plan)],
137 perks: (plan.perks ?? []). map (( p ) => ({
138 id: randomUUID (),
139 description: typeof p === "string" ? p : p.description,
140 })),
141 // free: cap lifetime reuse. Array + maxCount per the SDK schema (PurchaseLimit[]); the
142 // recipe's older `{ type, count }` object form predates it.
143 ... (free ? { purchaseLimits: [{ type: "PER_MEMBER_LIFETIME" , maxCount: 1 }] } : {}),
144 };
145 }
146
147 // ---- operations ----------------------------------------------------------------------------------
148
149 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
150 export async function installPricingPlansApp ( ctx ) {
151 try {
152 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
153 tenant: { tenantType: "SITE" , id: ctx.siteId },
154 appInstance: { appDefId: PRICING_PLANS_APP_ID , enabled: true },
155 } });
156 } catch {
157 /* already installed is fine */
158 }
159 }
160
161 // Create the plan(s) — Plans V3 has NO bulk-create: one POST per plan. Keep plan.id: the
162 // frontend orders by it AND it is the coverage externalId.
163 // docs: https://dev.wix.com/docs/api-reference/business-solutions/pricing-plans/plans-v3/create-plan.md
164 export async function createPlans ( ctx , plans ) {
165 const out = [];
166 for ( const plan of plans) {
167 const r = await req (ctx, "/pricing-plans/v3/plans" , { body: { plan: buildPlan (plan) } });
168 out. push ({
169 id: r.plan?.id,
170 slug: r.plan?.slug,
171 name: plan.name,
172 coveredServiceIds: plan.coveredServiceIds,
173 });
174 }
175 return out;
176 }
177
178 // ---- bookings coverage via Benefit Programs (SKIP when no bookings in the run) -------------------
179 // Strictly ordered: plan.id -> programDefinition.id -> itemSetId -> items. Do NOT parallelize.
180
181 // 2a: READ the auto-created program definition (the Plans app creates it; you never create
182 // it). Provisioning is ~immediate but async — retry ONCE after a short backoff, don't loop.
183 // docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/program-definitions/introduction.md
184 export async function getProgramDefinition ( ctx , planId ) {
185 const path = `/benefit-programs/v1/program-definitions/by-namespace-and-external-id?externalId=${ planId }&namespace=${ encodeURIComponent ( PP_NAMESPACE ) }` ;
186 try {
187 const r = await req (ctx, path, { method: "GET" });
188 if (r.programDefinition?.id) return r.programDefinition.id;
189 } catch {
190 /* fall through to the single insurance retry */
191 }
192 await sleep ( 1000 );
193 const r = await req (ctx, path, { method: "GET" });
194 return r.programDefinition?.id;
195 }
196
197 // 2b: create ONE pool definition with EXACTLY ONE benefit naming Bookings as the provider.
198 // price "0" = unlimited (default; no creditConfiguration). Limited pack: pass creditAmount →
199 // price "1" + details.creditConfiguration (a SIBLING of benefits[], NOT inside a benefit —
200 // nesting it 400s with "Price should be 0 when credit pool is not set up").
201 // docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/pool-definitions/create-pool-definition.md
202 export async function createPoolDefinition ( ctx , programDefinitionId , { creditAmount } = {}) {
203 const limited = creditAmount != null ;
204 const details = {
205 ... (limited ? { creditConfiguration: { amount: String (creditAmount) } } : {}),
206 benefits: [
207 {
208 benefitKey: randomUUID (),
209 displayName: "Bookings sessions" ,
210 providerAppId: BOOKINGS_APP_ID ,
211 price: limited ? "1" : "0" ,
212 },
213 ],
214 };
215 const r = await req (ctx, "/benefit-programs/v1/pool-definitions" , {
216 body: {
217 poolDefinition: {
218 namespace: PP_NAMESPACE ,
219 displayName: "Bookings benefit" ,
220 programDefinitionIds: [programDefinitionId],
221 details,
222 },
223 cascade: "IMMEDIATELY" ,
224 },
225 });
226 // itemSetId lives at poolDefinition.details.benefits[i].itemSetId — one benefit here, so [0].
227 return r.poolDefinition?.details?.benefits?.[ 0 ]?.itemSetId;
228 }
229
230 // 2c: bulk-create the benefit items — ONE item per covered service (externalId = bookings
231 // service id). Up to 100 per call; category is an empty string; namespace/providerAppId
232 // repeat 2b's values.
233 // docs: https://dev.wix.com/docs/api-reference/business-solutions/benefit-programs/items/bulk-create-items.md
234 export async function createBenefitItems ( ctx , itemSetId , serviceIds ) {
235 return req (ctx, "/benefit-programs/v1/bulk/items/create" , {
236 body: {
237 items: serviceIds. map (( externalId ) => ({
238 namespace: PP_NAMESPACE ,
239 category: "" ,
240 providerAppId: BOOKINGS_APP_ID ,
241 itemSetId,
242 externalId,
243 })),
244 returnEntity: true ,
245 },
246 });
247 }
248
249 /**
250 * Wire a plan to COVER bookings services (per plan). Runs 2a → 2b → 2c in order.
251 * serviceIds come from the BOOKINGS seed — seed bookings first.
252 */
253 export async function attachBookingsCoverage ( ctx , planId , serviceIds , { creditAmount } = {}) {
254 const programDefinitionId = await getProgramDefinition (ctx, planId);
255 if ( ! programDefinitionId) throw new Error ( `No program definition for plan ${ planId } — is the Pricing Plans app installed?` );
256 const itemSetId = await createPoolDefinition (ctx, programDefinitionId, { creditAmount });
257 await createBenefitItems (ctx, itemSetId, serviceIds);
258 return { itemSetId, serviceIds };
259 }
260
261 /**
262 * ONE-CALL seed: install → create plans (ids kept in memory) → per covering plan, wire
263 * bookings coverage (2a → 2b → 2c, strictly ordered). The default path.
264 */
265 export async function setupPricingPlans ( ctx , { plans = [], currency } = {}) {
266 // Before any plan exists: a plan's price is stored in the site currency.
267 if (currency) await setSiteCurrency (ctx, currency);
268 await installPricingPlansApp (ctx);
269 const created = await createPlans (ctx, plans);
270 const coverageAttached = [];
271 for ( let i = 0 ; i < created. length ; i ++ ) {
272 const cov = plans[i].bookingsCoverage || {};
273 const serviceIds = cov.serviceIds || created[i].coveredServiceIds;
274 if ( ! serviceIds?. length ) continue ; // plans-only plan — coverage skipped
275 const r = await attachBookingsCoverage (ctx, created[i].id, serviceIds, { creditAmount: cov.creditAmount });
276 coverageAttached. push ({ planId: created[i].id, ... r });
277 }
278 return {
279 plans: created,
280 coverageAttached,
281 benefitsCreated: coverageAttached. reduce (( n , c ) => n + c.serviceIds. length , 0 ),
282 };
283 }
284
285 // ---- CLI entry ----------------------------------------------------------------------------------
286
287 const invokedDirectly = isMain ( import . meta .url);
288 if (invokedDirectly) {
289 const planPath = process.argv[ 2 ];
290 if ( ! planPath) {
291 console. error ( "usage: node seed-pricing-plans.mjs <plan.json> (run from the project root)" );
292 process. exit ( 1 );
293 }
294 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
295 const ctx = makeCtx ();
296 setupPricingPlans (ctx, plan)
297 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
298 . catch (( e ) => {
299 console. error (e.message);
300 process. exit ( 1 );
301 });
302 }