@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/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: create,
159
- operatorTable: operatorTable,
160
- MAX_BPS: 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
  };
@@ -13,7 +13,7 @@
13
13
  "server": "lib/vendor/blamejs/"
14
14
  },
15
15
  "bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
16
- "bundledAt": "2026-05-22"
16
+ "bundledAt": "2026-05-23"
17
17
  }
18
18
  }
19
19
  }