@teleporthq/teleport-shared 0.43.65 → 0.43.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/utils/order-line-tax.ts +302 -0
- package/__tests__/utils/regional-pricing.ts +1056 -0
- package/dist/cjs/index.d.ts +2 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils/regional-pricing.d.ts +46 -0
- package/dist/cjs/utils/regional-pricing.d.ts.map +1 -0
- package/dist/cjs/utils/regional-pricing.js +50 -0
- package/dist/cjs/utils/regional-pricing.js.map +1 -0
- package/dist/cjs/utils/storefront-tax.d.ts +17 -0
- package/dist/cjs/utils/storefront-tax.d.ts.map +1 -1
- package/dist/cjs/utils/storefront-tax.js +19 -1
- package/dist/cjs/utils/storefront-tax.js.map +1 -1
- package/dist/cjs/utils/string-utils.d.ts +8 -0
- package/dist/cjs/utils/string-utils.d.ts.map +1 -1
- package/dist/cjs/utils/string-utils.js +14 -1
- package/dist/cjs/utils/string-utils.js.map +1 -1
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils/regional-pricing.d.ts +46 -0
- package/dist/esm/utils/regional-pricing.d.ts.map +1 -0
- package/dist/esm/utils/regional-pricing.js +46 -0
- package/dist/esm/utils/regional-pricing.js.map +1 -0
- package/dist/esm/utils/storefront-tax.d.ts +17 -0
- package/dist/esm/utils/storefront-tax.d.ts.map +1 -1
- package/dist/esm/utils/storefront-tax.js +17 -0
- package/dist/esm/utils/storefront-tax.js.map +1 -1
- package/dist/esm/utils/string-utils.d.ts +8 -0
- package/dist/esm/utils/string-utils.d.ts.map +1 -1
- package/dist/esm/utils/string-utils.js +12 -0
- package/dist/esm/utils/string-utils.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/utils/regional-pricing.ts +597 -0
- package/src/utils/storefront-tax.ts +81 -0
- package/src/utils/string-utils.ts +12 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regional pricing — shipping zones and tax jurisdictions — as ES5 source for
|
|
3
|
+
* the generated storefront (the cart provider and the `cart-get-total` workflow
|
|
4
|
+
* handler), which cannot import from this package at runtime.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ SECOND MIRROR. The editor/canvas copy is teleport-gui's
|
|
7
|
+
* `features/e-commerce/utils/regional-pricing/`, and the two must agree to the
|
|
8
|
+
* cent or the canvas previews one delivery fee and the published store charges
|
|
9
|
+
* another. The GUI's `regional-pricing-parity.spec.ts` and this package's
|
|
10
|
+
* `__tests__/utils/regional-pricing.ts` run the SAME fixture table against their
|
|
11
|
+
* respective copies.
|
|
12
|
+
*
|
|
13
|
+
* The rules the feature rests on:
|
|
14
|
+
*
|
|
15
|
+
* - The store's Delivery Settings price every delivered order — the flat fee
|
|
16
|
+
* and, when switched on, the free-delivery threshold — and a zone OVERRIDES
|
|
17
|
+
* them for the countries it lists (a rest-of-world zone, for every country
|
|
18
|
+
* no other zone lists). No active tax row means the store's single default
|
|
19
|
+
* rate. Empty tables reproduce what the store charged before regions existed.
|
|
20
|
+
* - Tax stays folded into the prices a shopper reads, exactly like the store
|
|
21
|
+
* default: `goodsTax` is only the tax ADDED on top. A region whose prices
|
|
22
|
+
* already include tax contributes to the breakdown, never to the total.
|
|
23
|
+
* - Shipping tax, when a region charges it, is folded into the shipping amount.
|
|
24
|
+
* - Tax follows the checkout address for every order. Fulfilment only decides
|
|
25
|
+
* shipping: a store-pickup order never pays it, and is never refused for an
|
|
26
|
+
* address no zone covers.
|
|
27
|
+
* - A zone with no active rate charges the store's delivery fee under its own
|
|
28
|
+
* threshold and cash-on-delivery rule. A zone whose rates exist but none
|
|
29
|
+
* fits the basket is NO-RATE, which checkout refuses rather than pricing at 0.
|
|
30
|
+
* - A zone marked "do not ship" is UNAVAILABLE: the merchant's explicit
|
|
31
|
+
* refusal (on a rest-of-world zone, "only my zones"). Store pickup is never
|
|
32
|
+
* refused, since a pickup order is never priced as a delivery.
|
|
33
|
+
*
|
|
34
|
+
* Everything is prefixed `__rp` so the block can be concatenated into any
|
|
35
|
+
* generated module without colliding with its locals.
|
|
36
|
+
*/
|
|
37
|
+
export const generateRegionalPricingHelperCode = (): string => `
|
|
38
|
+
// ── Regional pricing: shipping zones + tax jurisdictions ─────────────────────
|
|
39
|
+
// Every helper is a function EXPRESSION, never a declaration: this block is
|
|
40
|
+
// spliced into serialized workflow handlers, whose entry point is found by
|
|
41
|
+
// scanning for declared functions of handler arity — a declared two-argument
|
|
42
|
+
// helper would be mistaken for the entry once a minifier renames it.
|
|
43
|
+
// Currencies whose smallest unit is the whole unit (Stripe's zero-decimal list).
|
|
44
|
+
var __RP_ZERO_DECIMAL_CURRENCIES = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];
|
|
45
|
+
|
|
46
|
+
var __rpDecimals = function (currency) {
|
|
47
|
+
var code = String(currency || '').toUpperCase();
|
|
48
|
+
return __RP_ZERO_DECIMAL_CURRENCIES.indexOf(code) === -1 ? 2 : 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
var __rpRound = function (value, decimals) {
|
|
52
|
+
var factor = decimals === 0 ? 1 : 100;
|
|
53
|
+
var n = Number(value);
|
|
54
|
+
return Math.round((isFinite(n) ? n : 0) * factor) / factor;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// A finite number, or null for anything that is not one — including '' and
|
|
58
|
+
// null, which a nullable DECIMAL column hands back for "not set".
|
|
59
|
+
var __rpNumber = function (value) {
|
|
60
|
+
if (value === null || value === undefined || value === '') return null;
|
|
61
|
+
var n = typeof value === 'number' ? value : parseFloat(value);
|
|
62
|
+
return isFinite(n) ? n : null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Booleans arrive differently per driver (true / 't' / 1 / 'true').
|
|
66
|
+
var __rpBool = function (value, fallback) {
|
|
67
|
+
if (typeof value === 'boolean') return value;
|
|
68
|
+
if (typeof value === 'number') return value !== 0;
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
var s = value.trim().toLowerCase();
|
|
71
|
+
if (s === 'true' || s === 't' || s === '1' || s === 'yes') return true;
|
|
72
|
+
if (s === 'false' || s === 'f' || s === '0' || s === 'no') return false;
|
|
73
|
+
}
|
|
74
|
+
return fallback;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
var __rpText = function (value) {
|
|
78
|
+
return value === null || value === undefined ? '' : String(value).trim();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// A state / region name as matched: case- and spacing-insensitive, so a buyer's
|
|
82
|
+
// "new york" still finds the "New York" row.
|
|
83
|
+
var __rpRegion = function (value) {
|
|
84
|
+
return __rpText(value).replace(/\\s+/g, ' ').toLowerCase();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
var __rpStringArray = function (raw) {
|
|
88
|
+
if (Object.prototype.toString.call(raw) === '[object Array]') return raw;
|
|
89
|
+
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
90
|
+
var text = raw.trim();
|
|
91
|
+
if (text.charAt(0) === '[') {
|
|
92
|
+
try {
|
|
93
|
+
var parsed = JSON.parse(text);
|
|
94
|
+
return Object.prototype.toString.call(parsed) === '[object Array]' ? parsed : [];
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return text.split(/[\\s,;]+/);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ISO 3166-1 alpha-2 codes, upper-cased and de-duplicated. Accepts a JSON array
|
|
103
|
+
// or a comma/space/semicolon separated list, which is what a merchant types
|
|
104
|
+
// into the generated admin.
|
|
105
|
+
var __rpParseCountries = function (raw) {
|
|
106
|
+
var entries = __rpStringArray(raw);
|
|
107
|
+
var codes = [];
|
|
108
|
+
for (var i = 0; i < entries.length; i++) {
|
|
109
|
+
var code = __rpText(entries[i]).toUpperCase();
|
|
110
|
+
if (/^[A-Z]{2}$/.test(code) && codes.indexOf(code) === -1) codes.push(code);
|
|
111
|
+
}
|
|
112
|
+
return codes;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
var __rpByOrder = function (a, b) {
|
|
116
|
+
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
|
117
|
+
if (a.name !== b.name) return a.name < b.name ? -1 : 1;
|
|
118
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
var __rpRows = function (rows) {
|
|
122
|
+
return Object.prototype.toString.call(rows) === '[object Array]' ? rows : [];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
var __rpNormalizeZones = function (rows) {
|
|
126
|
+
var zones = [];
|
|
127
|
+
var list = __rpRows(rows);
|
|
128
|
+
for (var i = 0; i < list.length; i++) {
|
|
129
|
+
var row = list[i];
|
|
130
|
+
if (!row || typeof row !== 'object' || !__rpBool(row.is_active, true)) continue;
|
|
131
|
+
var id = __rpText(row.id);
|
|
132
|
+
if (!id) continue;
|
|
133
|
+
var threshold = __rpNumber(row.free_shipping_threshold);
|
|
134
|
+
zones.push({
|
|
135
|
+
id: id,
|
|
136
|
+
name: __rpText(row.name),
|
|
137
|
+
countries: __rpParseCountries(row.countries),
|
|
138
|
+
isRestOfWorld: __rpBool(row.is_rest_of_world, false),
|
|
139
|
+
doNotShip: __rpBool(row.do_not_ship, false),
|
|
140
|
+
freeShippingThreshold: threshold === null || threshold < 0 ? null : threshold,
|
|
141
|
+
codEnabled: __rpBool(row.cod_enabled, true),
|
|
142
|
+
sortOrder: __rpNumber(row.sort_order) || 0
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return zones.sort(__rpByOrder);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
var __rpNormalizeRates = function (rows) {
|
|
149
|
+
var rates = [];
|
|
150
|
+
var list = __rpRows(rows);
|
|
151
|
+
for (var i = 0; i < list.length; i++) {
|
|
152
|
+
var row = list[i];
|
|
153
|
+
if (!row || typeof row !== 'object' || !__rpBool(row.is_active, true)) continue;
|
|
154
|
+
var id = __rpText(row.id);
|
|
155
|
+
var zoneId = __rpText(row.zone_id);
|
|
156
|
+
if (!id || !zoneId) continue;
|
|
157
|
+
var type = __rpText(row.rate_type).toLowerCase();
|
|
158
|
+
var price = __rpNumber(row.price);
|
|
159
|
+
var min = __rpNumber(row.min_value);
|
|
160
|
+
var max = __rpNumber(row.max_value);
|
|
161
|
+
var tiered = type === 'price' || type === 'weight';
|
|
162
|
+
// A tier with a negative or empty range (a row edited in the generated
|
|
163
|
+
// admin, which cannot validate) is skipped rather than read as unbounded.
|
|
164
|
+
if (tiered && ((min !== null && min < 0) || (max !== null && max < 0) || (min !== null && max !== null && max <= min))) continue;
|
|
165
|
+
rates.push({
|
|
166
|
+
id: id,
|
|
167
|
+
zoneId: zoneId,
|
|
168
|
+
name: __rpText(row.name),
|
|
169
|
+
type: tiered ? type : 'flat',
|
|
170
|
+
price: price === null || price < 0 ? 0 : price,
|
|
171
|
+
min: min === null || min < 0 ? null : min,
|
|
172
|
+
max: max === null || max < 0 ? null : max,
|
|
173
|
+
estimatedDays: __rpText(row.estimated_days),
|
|
174
|
+
sortOrder: __rpNumber(row.sort_order) || 0
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return rates.sort(__rpByOrder);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
var __rpNormalizeTaxRates = function (rows) {
|
|
181
|
+
var taxRates = [];
|
|
182
|
+
var list = __rpRows(rows);
|
|
183
|
+
for (var i = 0; i < list.length; i++) {
|
|
184
|
+
var row = list[i];
|
|
185
|
+
if (!row || typeof row !== 'object' || !__rpBool(row.is_active, true)) continue;
|
|
186
|
+
var id = __rpText(row.id);
|
|
187
|
+
var countryCode = __rpText(row.country_code).toUpperCase();
|
|
188
|
+
if (!id || !/^[A-Z]{2}$/.test(countryCode)) continue;
|
|
189
|
+
var rate = __rpNumber(row.rate);
|
|
190
|
+
var mode = __rpText(row.tax_mode).toLowerCase();
|
|
191
|
+
taxRates.push({
|
|
192
|
+
id: id,
|
|
193
|
+
countryCode: countryCode,
|
|
194
|
+
region: __rpRegion(row.region),
|
|
195
|
+
categoryId: __rpText(row.category_id),
|
|
196
|
+
rate: rate === null || rate < 0 ? 0 : rate,
|
|
197
|
+
mode: mode === 'included' || mode === 'added' ? mode : 'inherit',
|
|
198
|
+
taxShipping: __rpBool(row.tax_shipping, false)
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// By id, so two rows a merchant accidentally made identical resolve the same
|
|
202
|
+
// way on every read, whatever order the database returned them in.
|
|
203
|
+
return taxRates.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// The three tables' rows, as \`/api/data/.../select\` returns them, reduced to
|
|
207
|
+
// the ACTIVE entries in a deterministic order.
|
|
208
|
+
var __rpNormalizeConfig = function (raw) {
|
|
209
|
+
var source = raw && typeof raw === 'object' ? raw : {};
|
|
210
|
+
return {
|
|
211
|
+
zones: __rpNormalizeZones(source.zones),
|
|
212
|
+
rates: __rpNormalizeRates(source.rates),
|
|
213
|
+
taxRates: __rpNormalizeTaxRates(source.taxRates)
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
var __rpWeightKg = function (weight, unit) {
|
|
218
|
+
var n = __rpNumber(weight);
|
|
219
|
+
if (n === null || n <= 0) return 0;
|
|
220
|
+
var u = __rpText(unit).toLowerCase();
|
|
221
|
+
if (u === 'g' || u === 'gr' || u === 'gram' || u === 'grams') return n / 1000;
|
|
222
|
+
if (u === 'lb' || u === 'lbs' || u === 'pound' || u === 'pounds') return n * 0.45359237;
|
|
223
|
+
if (u === 'oz' || u === 'ounce' || u === 'ounces') return n * 0.028349523125;
|
|
224
|
+
return n;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Cart lines as the storefront stores them (NET unit price, quantity, and the
|
|
228
|
+
// weight + categories hydration stamps on), reduced to what pricing reads.
|
|
229
|
+
var __rpLines = function (items) {
|
|
230
|
+
var lines = [];
|
|
231
|
+
var list = __rpRows(items);
|
|
232
|
+
for (var i = 0; i < list.length; i++) {
|
|
233
|
+
var item = list[i];
|
|
234
|
+
if (!item || typeof item !== 'object') continue;
|
|
235
|
+
var price = __rpNumber(item.price);
|
|
236
|
+
var quantity = __rpNumber(item.quantity);
|
|
237
|
+
var categories = __rpStringArray(item.categoryIds);
|
|
238
|
+
var categoryIds = [];
|
|
239
|
+
for (var c = 0; c < categories.length; c++) {
|
|
240
|
+
var categoryId = __rpText(categories[c]);
|
|
241
|
+
if (categoryId && categoryIds.indexOf(categoryId) === -1) categoryIds.push(categoryId);
|
|
242
|
+
}
|
|
243
|
+
lines.push({
|
|
244
|
+
key: item.id !== undefined && item.id !== null ? String(item.id) : String(i),
|
|
245
|
+
productId: __rpText(item.productId),
|
|
246
|
+
variantId: __rpText(item.variantId),
|
|
247
|
+
price: price === null || price < 0 ? 0 : price,
|
|
248
|
+
quantity: quantity === null || quantity <= 0 ? 1 : quantity,
|
|
249
|
+
weightKg: __rpWeightKg(item.weight, item.weightUnit),
|
|
250
|
+
categoryIds: categoryIds
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return lines;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// The most specific ACTIVE row for a destination: a region match outranks a
|
|
257
|
+
// country-wide row, and a category match outranks an uncategorised one. A row
|
|
258
|
+
// scoped to a region or a category that does not apply is not a candidate.
|
|
259
|
+
// Shipping is never a category, so it only ever sees the uncategorised rows.
|
|
260
|
+
var __rpFindTaxRow = function (taxRates, destination, categoryIds, forShipping) {
|
|
261
|
+
if (!destination || !destination.countryCode) return null;
|
|
262
|
+
var region = __rpRegion(destination.region);
|
|
263
|
+
var best = null;
|
|
264
|
+
var bestScore = -1;
|
|
265
|
+
for (var i = 0; i < taxRates.length; i++) {
|
|
266
|
+
var row = taxRates[i];
|
|
267
|
+
if (row.countryCode !== destination.countryCode) continue;
|
|
268
|
+
var score = 0;
|
|
269
|
+
if (row.region) {
|
|
270
|
+
if (row.region !== region) continue;
|
|
271
|
+
score += 2;
|
|
272
|
+
}
|
|
273
|
+
if (row.categoryId) {
|
|
274
|
+
if (forShipping || categoryIds.indexOf(row.categoryId) === -1) continue;
|
|
275
|
+
score += 1;
|
|
276
|
+
}
|
|
277
|
+
if (score > bestScore) {
|
|
278
|
+
best = row;
|
|
279
|
+
bestScore = score;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return best;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
var __rpRowIncluded = function (row, defaultIncluded) {
|
|
286
|
+
if (row.mode === 'inherit') return defaultIncluded;
|
|
287
|
+
return row.mode === 'included';
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// What a stored price costs the buyer at a rate: grossed up when the tax is
|
|
291
|
+
// added on top, the stored price itself when it already includes the tax.
|
|
292
|
+
var __rpGrossAmount = function (amount, rate, included, decimals) {
|
|
293
|
+
var n = __rpNumber(amount);
|
|
294
|
+
var base = n === null ? 0 : n;
|
|
295
|
+
return !included && rate > 0 ? __rpRound(base * (1 + rate / 100), decimals) : base;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
var __rpSafeRate = function (rate) {
|
|
299
|
+
var n = __rpNumber(rate);
|
|
300
|
+
return n === null || n < 0 ? 0 : n;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
var __rpInRange = function (basis, min, max) {
|
|
304
|
+
if (min !== null && basis < min) return false;
|
|
305
|
+
if (max !== null && basis >= max) return false;
|
|
306
|
+
return true;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// The store's own Delivery Settings: what every order no zone overrides pays.
|
|
310
|
+
var __rpStoreShipping = function (store) {
|
|
311
|
+
return {
|
|
312
|
+
base: __rpRound(__rpSafeRate(store.deliveryPrice), 2),
|
|
313
|
+
threshold: store.freeDeliveryEnabled === true ? __rpRound(__rpSafeRate(store.freeDeliveryThreshold), 2) : null
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
var __rpResolveZone = function (zones, countryCode) {
|
|
318
|
+
var restOfWorld = null;
|
|
319
|
+
for (var i = 0; i < zones.length; i++) {
|
|
320
|
+
var zone = zones[i];
|
|
321
|
+
if (zone.isRestOfWorld) {
|
|
322
|
+
if (!restOfWorld) restOfWorld = zone;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (zone.countries.indexOf(countryCode) !== -1) return zone;
|
|
326
|
+
}
|
|
327
|
+
return restOfWorld;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Tax on a shipping fee at the destination's standard rate: added on top, or —
|
|
331
|
+
// where prices include tax — already inside the fee and reported, not charged.
|
|
332
|
+
var __rpShippingTax = function (net, rate, included, decimals) {
|
|
333
|
+
if (!(rate > 0) || !(net > 0)) return { tax: 0, gross: net };
|
|
334
|
+
if (included) return { tax: __rpRound(net - net / (1 + rate / 100), decimals), gross: net };
|
|
335
|
+
var tax = __rpRound(net * rate / 100, decimals);
|
|
336
|
+
return { tax: tax, gross: __rpRound(net + tax, decimals) };
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
var __rpByPrice = function (a, b) {
|
|
340
|
+
if (a.price !== b.price) return a.price - b.price;
|
|
341
|
+
return __rpByOrder(a, b);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/*
|
|
345
|
+
* THE quote. One call prices a basket for one destination.
|
|
346
|
+
*
|
|
347
|
+
* input:
|
|
348
|
+
* config normalized rows (__rpNormalizeConfig)
|
|
349
|
+
* store { deliveryEnabled, storePickupEnabled, deliveryPrice,
|
|
350
|
+
* freeDeliveryEnabled, freeDeliveryThreshold,
|
|
351
|
+
* defaultTaxRate, defaultTaxIncluded }
|
|
352
|
+
* items cart lines (raw)
|
|
353
|
+
* destination { countryCode, countryName, region } or null
|
|
354
|
+
* fulfillment 'delivery' | 'pickup'
|
|
355
|
+
* selectedRateId, currency
|
|
356
|
+
*/
|
|
357
|
+
var __rpQuote = function (input) {
|
|
358
|
+
var source = input && typeof input === 'object' ? input : {};
|
|
359
|
+
var config = source.config && typeof source.config === 'object'
|
|
360
|
+
? source.config
|
|
361
|
+
: { zones: [], rates: [], taxRates: [] };
|
|
362
|
+
var store = source.store && typeof source.store === 'object' ? source.store : {};
|
|
363
|
+
var deliveryEnabled = store.deliveryEnabled === true;
|
|
364
|
+
var delivering = deliveryEnabled && source.fulfillment !== 'pickup';
|
|
365
|
+
var destination = source.destination && typeof source.destination === 'object'
|
|
366
|
+
? {
|
|
367
|
+
countryCode: __rpText(source.destination.countryCode).toUpperCase(),
|
|
368
|
+
countryName: __rpText(source.destination.countryName),
|
|
369
|
+
region: __rpText(source.destination.region)
|
|
370
|
+
}
|
|
371
|
+
: null;
|
|
372
|
+
var defaultRate = __rpSafeRate(store.defaultTaxRate);
|
|
373
|
+
var defaultIncluded = store.defaultTaxIncluded === true;
|
|
374
|
+
var taxRates = __rpRows(config.taxRates);
|
|
375
|
+
var zones = __rpRows(config.zones);
|
|
376
|
+
var taxRegional = taxRates.length > 0;
|
|
377
|
+
var zonesActive = delivering && zones.length > 0;
|
|
378
|
+
var moneyDecimals = __rpDecimals(source.currency);
|
|
379
|
+
// A store with no tax rows keeps the cent rounding it has always used, so its
|
|
380
|
+
// totals are identical to the ones it charged before regions existed.
|
|
381
|
+
var taxDecimals = taxRegional ? moneyDecimals : 2;
|
|
382
|
+
|
|
383
|
+
var lines = __rpLines(source.items);
|
|
384
|
+
var outLines = [];
|
|
385
|
+
var buckets = [];
|
|
386
|
+
var goodsNet = 0;
|
|
387
|
+
var goodsGross = 0;
|
|
388
|
+
var weightKg = 0;
|
|
389
|
+
for (var i = 0; i < lines.length; i++) {
|
|
390
|
+
var line = lines[i];
|
|
391
|
+
var row = taxRegional ? __rpFindTaxRow(taxRates, destination, line.categoryIds, false) : null;
|
|
392
|
+
var rate = row ? row.rate : defaultRate;
|
|
393
|
+
var included = row ? __rpRowIncluded(row, defaultIncluded) : defaultIncluded;
|
|
394
|
+
var unitGross = __rpGrossAmount(line.price, rate, included, taxDecimals);
|
|
395
|
+
var lineGross = __rpRound(unitGross * line.quantity, taxDecimals);
|
|
396
|
+
var lineNet = line.price * line.quantity;
|
|
397
|
+
var lineTax = included
|
|
398
|
+
? (rate > 0 ? lineGross - lineGross / (1 + rate / 100) : 0)
|
|
399
|
+
: lineGross - lineNet;
|
|
400
|
+
goodsNet += lineNet;
|
|
401
|
+
goodsGross += lineGross;
|
|
402
|
+
weightKg += line.weightKg * line.quantity;
|
|
403
|
+
outLines.push({
|
|
404
|
+
key: line.key,
|
|
405
|
+
productId: line.productId,
|
|
406
|
+
variantId: line.variantId,
|
|
407
|
+
unitGross: unitGross,
|
|
408
|
+
lineGross: lineGross,
|
|
409
|
+
taxRate: rate,
|
|
410
|
+
taxIncluded: included
|
|
411
|
+
});
|
|
412
|
+
var bucket = null;
|
|
413
|
+
for (var b = 0; b < buckets.length; b++) {
|
|
414
|
+
if (buckets[b].rate === rate && buckets[b].included === included) { bucket = buckets[b]; break; }
|
|
415
|
+
}
|
|
416
|
+
if (!bucket) {
|
|
417
|
+
bucket = { rate: rate, included: included, gross: 0, tax: 0 };
|
|
418
|
+
buckets.push(bucket);
|
|
419
|
+
}
|
|
420
|
+
bucket.gross += lineGross;
|
|
421
|
+
bucket.tax += lineTax;
|
|
422
|
+
}
|
|
423
|
+
goodsNet = __rpRound(goodsNet, 2);
|
|
424
|
+
goodsGross = __rpRound(goodsGross, taxDecimals);
|
|
425
|
+
var goodsTax = __rpRound(goodsGross - goodsNet, taxDecimals);
|
|
426
|
+
if (goodsTax < 0) goodsTax = 0;
|
|
427
|
+
var breakdown = [];
|
|
428
|
+
for (var k = 0; k < buckets.length; k++) {
|
|
429
|
+
if (!(buckets[k].rate > 0)) continue;
|
|
430
|
+
breakdown.push({
|
|
431
|
+
rate: buckets[k].rate,
|
|
432
|
+
included: buckets[k].included,
|
|
433
|
+
gross: __rpRound(buckets[k].gross, taxDecimals),
|
|
434
|
+
tax: __rpRound(buckets[k].tax, taxDecimals)
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
var standardRow = taxRegional ? __rpFindTaxRow(taxRates, destination, [], true) : null;
|
|
439
|
+
var taxRate = standardRow ? standardRow.rate : defaultRate;
|
|
440
|
+
var taxIncluded = standardRow ? __rpRowIncluded(standardRow, defaultIncluded) : defaultIncluded;
|
|
441
|
+
|
|
442
|
+
// Compared on the cent-rounded goods total, so a sum that drifted to
|
|
443
|
+
// 49.999999 still meets a threshold of 50.
|
|
444
|
+
var basisPrice = __rpRound(goodsGross, 2);
|
|
445
|
+
var status = 'ok';
|
|
446
|
+
var zone = null;
|
|
447
|
+
var options = [];
|
|
448
|
+
var selectedRateId = null;
|
|
449
|
+
var rateName = '';
|
|
450
|
+
var shippingBase = 0;
|
|
451
|
+
var threshold = null;
|
|
452
|
+
var codAvailable = true;
|
|
453
|
+
|
|
454
|
+
if (zonesActive && (!destination || !destination.countryCode)) {
|
|
455
|
+
status = 'pending';
|
|
456
|
+
} else if (delivering) {
|
|
457
|
+
var matched = zonesActive ? __rpResolveZone(zones, destination.countryCode) : null;
|
|
458
|
+
if (!matched) {
|
|
459
|
+
// No zone lists this country (or there are none): the store's own
|
|
460
|
+
// delivery settings, exactly as before regions existed.
|
|
461
|
+
var storeShipping = __rpStoreShipping(store);
|
|
462
|
+
shippingBase = storeShipping.base;
|
|
463
|
+
threshold = storeShipping.threshold;
|
|
464
|
+
} else if (matched.doNotShip) {
|
|
465
|
+
zone = { id: matched.id, name: matched.name };
|
|
466
|
+
status = 'unavailable';
|
|
467
|
+
} else {
|
|
468
|
+
zone = { id: matched.id, name: matched.name };
|
|
469
|
+
codAvailable = matched.codEnabled;
|
|
470
|
+
threshold = matched.freeShippingThreshold;
|
|
471
|
+
var basisWeight = Math.round(weightKg * 1000) / 1000;
|
|
472
|
+
var rates = __rpRows(config.rates);
|
|
473
|
+
var zoneHasRates = false;
|
|
474
|
+
for (var r = 0; r < rates.length; r++) {
|
|
475
|
+
var candidate = rates[r];
|
|
476
|
+
if (candidate.zoneId !== matched.id) continue;
|
|
477
|
+
zoneHasRates = true;
|
|
478
|
+
if (candidate.type === 'price' && !__rpInRange(basisPrice, candidate.min, candidate.max)) continue;
|
|
479
|
+
if (candidate.type === 'weight' && !__rpInRange(basisWeight, candidate.min, candidate.max)) continue;
|
|
480
|
+
options.push({
|
|
481
|
+
id: candidate.id,
|
|
482
|
+
name: candidate.name,
|
|
483
|
+
estimatedDays: candidate.estimatedDays,
|
|
484
|
+
price: __rpRound(candidate.price, moneyDecimals),
|
|
485
|
+
sortOrder: candidate.sortOrder
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
options.sort(__rpByPrice);
|
|
489
|
+
if (!zoneHasRates) {
|
|
490
|
+
// No rate at all (none, or only switched-off ones): the store fee,
|
|
491
|
+
// under this zone's own threshold and cash-on-delivery rule.
|
|
492
|
+
shippingBase = __rpStoreShipping(store).base;
|
|
493
|
+
} else if (options.length === 0) {
|
|
494
|
+
status = 'no-rate';
|
|
495
|
+
} else {
|
|
496
|
+
var selected = options[0];
|
|
497
|
+
for (var o = 0; o < options.length; o++) {
|
|
498
|
+
if (options[o].id === source.selectedRateId) { selected = options[o]; break; }
|
|
499
|
+
}
|
|
500
|
+
selectedRateId = selected.id;
|
|
501
|
+
rateName = selected.name;
|
|
502
|
+
shippingBase = selected.price;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
var thresholdReached = status === 'ok' && threshold !== null && basisPrice >= threshold;
|
|
508
|
+
var shippingNet = status === 'ok' && !thresholdReached ? shippingBase : 0;
|
|
509
|
+
var shippingTaxRow = standardRow && standardRow.taxShipping ? standardRow : null;
|
|
510
|
+
var shipping = __rpShippingTax(shippingNet, shippingTaxRow ? taxRate : 0, taxIncluded, moneyDecimals);
|
|
511
|
+
|
|
512
|
+
var outOptions = [];
|
|
513
|
+
for (var p = 0; p < options.length; p++) {
|
|
514
|
+
var optionFree = thresholdReached || options[p].price === 0;
|
|
515
|
+
outOptions.push({
|
|
516
|
+
id: options[p].id,
|
|
517
|
+
name: options[p].name,
|
|
518
|
+
estimatedDays: options[p].estimatedDays,
|
|
519
|
+
price: options[p].price,
|
|
520
|
+
// What choosing this option adds to the order — the figure to print beside it.
|
|
521
|
+
gross: optionFree
|
|
522
|
+
? 0
|
|
523
|
+
: __rpShippingTax(options[p].price, shippingTaxRow ? taxRate : 0, taxIncluded, moneyDecimals).gross,
|
|
524
|
+
isFree: optionFree
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
fulfillment: delivering ? 'delivery' : 'pickup',
|
|
530
|
+
destination: destination,
|
|
531
|
+
taxRegional: taxRegional,
|
|
532
|
+
zonesActive: zonesActive,
|
|
533
|
+
status: status,
|
|
534
|
+
taxDecimals: taxDecimals,
|
|
535
|
+
lines: outLines,
|
|
536
|
+
goodsNet: goodsNet,
|
|
537
|
+
goodsTax: goodsTax,
|
|
538
|
+
goodsGross: goodsGross,
|
|
539
|
+
taxRate: taxRate,
|
|
540
|
+
taxIncluded: taxIncluded,
|
|
541
|
+
breakdown: breakdown,
|
|
542
|
+
zone: zone,
|
|
543
|
+
options: outOptions,
|
|
544
|
+
selectedRateId: selectedRateId,
|
|
545
|
+
rateName: rateName,
|
|
546
|
+
freeShippingThreshold: threshold,
|
|
547
|
+
freeShippingProgress: threshold === null
|
|
548
|
+
? 0
|
|
549
|
+
: threshold > 0 ? Math.min(100, (basisPrice / threshold) * 100) : 100,
|
|
550
|
+
freeShippingRemaining: threshold === null ? 0 : Math.max(0, __rpRound(threshold - basisPrice, 2)),
|
|
551
|
+
shippingIsFree: thresholdReached,
|
|
552
|
+
shippingBase: shippingBase,
|
|
553
|
+
shippingNet: shippingNet,
|
|
554
|
+
shippingTax: shipping.tax,
|
|
555
|
+
shippingGross: shipping.gross,
|
|
556
|
+
codAvailable: codAvailable,
|
|
557
|
+
// Goods are rounded to \`taxDecimals\`, so the total is too: a zero-decimal
|
|
558
|
+
// store with no regional tax rows keeps cent-precise goods, as before.
|
|
559
|
+
total: __rpRound(goodsGross + shipping.gross, taxDecimals)
|
|
560
|
+
};
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// The address the checkout form is pricing, read straight off the form. Null
|
|
564
|
+
// anywhere the checkout form is not on the page.
|
|
565
|
+
//
|
|
566
|
+
// The same address the place-order workflow uses: the shipping address block
|
|
567
|
+
// exists in the DOM only while "ship to a different address" is ticked, and the
|
|
568
|
+
// billing address otherwise. \`countryCodes\` maps a lower-cased country NAME
|
|
569
|
+
// (what the country field holds) to its ISO code.
|
|
570
|
+
var __rpReadCheckoutDestination = function (countryCodes) {
|
|
571
|
+
if (typeof document === 'undefined') return null;
|
|
572
|
+
var billingCountry = document.querySelector('[name="billingCountry"]');
|
|
573
|
+
if (!billingCountry) return null;
|
|
574
|
+
var shippingCountry = document.querySelector('[name="shippingCountry"]');
|
|
575
|
+
var countryField = shippingCountry || billingCountry;
|
|
576
|
+
var regionField = document.querySelector(shippingCountry ? '[name="shippingState"]' : '[name="state"]');
|
|
577
|
+
var countryName = __rpText(countryField.value);
|
|
578
|
+
var codes = countryCodes && typeof countryCodes === 'object' ? countryCodes : {};
|
|
579
|
+
var countryCode = codes[countryName.toLowerCase()] || '';
|
|
580
|
+
if (!countryCode && /^[A-Za-z]{2}$/.test(countryName)) countryCode = countryName.toUpperCase();
|
|
581
|
+
return {
|
|
582
|
+
countryCode: countryCode,
|
|
583
|
+
countryName: countryName,
|
|
584
|
+
region: regionField ? __rpText(regionField.value) : ''
|
|
585
|
+
};
|
|
586
|
+
};
|
|
587
|
+
`
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The shipping rate the buyer picked at checkout (a rate id). ⚠️ `cart-get-total`
|
|
591
|
+
* spells the same key out as a literal — a serialized handler has no module
|
|
592
|
+
* scope to import it from.
|
|
593
|
+
*/
|
|
594
|
+
export const SHIPPING_RATE_STORAGE_KEY = 'workflow_shipping_rate'
|
|
595
|
+
|
|
596
|
+
/** Broadcast when the buyer picks a shipping rate. */
|
|
597
|
+
export const SHIPPING_RATE_CHANGED_EVENT = 'teleport:shipping-rate-changed'
|
|
@@ -126,3 +126,84 @@ function grossLineMoney(unitAmount, quantity, storedTotal) {
|
|
|
126
126
|
return (Math.round(applyStorefrontTax(unitAmount) * qty * 100) / 100).toFixed(2);
|
|
127
127
|
}`
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* ES5 helpers for a generated route that shows the lines of a PLACED order —
|
|
132
|
+
* the merchant's order-notification email. Mirrors teleport-gui's
|
|
133
|
+
* `ORDER_LINE_TAX_SCRIPT` (`storefront-tax-script.ts`), which the storefront's
|
|
134
|
+
* baked workflows run; `__tests__/utils/order-line-tax.ts` and the GUI's
|
|
135
|
+
* `order-line-pricing-parity.spec.ts` run the same fixture table over both.
|
|
136
|
+
*
|
|
137
|
+
* What a line cost the buyer is decided once, at checkout, and written to
|
|
138
|
+
* `teleport_order_items` (`unit_price_paid`, `total_price_paid`, `tax_rate`,
|
|
139
|
+
* `tax_included`). A reader prefers the record (`orderLineTaxOf`,
|
|
140
|
+
* `paidAmountAt`) and DERIVES it only for a line written before the record
|
|
141
|
+
* existed: from the rate its order's `tax_breakdown` recorded for it (an order
|
|
142
|
+
* priced by region), else from `STOREFRONT_TAX_RATE`.
|
|
143
|
+
*
|
|
144
|
+
* Requires the prelude from `generateStorefrontTaxHelperCode` in scope.
|
|
145
|
+
*/
|
|
146
|
+
export const generateOrderLineTaxHelperCode = (): string => `
|
|
147
|
+
// Every line of an order carries the same breakdown text: parse it once.
|
|
148
|
+
var orderLineTaxRaw = null;
|
|
149
|
+
var orderLineTaxParsed = null;
|
|
150
|
+
function parseOrderLineTaxBreakdown(breakdown) {
|
|
151
|
+
var parsed = breakdown;
|
|
152
|
+
if (typeof breakdown === 'string') {
|
|
153
|
+
if (breakdown !== orderLineTaxRaw) {
|
|
154
|
+
orderLineTaxRaw = breakdown;
|
|
155
|
+
try { orderLineTaxParsed = breakdown ? JSON.parse(breakdown) : null; } catch (e) { orderLineTaxParsed = null; }
|
|
156
|
+
}
|
|
157
|
+
parsed = orderLineTaxParsed;
|
|
158
|
+
}
|
|
159
|
+
return parsed && typeof parsed === 'object' && Array.isArray(parsed.lines) ? parsed : null;
|
|
160
|
+
}
|
|
161
|
+
function orderLineTaxDecimals(parsed) {
|
|
162
|
+
return parsed && parsed.taxDecimals === 0 ? 0 : 2;
|
|
163
|
+
}
|
|
164
|
+
function orderLineTaxIncluded(value) {
|
|
165
|
+
return value === true || value === 'true' || value === 't' || value === 1 || value === '1';
|
|
166
|
+
}
|
|
167
|
+
// The tax one line was charged at: { rate, included, decimals }.
|
|
168
|
+
function orderLineTax(breakdown, productId, variantId) {
|
|
169
|
+
var parsed = parseOrderLineTaxBreakdown(breakdown);
|
|
170
|
+
if (!parsed) {
|
|
171
|
+
return { rate: STOREFRONT_TAX_RATE > 0 ? STOREFRONT_TAX_RATE : 0, included: false, decimals: 2 };
|
|
172
|
+
}
|
|
173
|
+
var pid = String(productId == null ? '' : productId);
|
|
174
|
+
var vid = String(variantId == null ? '' : variantId);
|
|
175
|
+
var match = null;
|
|
176
|
+
for (var li = 0; li < parsed.lines.length; li++) {
|
|
177
|
+
var line = parsed.lines[li];
|
|
178
|
+
if (line && String(line.productId || '') === pid && String(line.variantId || '') === vid) {
|
|
179
|
+
match = line;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
var source = match || parsed;
|
|
184
|
+
var rate = Number(source.taxRate);
|
|
185
|
+
if (!isFinite(rate) || rate < 0) rate = 0;
|
|
186
|
+
return { rate: rate, included: source.taxIncluded === true, decimals: orderLineTaxDecimals(parsed) };
|
|
187
|
+
}
|
|
188
|
+
// The tax a stored line was RECORDED with, else \`orderLineTax\`.
|
|
189
|
+
function orderLineTaxOf(breakdown, productId, variantId, storedRate, storedIncluded) {
|
|
190
|
+
var rate = storedRate == null || storedRate === '' ? NaN : Number(storedRate);
|
|
191
|
+
if (!isFinite(rate) || rate < 0) return orderLineTax(breakdown, productId, variantId);
|
|
192
|
+
return {
|
|
193
|
+
rate: rate,
|
|
194
|
+
included: orderLineTaxIncluded(storedIncluded),
|
|
195
|
+
decimals: orderLineTaxDecimals(parseOrderLineTaxBreakdown(breakdown))
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function grossAmountAt(amount, tax) {
|
|
199
|
+
var base = Number(amount);
|
|
200
|
+
if (!isFinite(base)) base = 0;
|
|
201
|
+
if (!tax || tax.included === true || !(tax.rate > 0)) return base;
|
|
202
|
+
var factor = tax.decimals === 0 ? 1 : 100;
|
|
203
|
+
return Math.round(base * (1 + tax.rate / 100) * factor) / factor;
|
|
204
|
+
}
|
|
205
|
+
// What the buyer paid, as RECORDED on the line; derived for an older line.
|
|
206
|
+
function paidAmountAt(paidAmount, netAmount, tax) {
|
|
207
|
+
var paid = paidAmount == null || paidAmount === '' ? NaN : Number(paidAmount);
|
|
208
|
+
return isFinite(paid) ? paid : grossAmountAt(netAmount, tax);
|
|
209
|
+
}`
|