@blamejs/blamejs-shop 0.0.53 → 0.0.56
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/CHANGELOG.md +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
package/lib/tax.js
CHANGED
|
@@ -154,8 +154,396 @@ function create(opts) {
|
|
|
154
154
|
return operatorTable(opts);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// ---- VAT / GST primitives ----------------------------------------------
|
|
158
|
+
//
|
|
159
|
+
// Sales tax (US-style) is *added* to a displayed price: $10 + 8.75% =
|
|
160
|
+
// $10.875. VAT (EU) and GST (AU / CA / IN / NZ / SG) are typically
|
|
161
|
+
// *extracted* from a displayed price: a €10 line at 20% VAT means the
|
|
162
|
+
// operator receives €8.33 net + €1.67 VAT, not €10 + €2. The B2B
|
|
163
|
+
// "reverse charge" mechanism on intra-EU trade moves the VAT
|
|
164
|
+
// obligation to the buyer (the seller invoices zero VAT and documents
|
|
165
|
+
// the buyer's VAT ID); the buyer self-assesses + remits in their own
|
|
166
|
+
// member state.
|
|
167
|
+
//
|
|
168
|
+
// These helpers are *pure functions* — they don't touch the network
|
|
169
|
+
// and they don't talk to the operator-table adapter. They're the
|
|
170
|
+
// math + format-validation surface the storefront / invoice
|
|
171
|
+
// primitives compose against. The operator-table adapter still owns
|
|
172
|
+
// jurisdiction-rule selection.
|
|
173
|
+
|
|
174
|
+
// Banker's rounding (round-half-to-even) for non-negative `raw`.
|
|
175
|
+
// JS Math.round is round-half-up, which biases tax totals upward
|
|
176
|
+
// across many small carts; this matches the rounding mode the
|
|
177
|
+
// existing operatorTable adapter uses.
|
|
178
|
+
function _bankersRound(raw) {
|
|
179
|
+
var floor = Math.floor(raw);
|
|
180
|
+
var frac = raw - floor;
|
|
181
|
+
if (frac < 0.5) return floor;
|
|
182
|
+
if (frac > 0.5) return floor + 1;
|
|
183
|
+
return (floor % 2 === 0) ? floor : floor + 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _currency(c) {
|
|
187
|
+
if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
|
|
188
|
+
throw new TypeError("tax: currency must be a 3-letter ISO 4217 code (uppercase), got " + JSON.stringify(c));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extract VAT from a VAT-inclusive (gross) price.
|
|
194
|
+
*
|
|
195
|
+
* tax = round(gross * rate_bps / (10000 + rate_bps))
|
|
196
|
+
* net = gross - tax
|
|
197
|
+
*
|
|
198
|
+
* Returns `{ net_minor, tax_minor, gross_minor, rate_bps }`. The
|
|
199
|
+
* gross figure round-trips exactly (`net + tax === gross`) by
|
|
200
|
+
* construction — `net` is derived from `tax`, not rounded
|
|
201
|
+
* independently.
|
|
202
|
+
*
|
|
203
|
+
* Use when the storefront displays VAT-inclusive prices (the EU
|
|
204
|
+
* default for B2C). Banker's rounding to even.
|
|
205
|
+
*/
|
|
206
|
+
function calculateInclusive(args) {
|
|
207
|
+
if (!args || typeof args !== "object") throw new TypeError("tax.calculateInclusive: args object required");
|
|
208
|
+
_nonNegInt(args.amount_minor, "amount_minor");
|
|
209
|
+
_bps(args.rate_bps);
|
|
210
|
+
_currency(args.currency);
|
|
211
|
+
var gross = args.amount_minor;
|
|
212
|
+
var rate = args.rate_bps;
|
|
213
|
+
var tax = _bankersRound(gross * rate / (10000 + rate));
|
|
214
|
+
return {
|
|
215
|
+
net_minor: gross - tax,
|
|
216
|
+
tax_minor: tax,
|
|
217
|
+
gross_minor: gross,
|
|
218
|
+
rate_bps: rate,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Add VAT to a VAT-exclusive (net) price.
|
|
224
|
+
*
|
|
225
|
+
* tax = round(net * rate_bps / 10000)
|
|
226
|
+
* gross = net + tax
|
|
227
|
+
*
|
|
228
|
+
* Returns `{ net_minor, tax_minor, gross_minor, rate_bps }`. Use
|
|
229
|
+
* when the storefront displays VAT-exclusive prices (the standard
|
|
230
|
+
* B2B presentation, and the US-sales-tax shape). Banker's rounding
|
|
231
|
+
* to even — same convention as `operatorTable.calculate`.
|
|
232
|
+
*/
|
|
233
|
+
function calculateExclusive(args) {
|
|
234
|
+
if (!args || typeof args !== "object") throw new TypeError("tax.calculateExclusive: args object required");
|
|
235
|
+
_nonNegInt(args.amount_minor, "amount_minor");
|
|
236
|
+
_bps(args.rate_bps);
|
|
237
|
+
_currency(args.currency);
|
|
238
|
+
var net = args.amount_minor;
|
|
239
|
+
var rate = args.rate_bps;
|
|
240
|
+
var tax = _bankersRound(net * rate / 10000);
|
|
241
|
+
return {
|
|
242
|
+
net_minor: net,
|
|
243
|
+
tax_minor: tax,
|
|
244
|
+
gross_minor: net + tax,
|
|
245
|
+
rate_bps: rate,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- VAT ID format validation -------------------------------------------
|
|
250
|
+
//
|
|
251
|
+
// VAT IDs have country-specific formats. The well-known patterns
|
|
252
|
+
// below cover the EU-27 + GB + Switzerland. These regexes validate
|
|
253
|
+
// the *format* only — they do NOT confirm the VAT ID is registered
|
|
254
|
+
// or active. That requires a live HTTPS call to the EU VIES service
|
|
255
|
+
// (or the equivalent national registry), which this primitive
|
|
256
|
+
// deliberately does NOT make:
|
|
257
|
+
//
|
|
258
|
+
// - Zero npm runtime deps (composing an HTTPS+XML client here is
|
|
259
|
+
// out of scope for a math primitive).
|
|
260
|
+
// - No SSRF surface in the tax math path.
|
|
261
|
+
// - VIES is a soft availability commitment; operators that depend
|
|
262
|
+
// on it need their own retry / cache / fallback policy, which
|
|
263
|
+
// belongs in the operator's checkout pipeline, not in lib/tax.js.
|
|
264
|
+
//
|
|
265
|
+
// Operators are responsible for VIES live-checking before storing
|
|
266
|
+
// any buyer VAT ID. This primitive only refuses obviously-malformed
|
|
267
|
+
// IDs (wrong country prefix, wrong length, wrong character class).
|
|
268
|
+
//
|
|
269
|
+
// The format table is operator-extensible — assign to
|
|
270
|
+
// `tax.VAT_FORMATS["XX"]` (a RegExp) to add or override a country.
|
|
271
|
+
//
|
|
272
|
+
// References: each pattern matches the published national format
|
|
273
|
+
// rule; mixed alphanumeric positions (FR, ES, IE) are preserved.
|
|
274
|
+
var VAT_FORMATS = {
|
|
275
|
+
AT: /^ATU[0-9]{8}$/, // Austria — U + 8 digits
|
|
276
|
+
BE: /^BE[01][0-9]{9}$/, // Belgium — 10 digits starting 0 or 1
|
|
277
|
+
BG: /^BG[0-9]{9,10}$/, // Bulgaria — 9 or 10 digits
|
|
278
|
+
CY: /^CY[0-9]{8}[A-Z]$/, // Cyprus — 8 digits + letter
|
|
279
|
+
CZ: /^CZ[0-9]{8,10}$/, // Czechia — 8-10 digits
|
|
280
|
+
DE: /^DE[0-9]{9}$/, // Germany — 9 digits
|
|
281
|
+
DK: /^DK[0-9]{8}$/, // Denmark — 8 digits
|
|
282
|
+
EE: /^EE[0-9]{9}$/, // Estonia — 9 digits
|
|
283
|
+
EL: /^EL[0-9]{9}$/, // Greece (EU-internal — Greece uses EL not GR)
|
|
284
|
+
GR: /^EL[0-9]{9}$/, // Greece alias — operators sometimes pass GR
|
|
285
|
+
ES: /^ES[A-Z0-9][0-9]{7}[A-Z0-9]$/, // Spain — alpha/digit, 7 digits, alpha/digit
|
|
286
|
+
FI: /^FI[0-9]{8}$/, // Finland — 8 digits
|
|
287
|
+
FR: /^FR[0-9A-HJ-NP-Z]{2}[0-9]{9}$/, // France — 2-char prefix (no I/O) + 9 digits
|
|
288
|
+
HR: /^HR[0-9]{11}$/, // Croatia — 11 digits
|
|
289
|
+
HU: /^HU[0-9]{8}$/, // Hungary — 8 digits
|
|
290
|
+
IE: /^IE[0-9][0-9A-Z*+][0-9]{5}[A-Z]{1,2}$/, // Ireland — 9-character mixed
|
|
291
|
+
IT: /^IT[0-9]{11}$/, // Italy — 11 digits
|
|
292
|
+
LT: /^LT(?:[0-9]{9}|[0-9]{12})$/, // Lithuania — 9 or 12 digits
|
|
293
|
+
LU: /^LU[0-9]{8}$/, // Luxembourg — 8 digits
|
|
294
|
+
LV: /^LV[0-9]{11}$/, // Latvia — 11 digits
|
|
295
|
+
MT: /^MT[0-9]{8}$/, // Malta — 8 digits
|
|
296
|
+
NL: /^NL[0-9]{9}B[0-9]{2}$/, // Netherlands — 9 digits + B + 2 digits
|
|
297
|
+
PL: /^PL[0-9]{10}$/, // Poland — 10 digits
|
|
298
|
+
PT: /^PT[0-9]{9}$/, // Portugal — 9 digits
|
|
299
|
+
RO: /^RO[0-9]{2,10}$/, // Romania — 2-10 digits
|
|
300
|
+
SE: /^SE[0-9]{12}$/, // Sweden — 12 digits
|
|
301
|
+
SI: /^SI[0-9]{8}$/, // Slovenia — 8 digits
|
|
302
|
+
SK: /^SK[0-9]{10}$/, // Slovakia — 10 digits
|
|
303
|
+
// Non-EU, retained for the EU-adjacent operator surface:
|
|
304
|
+
GB: /^GB(?:[0-9]{9}|[0-9]{12}|GD[0-9]{3}|HA[0-9]{3})$/, // United Kingdom — 9 / 12 digits or GD/HA + 3
|
|
305
|
+
CH: /^CHE[0-9]{9}(?:MWST|TVA|IVA)?$/, // Switzerland — CHE + 9 digits, optional suffix
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// EU-27 member-state country codes (for reverse-charge eligibility).
|
|
309
|
+
// GB is intentionally NOT included — Brexit removed the UK from the
|
|
310
|
+
// intra-EU reverse-charge mechanism. CH was never an EU member.
|
|
311
|
+
var EU_COUNTRY_CODES = [
|
|
312
|
+
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
|
|
313
|
+
"FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
|
|
314
|
+
"NL", "PL", "PT", "RO", "SE", "SI", "SK",
|
|
315
|
+
];
|
|
316
|
+
var _EU_SET = EU_COUNTRY_CODES.reduce(function (acc, c) { acc[c] = true; return acc; }, {});
|
|
317
|
+
|
|
318
|
+
function _isEu(cc) {
|
|
319
|
+
if (typeof cc !== "string") return false;
|
|
320
|
+
return _EU_SET[cc] === true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Validate a VAT ID's *format* against the country's published
|
|
325
|
+
* pattern. Returns `{ ok, country, vat_number, format }`:
|
|
326
|
+
*
|
|
327
|
+
* ok — true when the ID matches the country's regex
|
|
328
|
+
* country — the country code (uppercase, normalised)
|
|
329
|
+
* vat_number — the ID with the country prefix stripped
|
|
330
|
+
* format — the regex source string, for diagnostics
|
|
331
|
+
*
|
|
332
|
+
* On a mismatch returns `{ ok: false, country, vat_number: null,
|
|
333
|
+
* format }`. On an unknown country code throws — the caller asked
|
|
334
|
+
* for validation against a country we don't have a pattern for, and
|
|
335
|
+
* silently returning `{ ok: true }` would be unsafe.
|
|
336
|
+
*
|
|
337
|
+
* IMPORTANT: this is FORMAT validation only. It does NOT confirm
|
|
338
|
+
* the VAT ID is registered, active, or assigned to the named buyer.
|
|
339
|
+
* Operators MUST cross-check against VIES (https://ec.europa.eu/
|
|
340
|
+
* taxation_customs/vies/) or the equivalent national registry
|
|
341
|
+
* before relying on a VAT ID for reverse-charge invoicing. This
|
|
342
|
+
* primitive is the cheap pre-filter, not the legal record-of-truth.
|
|
343
|
+
*
|
|
344
|
+
* Extend `tax.VAT_FORMATS["XX"] = /^XX.../` to register additional
|
|
345
|
+
* country patterns at boot time.
|
|
346
|
+
*/
|
|
347
|
+
function validateVatId(vat_id, country_code) {
|
|
348
|
+
if (typeof vat_id !== "string") {
|
|
349
|
+
throw new TypeError("tax.validateVatId: vat_id must be a string, got " + JSON.stringify(vat_id));
|
|
350
|
+
}
|
|
351
|
+
if (typeof country_code !== "string" || !/^[A-Z]{2}$/.test(country_code)) {
|
|
352
|
+
throw new TypeError("tax.validateVatId: country_code must be a 2-letter uppercase ISO 3166-1 code, got " + JSON.stringify(country_code));
|
|
353
|
+
}
|
|
354
|
+
var pattern = VAT_FORMATS[country_code];
|
|
355
|
+
if (!pattern) {
|
|
356
|
+
throw new TypeError("tax.validateVatId: no VAT format registered for country " + JSON.stringify(country_code) + " — extend tax.VAT_FORMATS to add one");
|
|
357
|
+
}
|
|
358
|
+
// Normalise: strip whitespace; uppercase. Many invoicing UIs let
|
|
359
|
+
// operators paste "DE 123 456 789" or "de123456789" — accept both.
|
|
360
|
+
var normalised = vat_id.replace(/\s+/g, "").toUpperCase();
|
|
361
|
+
var matched = pattern.test(normalised);
|
|
362
|
+
if (!matched) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
country: country_code,
|
|
366
|
+
vat_number: null,
|
|
367
|
+
format: pattern.source,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
// The country prefix on EU/GB/CH VAT IDs is always the first 2-3
|
|
371
|
+
// chars. We strip the matching country prefix from the front and
|
|
372
|
+
// return the remainder as the bare VAT number.
|
|
373
|
+
var prefix = normalised.indexOf(country_code) === 0 ? country_code : normalised.slice(0, 3);
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
country: country_code,
|
|
377
|
+
vat_number: normalised.slice(prefix.length),
|
|
378
|
+
format: pattern.source,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Decide whether the intra-EU B2B reverse-charge mechanism applies
|
|
384
|
+
* to the current transaction. Returns one of:
|
|
385
|
+
*
|
|
386
|
+
* { rate_bps: 0, reverse_charge: true, reason: "eu-b2b",
|
|
387
|
+
* seller: { country, vat_number }, buyer: { country, vat_number } }
|
|
388
|
+
*
|
|
389
|
+
* { rate_bps: null, reverse_charge: false, reason: <why-not> }
|
|
390
|
+
*
|
|
391
|
+
* When the function returns `reverse_charge: true` the caller MUST
|
|
392
|
+
* invoice with VAT zero AND document both VAT IDs on the invoice;
|
|
393
|
+
* the buyer self-assesses + remits in their member state.
|
|
394
|
+
*
|
|
395
|
+
* Conditions (ALL must hold):
|
|
396
|
+
* 1. Seller has a VAT ID AND its country is in EU_COUNTRY_CODES.
|
|
397
|
+
* 2. Buyer has a VAT ID AND its country is in EU_COUNTRY_CODES.
|
|
398
|
+
* 3. Both VAT IDs pass format validation.
|
|
399
|
+
* 4. The buyer's billing country matches the buyer-VAT-ID country
|
|
400
|
+
* (a mismatch is a fraud red flag — the operator should refuse
|
|
401
|
+
* reverse charge and either charge the seller's rate or block
|
|
402
|
+
* checkout pending VIES confirmation).
|
|
403
|
+
*
|
|
404
|
+
* The `ctx` argument is reserved for future signals (e.g. a
|
|
405
|
+
* cached VIES-confirmation flag the operator's storefront sets
|
|
406
|
+
* after a successful live check). Today only the `vat_validated_at`
|
|
407
|
+
* timestamp is honoured as an advisory field on the seller side.
|
|
408
|
+
*
|
|
409
|
+
* IMPORTANT: this primitive does NOT call VIES. The operator must
|
|
410
|
+
* confirm the buyer's VAT ID against the EU VIES service (or
|
|
411
|
+
* equivalent national registry) BEFORE relying on the
|
|
412
|
+
* `reverse_charge: true` outcome to suppress VAT on an invoice.
|
|
413
|
+
* Format-pass + VIES-fail is a fraud vector; the operator's
|
|
414
|
+
* checkout pipeline owns the live check.
|
|
415
|
+
*/
|
|
416
|
+
function applyReverseCharge(args) {
|
|
417
|
+
if (!args || typeof args !== "object") throw new TypeError("tax.applyReverseCharge: args object required");
|
|
418
|
+
var ctx = args.ctx || {};
|
|
419
|
+
var buyerCountry = args.buyer_country;
|
|
420
|
+
if (typeof buyerCountry !== "string" || !/^[A-Z]{2}$/.test(buyerCountry)) {
|
|
421
|
+
throw new TypeError("tax.applyReverseCharge: buyer_country must be a 2-letter uppercase ISO 3166-1 code, got " + JSON.stringify(buyerCountry));
|
|
422
|
+
}
|
|
423
|
+
var sellerVatId = args.seller_vat_id;
|
|
424
|
+
var buyerVatId = args.buyer_vat_id;
|
|
425
|
+
|
|
426
|
+
// Both VAT IDs must be present. Missing either one means the
|
|
427
|
+
// buyer is treated as a B2C consumer — charge the seller's rate.
|
|
428
|
+
if (typeof sellerVatId !== "string" || !sellerVatId) {
|
|
429
|
+
return { rate_bps: null, reverse_charge: false, reason: "missing-seller-vat-id" };
|
|
430
|
+
}
|
|
431
|
+
if (typeof buyerVatId !== "string" || !buyerVatId) {
|
|
432
|
+
return { rate_bps: null, reverse_charge: false, reason: "missing-buyer-vat-id" };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The seller's country prefix is the first 2 chars of the VAT ID.
|
|
436
|
+
// CH starts CHE — handle that out-of-band.
|
|
437
|
+
var sellerCountry = sellerVatId.slice(0, 2).toUpperCase();
|
|
438
|
+
if (sellerCountry === "CH") sellerCountry = "CH";
|
|
439
|
+
|
|
440
|
+
if (!_isEu(sellerCountry)) {
|
|
441
|
+
return { rate_bps: null, reverse_charge: false, reason: "seller-not-eu" };
|
|
442
|
+
}
|
|
443
|
+
if (!_isEu(buyerCountry)) {
|
|
444
|
+
return { rate_bps: null, reverse_charge: false, reason: "buyer-not-eu" };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Both VAT IDs must format-validate.
|
|
448
|
+
var sellerCheck;
|
|
449
|
+
var buyerCheck;
|
|
450
|
+
try { sellerCheck = validateVatId(sellerVatId, sellerCountry); }
|
|
451
|
+
catch (_e) { return { rate_bps: null, reverse_charge: false, reason: "seller-vat-id-unknown-country" }; }
|
|
452
|
+
if (!sellerCheck.ok) {
|
|
453
|
+
return { rate_bps: null, reverse_charge: false, reason: "seller-vat-id-bad-format" };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The buyer's VAT ID country prefix must match the declared
|
|
457
|
+
// buyer_country. A mismatch is a red flag — the buyer may be
|
|
458
|
+
// trying to invoice into the wrong jurisdiction.
|
|
459
|
+
var buyerVatCountry = buyerVatId.slice(0, 2).toUpperCase();
|
|
460
|
+
if (buyerVatCountry !== buyerCountry) {
|
|
461
|
+
return { rate_bps: null, reverse_charge: false, reason: "buyer-vat-id-country-mismatch" };
|
|
462
|
+
}
|
|
463
|
+
try { buyerCheck = validateVatId(buyerVatId, buyerCountry); }
|
|
464
|
+
catch (_e) { return { rate_bps: null, reverse_charge: false, reason: "buyer-vat-id-unknown-country" }; }
|
|
465
|
+
if (!buyerCheck.ok) {
|
|
466
|
+
return { rate_bps: null, reverse_charge: false, reason: "buyer-vat-id-bad-format" };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
rate_bps: 0,
|
|
471
|
+
reverse_charge: true,
|
|
472
|
+
reason: "eu-b2b",
|
|
473
|
+
seller: { country: sellerCountry, vat_number: sellerCheck.vat_number },
|
|
474
|
+
buyer: { country: buyerCountry, vat_number: buyerCheck.vat_number },
|
|
475
|
+
// Advisory: timestamp at which the operator last live-checked
|
|
476
|
+
// VIES for the buyer (if supplied via ctx). The primitive does
|
|
477
|
+
// not consume this — it's surfaced so downstream invoice
|
|
478
|
+
// rendering can show "VIES-confirmed YYYY-MM-DD".
|
|
479
|
+
vies_validated_at: typeof ctx.vies_validated_at === "string" ? ctx.vies_validated_at : null,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Render a `rate_bps` value as a human-readable percentage string,
|
|
485
|
+
* locale-aware via `Intl.NumberFormat`. Returns:
|
|
486
|
+
*
|
|
487
|
+
* format({ rate_bps: 2000 }) -> "20.0%"
|
|
488
|
+
* format({ rate_bps: 550 }) -> "5.5%"
|
|
489
|
+
* format({ rate_bps: 0, reverse_charge: true }) -> "0% (reverse charge)"
|
|
490
|
+
* format({ rate_bps: 2000, locale: "de-DE" }) -> "20,0 %"
|
|
491
|
+
* format({ rate_bps: 2000, locale: "fr-FR" }) -> "20,0 %" (or NBSP variant)
|
|
492
|
+
*
|
|
493
|
+
* Locale defaults to `"en-US"`. The `reverse_charge` annotation is
|
|
494
|
+
* intentionally English-only in v1 — operator-facing accounting
|
|
495
|
+
* strings ship in English on every storefront the framework
|
|
496
|
+
* currently supports; localising the bracketed phrase lands when
|
|
497
|
+
* the storefront primitive grows a translation surface.
|
|
498
|
+
*/
|
|
499
|
+
function format(args) {
|
|
500
|
+
if (!args || typeof args !== "object") throw new TypeError("tax.format: args object required");
|
|
501
|
+
_bps(args.rate_bps);
|
|
502
|
+
if (args.locale !== undefined && args.locale !== null) {
|
|
503
|
+
if (typeof args.locale !== "string" || args.locale.length === 0) {
|
|
504
|
+
throw new TypeError("tax.format: locale must be a non-empty BCP 47 string");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
var locale = args.locale || "en-US";
|
|
508
|
+
var nf;
|
|
509
|
+
try {
|
|
510
|
+
nf = new Intl.NumberFormat(locale, {
|
|
511
|
+
style: "percent",
|
|
512
|
+
minimumFractionDigits: 1,
|
|
513
|
+
maximumFractionDigits: 2,
|
|
514
|
+
});
|
|
515
|
+
} catch (_e) {
|
|
516
|
+
throw new TypeError("tax.format: locale " + JSON.stringify(locale) + " rejected by Intl.NumberFormat");
|
|
517
|
+
}
|
|
518
|
+
// Intl percent formatter expects a decimal fraction (0.2 → "20%").
|
|
519
|
+
var rendered = nf.format(args.rate_bps / 10000);
|
|
520
|
+
// Strip a redundant ".0" / ",0" when the rate is a whole percent
|
|
521
|
+
// AND not the zero rate — "20.0%" reads naturally; "0.0%" reads
|
|
522
|
+
// as a precision claim we don't intend. For 0% with the reverse-
|
|
523
|
+
// charge flag we always render "0%" to keep the annotation
|
|
524
|
+
// grammatical.
|
|
525
|
+
if (args.reverse_charge === true && args.rate_bps === 0) {
|
|
526
|
+
// Intl renders 0 as "0%" already in en-US but as "0 %" in de/fr;
|
|
527
|
+
// we want a stable shape with the annotation.
|
|
528
|
+
var zero = new Intl.NumberFormat(locale, {
|
|
529
|
+
style: "percent",
|
|
530
|
+
minimumFractionDigits: 0,
|
|
531
|
+
maximumFractionDigits: 0,
|
|
532
|
+
}).format(0);
|
|
533
|
+
return zero + " (reverse charge)";
|
|
534
|
+
}
|
|
535
|
+
return rendered;
|
|
536
|
+
}
|
|
537
|
+
|
|
157
538
|
module.exports = {
|
|
158
|
-
create:
|
|
159
|
-
operatorTable:
|
|
160
|
-
MAX_BPS:
|
|
539
|
+
create: create,
|
|
540
|
+
operatorTable: operatorTable,
|
|
541
|
+
MAX_BPS: MAX_BPS,
|
|
542
|
+
calculateInclusive: calculateInclusive,
|
|
543
|
+
calculateExclusive: calculateExclusive,
|
|
544
|
+
validateVatId: validateVatId,
|
|
545
|
+
applyReverseCharge: applyReverseCharge,
|
|
546
|
+
format: format,
|
|
547
|
+
VAT_FORMATS: VAT_FORMATS,
|
|
548
|
+
EU_COUNTRY_CODES: EU_COUNTRY_CODES,
|
|
161
549
|
};
|