Setting the file. One moment.
Pricing Plan Definition · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
Next
Bundled file Product Import
lib/ pricing-plan-definition.js
JavaScript · 177 lines · 11 KB
15
const
crypto
=
require
(
'node:crypto'
);
16
17 const SOURCES = Object. freeze ([ 'B1' , 'B2' , 'B3' ]);
18 const SOURCE_LABELS = Object. freeze ({
19 B1: 'subscription products (product type + postmeta)' ,
20 B2: 'membership plans (wps_membership* CPT)' ,
21 B3: 'recurring plan definitions (WP Swings meta)' ,
22 });
23 const OUTCOMES = Object. freeze ({
24 NONE_FOUND: 'plans-none-found' ,
25 WRITTEN: 'plan-written' ,
26 RECONCILED: 'plan-reconciled-existing' ,
27 AMBIGUOUS: 'plan-reconciliation-ambiguous' ,
28 UNMAPPABLE: 'plan-unmappable' ,
29 FAILED: 'plan-failed' ,
30 TARGET_READ_FAILED: 'plan-target-read-failed' ,
31 });
32 // Wix's own limits (plan.json pitfalls, verified live 2026-08-16).
33 const LIMITS = Object. freeze ({ name: 50 , description: 450 , minCycleDays: 7 , maxPlanYears: 10 , maxCycleCount: 2000 , maxTrialDays: 999 });
34 // Conservative for the MAXIMUM check (31-day months, 366-day years): Wix's own rule is calendar
35 // arithmetic, and 121 monthly cycles at 30 days passed a 10-year test the API then refused.
36 const PERIOD_DAYS = Object. freeze ({ DAY: 1 , WEEK: 7 , MONTH: 31 , YEAR: 366 });
37 const PERIODS = Object. freeze ({ day: 'DAY' , week: 'WEEK' , month: 'MONTH' , year: 'YEAR' });
38
39 // The gate. Row counts, never plugin detection (PS-13). All three at zero -> the area does not run.
40 function evaluatePlanSources ( rowCounts = {}) {
41 const counts = {};
42 for ( const source of SOURCES ) {
43 const value = rowCounts[source];
44 const n = value === undefined || value === null ? 0 : Number (value);
45 if ( ! Number. isInteger (n) || n < 0 ) throw new Error ( `plan source ${ source } row count must be a non-negative integer, got ${ JSON . stringify ( value ) }` );
46 counts[source] = n;
47 }
48 const total = SOURCES . reduce (( sum , s ) => sum + counts[s], 0 );
49 return {
50 counts,
51 total,
52 run: total > 0 ,
53 outcome: total > 0 ? null : OUTCOMES . NONE_FOUND ,
54 // The wording is part of the contract: measured absence, not a missing capability.
55 message: total > 0 ? `${ total } plan definition(s) found across ${ SOURCES . filter (( s ) => counts [ s ] > 0 ). join ( ', ' ) }` : 'no pricing plans found in the source (all three plan-shaped sources hold zero rows)' ,
56 };
57 }
58
59 function uuidV5ish ( name ) {
60 const hash = crypto. createHash ( 'sha1' ). update (name). digest ();
61 hash[ 6 ] = (hash[ 6 ] & 0x0f ) | 0x50 ;
62 hash[ 8 ] = (hash[ 8 ] & 0x3f ) | 0x80 ;
63 const hex = hash. toString ( 'hex' );
64 return `${ hex . slice ( 0 , 8 ) }-${ hex . slice ( 8 , 12 ) }-${ hex . slice ( 12 , 16 ) }-${ hex . slice ( 16 , 20 ) }-${ hex . slice ( 20 , 32 ) }` ;
65 }
66
67 function truncate ( text , limit , code , field , findings ) {
68 const value = text === undefined || text === null ? '' : String (text);
69 if (value. length <= limit) return value;
70 findings. push ({ code, field, length: value. length , limit });
71 return value. slice ( 0 , limit);
72 }
73
74 // A normalized source offer -> the Plans V3 create body. The offer is source-agnostic:
75 // { sourceId, name, description, amount, currency?, period: day|week|month|year, interval,
76 // lengthCycles (0 = open-ended), trialDays, signupFee }
77 // Returns { plan, findings, unmappable } -- `unmappable` names the reason when no body can be built.
78 function buildPlanDefinition ( offer = {}, { sourceSiteHost = 'source' } = {}) {
79 const findings = [];
80 if ( ! offer || typeof offer !== 'object' ) throw new Error ( 'buildPlanDefinition needs a source offer object' );
81 if (offer.sourceId === undefined || offer.sourceId === null || String (offer.sourceId). trim () === '' ) {
82 throw new Error ( 'a plan offer needs a sourceId: the variant id and idempotency key derive from it' );
83 }
84 const period = PERIODS [ String (offer.period || '' ). toLowerCase ()];
85 const interval = Number (offer.interval === undefined || offer.interval === null ? 1 : offer.interval);
86 if ( ! period) return { plan: null , findings, unmappable: { code: OUTCOMES . UNMAPPABLE , reason: 'unknown-period' , period: offer.period } };
87 if ( ! Number. isInteger (interval) || interval < 1 ) return { plan: null , findings, unmappable: { code: OUTCOMES . UNMAPPABLE , reason: 'invalid-interval' , interval: offer.interval } };
88 const cycleDays = PERIOD_DAYS [period] * interval;
89 if (cycleDays < LIMITS .minCycleDays) {
90 // Wix's billing cycle is at least 7 days. A source cadence of every 1-6 days cannot be recreated.
91 return { plan: null , findings, unmappable: { code: OUTCOMES . UNMAPPABLE , reason: 'billing-cycle-under-7-days' , cycleDays } };
92 }
93 if (cycleDays > LIMITS .maxPlanYears * 366 ) {
94 // One cycle longer than the whole plan may be: no end type rescues it.
95 return { plan: null , findings, unmappable: { code: OUTCOMES . UNMAPPABLE , reason: 'billing-cycle-over-10-years' , cycleDays } };
96 }
97 const amount = offer.amount === undefined || offer.amount === null ? null : String (offer.amount). trim ();
98 if (amount === null || ! / ^ \d + ( \. \d {1,4} ) ?$ / . test (amount)) {
99 return { plan: null , findings, unmappable: { code: OUTCOMES . UNMAPPABLE , reason: 'amount-not-decimal' , amount: offer.amount } };
100 }
101
102 const name = truncate (offer.name || `Plan ${ offer . sourceId }` , LIMITS .name, 'plan-name-truncated' , 'name' , findings);
103 // The Stores-product linkage is severed by design; the source reference lives in the description.
104 const sourceRef = `Source: ${ sourceSiteHost } offer ${ offer . sourceId }${ offer . name ? ` (${ offer . name })` : ''}` ;
105 const description = truncate ([offer.description || '' , sourceRef]. filter (Boolean). join ( ' \n ' ), LIMITS .description, 'plan-description-truncated' , 'description' , findings);
106
107 const billingTerms = { billingCycle: { period, count: String (interval) }, startType: 'ON_PURCHASE' };
108 const lengthCycles = Number (offer.lengthCycles === undefined || offer.lengthCycles === null ? 0 : offer.lengthCycles);
109 if ( ! Number. isInteger (lengthCycles) || lengthCycles < 0 ) return { plan: null , findings, unmappable: { code: OUTCOMES . UNMAPPABLE , reason: 'invalid-length' , lengthCycles: offer.lengthCycles } };
110 if (lengthCycles === 0 ) {
111 billingTerms.endType = 'UNTIL_CANCELLED' ;
112 } else if (lengthCycles > LIMITS .maxCycleCount || cycleDays * lengthCycles > LIMITS .maxPlanYears * 366 ) {
113 // Longer than Wix allows: open-ended, with the loss recorded.
114 billingTerms.endType = 'UNTIL_CANCELLED' ;
115 findings. push ({ code: 'plan-duration-exceeds-limit' , lengthCycles, cycleDays, note: 'imported as UNTIL_CANCELLED' });
116 } else {
117 billingTerms.endType = 'CYCLES_COMPLETED' ;
118 billingTerms.cyclesCompletedDetails = { billingCycleCount: String (lengthCycles) };
119 }
120
121 const variant = {
122 // Client-minted, preserved verbatim by Wix (verified live 2026-08-16), so it is stable per offer.
123 id: uuidV5ish ( `replatform:pricing-plan-variant:${ sourceSiteHost }:${ offer . sourceId }` ),
124 name: `${ interval > 1 ? `Every ${ interval } ${ period . toLowerCase () }s` : `${ period . charAt ( 0 ) }${ period . slice ( 1 ). toLowerCase () }ly`}` . replace ( 'Dayly' , 'Daily' ). replace ( 'Monthly' , 'Monthly' ),
125 billingTerms,
126 pricingStrategies: [{ flatRate: { amount } }],
127 };
128 const rawTrial = Number (offer.trialDays === undefined || offer.trialDays === null ? 0 : offer.trialDays);
129 const trialDays = Number. isFinite (rawTrial) ? Math. floor (rawTrial) : 0 ;
130 if (Number. isFinite (rawTrial) && rawTrial !== trialDays) findings. push ({ code: 'plan-trial-rounded' , trialDays: rawTrial, used: trialDays });
131 if (Number. isInteger (trialDays) && trialDays > 0 ) {
132 variant.freeTrialDays = Math. min (trialDays, LIMITS .maxTrialDays);
133 if (trialDays > LIMITS .maxTrialDays) findings. push ({ code: 'plan-trial-truncated' , trialDays, limit: LIMITS .maxTrialDays });
134 }
135 const signupFee = offer.signupFee === undefined || offer.signupFee === null ? null : String (offer.signupFee). trim ();
136 if (signupFee && / ^ \d + ( \. \d {1,4} ) ?$ / . test (signupFee) && Number (signupFee) > 0 ) {
137 variant.fees = [{ name: 'Signup fee' , priceType: 'FIXED_AMOUNT' , fixedAmountOptions: { amount: signupFee }, appliedAt: 'FIRST_PAYMENT' }];
138 }
139
140 const plan = {
141 name,
142 description,
143 // The two flags that keep an imported definition inert, plus the status
144 // the API requires and the docs do not list (plan.json: status-required-on-create).
145 visibility: 'PRIVATE' ,
146 buyable: false ,
147 status: 'ACTIVE' ,
148 buyerCanCancel: true ,
149 pricingVariants: [variant],
150 };
151 return { plan, findings, unmappable: null , idempotencyKey: uuidV5ish ( `replatform:pricing-plan:${ sourceSiteHost }:${ offer . sourceId }` ) };
152 }
153
154 // The two invariants a test pins on the request body, kept as a function so the writer can refuse
155 // a body that lost them between here and the send.
156 function assertInertPlan ( plan ) {
157 if ( ! plan || plan.visibility !== 'PRIVATE' || plan.buyable !== false ) {
158 throw new Error ( 'a migrated plan definition must be visibility PRIVATE and buyable false; anything else becomes purchasable on a live site' );
159 }
160 }
161
162 // Where ensurePlanDefinition's outcomes fold in the report: reconciled-existing is on the target,
163 // so it counts as written; everything else is skipped, reasons kept for the notes.
164 // Takes the writer's result objects (preferred) or bare outcome strings. A `plan-failed` whose
165 // purchasable plan could NOT be archived (`error.archived === false`) is a live-site hazard, counted
166 // on its own line so the report blocks on it -- review demonstrated the archive's 403 reported as
167 // "archived: true" while the plan stayed buyable.
168 function tallyPlanWriteOutcomes ( results = []) {
169 const outcomes = results. map (( r ) => (r && typeof r === 'object' ? r.outcome : r));
170 const written = outcomes. filter (( o ) => o === OUTCOMES . WRITTEN || o === OUTCOMES . RECONCILED ). length ;
171 const skippedBy = {};
172 for ( const o of outcomes) { if (o === OUTCOMES . WRITTEN || o === OUTCOMES . RECONCILED ) continue ; skippedBy[o] = (skippedBy[o] || 0 ) + 1 ; }
173 const hazards = results. filter (( r ) => r && typeof r === 'object' && r.outcome === OUTCOMES . FAILED && r.error && r.error.cause === 'created-plan-not-inert' && r.error.archived === false ). length ;
174 return { 'plans-written' : written, 'plans-skipped' : outcomes. length - written, 'plans-live-hazard' : hazards, skippedBy };
175 }
176
177 module . exports = { SOURCES, SOURCE_LABELS, OUTCOMES, LIMITS, evaluatePlanSources, buildPlanDefinition, assertInertPlan, tallyPlanWriteOutcomes };