Setting the file. One moment.
Tax Build · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page Post
This file
Number 44.111
Position 111 of 115
Type JavaScript
Size 12 KB
Lines 231 lib/ tax-build.js
JavaScript · 231 lines · 12 KB
13 // Scope: Tax Groups / Tax Regions / Manual Tax Mappings only. Tax Settings is a single
14 // site-level upsert (see domains/tax/entities/tax-settings.json) with no payload-shaping
15 // logic worth a builder — just a boolean passthrough.
16
17 // ISO 3166-1 alpha-2 countries Wix accepts a `subdivision` for (dev.wix.com Tax Region object,
18 // verified live 2026-08-12 on the reference store). Any other country must omit subdivision (or pass '*').
19 const SUBDIVISION_ELIGIBLE_COUNTRIES = new Set ([
20 'AU' , 'BR' , 'CA' , 'FR' , 'DE' , 'IN' , 'IT' , 'MX' , 'NL' , 'PT' , 'ES' , 'AE' , 'GB' , 'US' ,
21 ]);
22
23 // Countries Tax Region create rejects outright (dev.wix.com, "embargoed-countries-rejected").
24 const EMBARGOED_COUNTRIES = new Set ([ 'CU' , 'IR' , 'KP' , 'SY' ]);
25
26 // WooCommerce's `state` column format varies by version/site — sometimes bare ("NY"), sometimes
27 // country-prefixed ("US-NY"). Wix stores ISO 3166-2 WITHOUT the country prefix. Never invent a
28 // subdivision for a country outside the eligible set — pass none (country-level region) instead.
29 function normalizeSubdivision ( country , rawState ) {
30 const countryCode = String (country || '' ). trim (). toUpperCase ();
31 if ( isBlank (rawState)) return undefined ;
32 if ( ! SUBDIVISION_ELIGIBLE_COUNTRIES . has (countryCode)) return undefined ;
33 const raw = String (rawState). trim (). toUpperCase ();
34 const prefix = `${ countryCode }-` ;
35 return raw. startsWith (prefix) ? raw. slice (prefix. length ) : raw;
36 }
37
38 // WooCommerce `rate` is a PERCENTAGE (e.g. "7.5000"). Wix `taxRate` is a decimal-string FRACTION
39 // with at most 6 decimal places (e.g. "0.075"). A number, integer, or percent-string all 400.
40 function toWixTaxRateFraction ( percentage ) {
41 const n = Number (percentage);
42 if ( ! Number. isFinite (n)) {
43 throw new Error ( `toWixTaxRateFraction: "${ percentage }" is not a finite number` );
44 }
45 let fixed = (n / 100 ). toFixed ( 6 );
46 if (fixed. includes ( '.' )) {
47 fixed = fixed. replace ( /0 +$ / , '' );
48 if (fixed. endsWith ( '.' )) fixed += '0' ;
49 }
50 return fixed;
51 }
52
53 // One Wix Tax Region per distinct (country, subdivision) pair — a source can have several rate
54 // rows (standard/reduced/zero) sharing one region, which fan out to separate Manual Tax Mappings
55 // against that ONE region rather than one region each.
56 function groupTaxRatesByRegion ( taxRates ) {
57 const regions = new Map ();
58 for ( const rate of taxRates || []) {
59 const country = String (rate.country || '' ). trim (). toUpperCase ();
60 if ( ! country) continue ;
61 const subdivision = normalizeSubdivision (country, rate.state);
62 const key = taxRegionDedupeKey ({ country, subdivision });
63 if ( ! regions. has (key)) {
64 regions. set (key, { country, subdivision, rates: [] });
65 }
66 regions. get (key).rates. push (rate);
67 }
68 return [ ... regions. values ()];
69 }
70
71 function taxRegionDedupeKey ({ country , subdivision }) {
72 return `${ String ( country || '' ). trim (). toUpperCase () }|${ subdivision || ''}` ;
73 }
74
75 function isEmbargoedCountry ( country ) {
76 return EMBARGOED_COUNTRIES . has ( String (country || '' ). trim (). toUpperCase ());
77 }
78
79 // `appId` must be resolved live per site via listTaxCalculators/resolveManualTaxCalculatorAppId
80 // in wix-writers.js — never hardcoded here, since it's installation-scoped and differs per site.
81 function buildTaxRegionInput ({ country , subdivision }, { appId , taxIncludedInPrice = false }) {
82 if ( ! appId) throw new Error ( 'buildTaxRegionInput: appId is required (resolve live, never hardcode)' );
83 const taxRegion = { country: String (country). trim (). toUpperCase (), appId, taxIncludedInPrice };
84 if (subdivision) taxRegion.subdivision = subdivision;
85 return taxRegion;
86 }
87
88 function buildTaxGroupInput ({ name }) {
89 if ( isBlank (name)) throw new Error ( 'buildTaxGroupInput: name is required' );
90 return { name: String (name). trim () };
91 }
92
93 // `taxGroupId`/`taxRegionId` must already exist — resolve both from their own crosswalks before
94 // calling. `taxName` is cosmetic (shown at checkout) and never affects the calculated amount.
95 function buildManualTaxMappingInput ( rate , { taxGroupId , taxRegionId }) {
96 if ( ! taxGroupId) throw new Error ( 'buildManualTaxMappingInput: taxGroupId is required' );
97 if ( ! taxRegionId) throw new Error ( 'buildManualTaxMappingInput: taxRegionId is required' );
98 const mapping = {
99 taxGroupId,
100 taxRegionId,
101 taxRate: toWixTaxRateFraction (rate.rate),
102 };
103 if ( ! isBlank (rate.name)) mapping.taxName = String (rate.name). trim ();
104 return mapping;
105 }
106
107 // Create Manual Tax Mapping 409s on a duplicate (taxRegionId, taxGroupId, taxName, taxType,
108 // jurisdiction, jurisdictionType) — dedupe on that composite key before creating, since
109 // WooCommerce rows differing only in `priority` (no Wix equivalent) would otherwise re-collide.
110 //
111 // LIVE-VERIFIED 2026-08-15: Query Manual Tax Mapping returns the STRING "UNDEFINED" for an unset
112 // `jurisdictionType` (not "" and not the field's absence), while a freshly-built mappingInput that
113 // never sets the field is plain JS `undefined` — so comparing a fetched mapping against a
114 // newly-built one for the same (region, group) 409'd instead of correctly deduping. Normalize both
115 // "" and "UNDEFINED" to the same bucket so a fetched existing mapping and a freshly-built one that
116 // both mean "no jurisdictionType" produce the same key.
117 function normalizeDedupeField ( value ) {
118 const s = String (value || '' ). trim ();
119 return s === 'UNDEFINED' ? '' : s;
120 }
121 function manualTaxMappingDedupeKey ({ taxRegionId , taxGroupId , taxName , taxType , jurisdiction , jurisdictionType }) {
122 return [
123 taxRegionId,
124 taxGroupId,
125 normalizeDedupeField (taxName),
126 normalizeDedupeField (taxType),
127 normalizeDedupeField (jurisdiction),
128 normalizeDedupeField (jurisdictionType),
129 ]. join ( '|' );
130 }
131
132 // The four Wix "default" tax groups every site has (VERIFIED live 2026-08-12 on the reference store via
133 // List Default Tax Groups: "Shipping and delivery", "Products", "Services", "Cancellation
134 // fees" — see tax-group.json's query-tax-groups-excludes-defaults pitfall). WooCommerce has no
135 // equivalent split: a `class: "standard"` rate (or a blank class) is the store's ONE general
136 // rate, not a products-only rate — WooCommerce's own `shipping` boolean on a tax rate row
137 // exists specifically because the rate is meant to reach beyond products. Wix's
138 // Products/Shipping and delivery/Services/Cancellation fees groups are BILLING categories, not
139 // TAX-RATE categories, so mapping a standard rate to "Products" only silently zero-rates every
140 // other category in that region — a group with no manual tax mapping calculates to EXACTLY
141 // ZERO tax (VERIFIED live 2026-08-12, see manual-tax-mapping.json's
142 // unmapped-tax-group-calculates-to-exactly-zero-tax finding). That is undercharging, not a
143 // faithful migration, so a standard-class rate must get one mapping per default group.
144 const DEFAULT_GROUP_NAMES = [ 'Products' , 'Shipping and delivery' , 'Services' , 'Cancellation fees' ];
145
146 // Which already-created Wix tax groups a WooCommerce rate's Manual Tax Mapping(s) should
147 // target. `defaultGroups` is List Default Tax Groups' result; `customGroups` is this project's
148 // own created-group tracking, each carrying the `sourceTaxClass` it was created for (from
149 // planCustomTaxGroups's plan — a plain Query Tax Groups result has no such field, since Wix
150 // itself doesn't know why a custom group exists).
151 //
152 // A `standard` (or blank) class rate targets every DEFAULT group — never a class-specific
153 // custom group (a reduced-rate/zero-rate group exists so a product TAGGED with that class gets
154 // a different rate than the store's general one, not the same rate broadened everywhere), and
155 // never the Tax Exempt group (no mapping is how exempt is represented at all — this falls out
156 // naturally here since Tax Exempt's `sourceTaxClass` is null and no rate's class is ever null).
157 // A non-standard class rate targets ONLY its own matching custom group.
158 //
159 // List Default Tax Groups' own schema (dev.wix.com, checked 2026-08-15) has no locale-invariant
160 // key for "which billing category is this" — a TaxGroup is only {id, name, revision, dates}, and
161 // the docs' own worked example returns a completely different default set ("Standard Tax") than
162 // the reference store's real one, confirming `name` genuinely varies per site/locale and isn't a fixed
163 // constant. `name` is matched here anyway because it's the ONLY field the API offers for this —
164 // but matching zero (or some but not all four) of DEFAULT_GROUP_NAMES on a translated or
165 // differently-configured site must never fall through to a silent partial fan-out: that would
166 // reproduce the exact silent-undercharging bug this function exists to fix, just triggered by
167 // site language/config instead of an incomplete implementation. Fail loudly instead, so a human
168 // resolves the real name mismatch before any mapping is created.
169 function groupsForRate ( rate , { defaultGroups = [], customGroups = [] } = {}) {
170 const taxClass = String (rate?.class || 'standard' ). trim () || 'standard' ;
171 if (taxClass === 'standard' ) {
172 const matched = defaultGroups. filter (( group ) => DEFAULT_GROUP_NAMES . includes ( String (group?.name || '' ). trim ()));
173 if (matched. length !== DEFAULT_GROUP_NAMES . length ) {
174 const foundNames = matched. map (( group ) => group.name);
175 const missing = DEFAULT_GROUP_NAMES . filter (( name ) => ! foundNames. includes (name));
176 throw new Error (
177 `groupsForRate: expected all ${ DEFAULT_GROUP_NAMES . length } default tax groups (${ DEFAULT_GROUP_NAMES . join ( ', ' ) }), `
178 + `found only ${ matched . length } (missing: ${ missing . join ( ', ' ) }). This site's default tax group names may be `
179 + 'translated or otherwise non-standard -- resolve the real names via List Default Tax Groups before mapping, '
180 + 'do not silently map to a partial set.' ,
181 );
182 }
183 return matched;
184 }
185 return customGroups. filter (( group ) => group?.sourceTaxClass === taxClass);
186 }
187
188 // Only create a custom tax group when a real per-product signal exists — never speculatively,
189 // just because wc/v3/taxes/classes lists WooCommerce's unused defaults (reduced-rate/zero-rate).
190 // `products` here is the discovered wc/v3/products record shape (`tax_class`, `tax_status`).
191 function planCustomTaxGroups ( products ) {
192 const plans = [];
193 const classesInUse = new Set ();
194 let hasExempt = false ;
195 for ( const product of products || []) {
196 const taxClass = String (product.tax_class || 'standard' ). trim () || 'standard' ;
197 if (taxClass !== 'standard' ) classesInUse. add (taxClass);
198 if ( String (product.tax_status || 'taxable' ). trim () === 'none' ) hasExempt = true ;
199 }
200 for ( const taxClass of classesInUse) {
201 plans. push ({ name: taxClass, sourceTaxClass: taxClass, reason: 'non-default WooCommerce tax_class actually assigned to at least one product' });
202 }
203 if (hasExempt) {
204 plans. push ({ name: 'Tax Exempt' , sourceTaxClass: null , exempt: true , reason: 'at least one product has tax_status=none — dedicated group, deliberately no manual tax mapping in any region' });
205 }
206 return plans;
207 }
208
209 // "No source has any tax-rate rows" is the majority case (the reference store, 2026-08-12: 0 rows) — the
210 // correct action is to create nothing speculative, not to pre-create empty regions/mappings.
211 function shouldSkipTaxDomain ( taxRates ) {
212 return ! Array. isArray (taxRates) || taxRates. length === 0 ;
213 }
214
215 module . exports = {
216 SUBDIVISION_ELIGIBLE_COUNTRIES,
217 EMBARGOED_COUNTRIES,
218 normalizeSubdivision,
219 toWixTaxRateFraction,
220 groupTaxRatesByRegion,
221 taxRegionDedupeKey,
222 isEmbargoedCountry,
223 buildTaxRegionInput,
224 buildTaxGroupInput,
225 buildManualTaxMappingInput,
226 manualTaxMappingDedupeKey,
227 DEFAULT_GROUP_NAMES,
228 groupsForRate,
229 planCustomTaxGroups,
230 shouldSkipTaxDomain,
231 };