@blamejs/blamejs-shop 0.0.64 → 0.0.65

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.
@@ -0,0 +1,699 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.priceDisplay
4
+ * @title Per-region price-display mode + per-customer overrides
5
+ *
6
+ * @intro
7
+ * The catalog stores one set of prices. Whether the storefront
8
+ * renders a line as `€12.00` (tax included — the EU B2C default) or
9
+ * `€10.00 + VAT` (tax excluded — the standard B2B presentation, and
10
+ * the US default) is a *display* decision, not a pricing one.
11
+ *
12
+ * Operators declare per-country presentation defaults via
13
+ * `defineRule({ country, mode, customer_status_in? })`. The
14
+ * storefront calls `getModeFor({ country, customer_status? })` at
15
+ * render time and resolves with the precedence:
16
+ *
17
+ * 1. A per-customer override (set via `defineCustomerOverride`)
18
+ * always wins. B2B accounts trading in the EU often need
19
+ * pre-tax presentation even when the country defaults to
20
+ * tax-included; the customer-override table is the direct
21
+ * lever for that pin.
22
+ *
23
+ * 2. The country rule applies when either it has no
24
+ * `customer_status_in` filter (the country default), OR the
25
+ * request's `customer_status` is in the rule's filter list
26
+ * (the status-narrowed form).
27
+ *
28
+ * 3. No rule, no status match — `getModeFor` returns `null`,
29
+ * leaving the storefront free to pick its own fallback
30
+ * (typically `tax_excluded` for safety, since under-disclosing
31
+ * tax is the riskier failure mode than over-disclosing it).
32
+ *
33
+ * The mode vocabulary is the SQL CHECK constraint's enum:
34
+ *
35
+ * tax_included — display gross (tax already in the figure)
36
+ * tax_excluded — display net (tax shown separately or omitted)
37
+ * both — display both forms in a single label
38
+ *
39
+ * `formatPrice({ amount_minor, currency, country, customer_status?,
40
+ * locale })` resolves the mode, optionally composes
41
+ * the injected `tax` primitive's `calculateInclusive` /
42
+ * `calculateExclusive` to derive a `{ net, tax, gross }` breakdown,
43
+ * and returns a locale-aware display label via `b.money.format` so
44
+ * ISO 4217 exponent rules (JPY zero-decimal, BHD three-decimal)
45
+ * render correctly without per-currency special-casing.
46
+ *
47
+ * `tax` resolution: when the operator wires a `tax` adapter
48
+ * (`{ calculateInclusive, calculateExclusive }`) the primitive
49
+ * composes against it AND looks up a per-country rate from the
50
+ * operator's injected `taxRates` map (or the optional `geolocation`
51
+ * handle's resolved country). When no tax adapter is wired the
52
+ * primitive returns the display label without a breakdown — useful
53
+ * for catalogs that don't apply tax at the storefront layer at all.
54
+ *
55
+ * Composition:
56
+ *
57
+ * var pd = priceDisplay.create({
58
+ * query: q,
59
+ * tax: bShop.tax, // optional
60
+ * taxRates: { DE: 1900, FR: 2000, US: 0 }, // optional
61
+ * });
62
+ * await pd.defineRule({ country: "DE", mode: "tax_included" });
63
+ * await pd.defineRule({
64
+ * country: "US", mode: "tax_excluded",
65
+ * });
66
+ * await pd.defineCustomerOverride({
67
+ * customer_id: "cus_b2b_42", mode: "tax_excluded",
68
+ * });
69
+ * var out = await pd.formatPrice({
70
+ * amount_minor: 12000,
71
+ * currency: "EUR",
72
+ * country: "DE",
73
+ * customer_status: "b2c",
74
+ * locale: "de-DE",
75
+ * });
76
+ * // {
77
+ * // display_minor: 12000,
78
+ * // display_label: "120,00 €",
79
+ * // tax_breakdown: { net: 10084, tax: 1916, gross: 12000 },
80
+ * // }
81
+ *
82
+ * @related shop.tax, shop.currencyDisplay, shop.geolocation
83
+ */
84
+
85
+ var bShop;
86
+ function _b() {
87
+ if (!bShop) bShop = require("./index");
88
+ return bShop.framework;
89
+ }
90
+
91
+ // ---- constants ----------------------------------------------------------
92
+
93
+ var COUNTRY_RE = /^[A-Z]{2}$/;
94
+ var CURRENCY_RE = /^[A-Z]{3}$/;
95
+ var STATUS_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
96
+ var CUSTOMER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
97
+ var MAX_STATUSES = 16;
98
+ var MAX_LIST_LIMIT = 500;
99
+ var MAX_BPS = 10000;
100
+ var MODES = ["tax_included", "tax_excluded", "both"];
101
+ var ALLOWED_PATCH = ["mode", "customer_status_in"];
102
+
103
+ // ---- validation helpers -------------------------------------------------
104
+
105
+ function _country(c, label) {
106
+ if (typeof c !== "string" || !COUNTRY_RE.test(c)) {
107
+ throw new TypeError(
108
+ "priceDisplay: " + label +
109
+ " must be a 2-letter uppercase ISO 3166-1 country code, got " +
110
+ JSON.stringify(c)
111
+ );
112
+ }
113
+ return c;
114
+ }
115
+
116
+ function _currency(c, label) {
117
+ if (typeof c !== "string" || !CURRENCY_RE.test(c)) {
118
+ throw new TypeError(
119
+ "priceDisplay: " + label +
120
+ " must be a 3-letter uppercase ISO 4217 code, got " +
121
+ JSON.stringify(c)
122
+ );
123
+ }
124
+ return c;
125
+ }
126
+
127
+ function _mode(m, label) {
128
+ if (typeof m !== "string" || MODES.indexOf(m) === -1) {
129
+ throw new TypeError(
130
+ "priceDisplay: " + label +
131
+ " must be one of (" + MODES.join(", ") + "), got " +
132
+ JSON.stringify(m)
133
+ );
134
+ }
135
+ return m;
136
+ }
137
+
138
+ function _status(s, label) {
139
+ if (typeof s !== "string" || !STATUS_RE.test(s)) {
140
+ throw new TypeError(
141
+ "priceDisplay: " + label +
142
+ " must match /[a-z0-9][a-z0-9_-]{0,31}/, got " +
143
+ JSON.stringify(s)
144
+ );
145
+ }
146
+ return s;
147
+ }
148
+
149
+ function _customerId(id, label) {
150
+ if (typeof id !== "string" || !CUSTOMER_ID_RE.test(id)) {
151
+ throw new TypeError(
152
+ "priceDisplay: " + label +
153
+ " must match /[A-Za-z0-9][A-Za-z0-9_.-]{0,127}/, got " +
154
+ JSON.stringify(id)
155
+ );
156
+ }
157
+ return id;
158
+ }
159
+
160
+ function _statusList(list, label) {
161
+ if (list == null) return null;
162
+ if (!Array.isArray(list)) {
163
+ throw new TypeError(
164
+ "priceDisplay: " + label + " must be an array when present"
165
+ );
166
+ }
167
+ if (list.length === 0) return null;
168
+ if (list.length > MAX_STATUSES) {
169
+ throw new TypeError(
170
+ "priceDisplay: " + label +
171
+ " must contain at most " + MAX_STATUSES + " entries, got " + list.length
172
+ );
173
+ }
174
+ var seen = {};
175
+ var out = [];
176
+ for (var i = 0; i < list.length; i += 1) {
177
+ var s = _status(list[i], label + "[" + i + "]");
178
+ if (seen[s]) continue;
179
+ seen[s] = true;
180
+ out.push(s);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function _nonNegInt(n, label) {
186
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
187
+ throw new TypeError(
188
+ "priceDisplay: " + label +
189
+ " must be a non-negative integer (minor units), got " +
190
+ JSON.stringify(n)
191
+ );
192
+ }
193
+ return n;
194
+ }
195
+
196
+ function _bps(n, label) {
197
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BPS) {
198
+ throw new TypeError(
199
+ "priceDisplay: " + label +
200
+ " must be an integer 0..." + MAX_BPS + " (1 bp = 0.01%), got " +
201
+ JSON.stringify(n)
202
+ );
203
+ }
204
+ return n;
205
+ }
206
+
207
+ function _locale(l) {
208
+ if (l == null) return "en-US";
209
+ if (typeof l !== "string" || l.length === 0) {
210
+ throw new TypeError(
211
+ "priceDisplay: locale must be a non-empty BCP 47 string, got " +
212
+ JSON.stringify(l)
213
+ );
214
+ }
215
+ return l;
216
+ }
217
+
218
+ // ---- row hydration ------------------------------------------------------
219
+
220
+ function _safeParseArray(s) {
221
+ if (s == null) return null;
222
+ try {
223
+ var parsed = JSON.parse(s);
224
+ if (Array.isArray(parsed)) return parsed.length ? parsed : null;
225
+ return null;
226
+ } catch (_e) {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ function _hydrateRule(r) {
232
+ if (!r) return null;
233
+ return {
234
+ country: String(r.country),
235
+ mode: String(r.mode),
236
+ customer_status_in: _safeParseArray(r.customer_status_in_json),
237
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
238
+ created_at: Number(r.created_at),
239
+ updated_at: Number(r.updated_at),
240
+ };
241
+ }
242
+
243
+ function _hydrateOverride(r) {
244
+ if (!r) return null;
245
+ return {
246
+ customer_id: String(r.customer_id),
247
+ mode: String(r.mode),
248
+ set_at: Number(r.set_at),
249
+ };
250
+ }
251
+
252
+ // ---- tax-rate lookup ----------------------------------------------------
253
+
254
+ // The operator wires either a static `taxRates` map (country -> bps),
255
+ // a function (country -> bps | Promise<bps>), or both a taxRates value
256
+ // AND a geolocation handle. When the request's country is missing the
257
+ // optional geolocation lookup fills it in; the taxRates map then
258
+ // resolves the bps for that country. A missing country in the map
259
+ // resolves to null — the primitive then returns the display label
260
+ // without a tax_breakdown.
261
+ async function _resolveRateBps(country, taxRates) {
262
+ if (taxRates == null) return null;
263
+ if (typeof taxRates === "function") {
264
+ var r = await taxRates(country);
265
+ if (r == null) return null;
266
+ return _bps(r, "taxRates(" + country + ")");
267
+ }
268
+ if (typeof taxRates === "object") {
269
+ if (!Object.prototype.hasOwnProperty.call(taxRates, country)) return null;
270
+ var b = taxRates[country];
271
+ if (b == null) return null;
272
+ return _bps(b, "taxRates." + country);
273
+ }
274
+ throw new TypeError(
275
+ "priceDisplay: taxRates must be an object (country -> bps), a function, or null"
276
+ );
277
+ }
278
+
279
+ // ---- factory ------------------------------------------------------------
280
+
281
+ function create(opts) {
282
+ opts = opts || {};
283
+ var query = opts.query;
284
+ if (!query) {
285
+ query = function (sql, params) {
286
+ return _b().externalDb.query(sql, params);
287
+ };
288
+ }
289
+ var tax = opts.tax || null;
290
+ var taxRates = opts.taxRates == null ? null : opts.taxRates;
291
+ var geolocation = opts.geolocation || null;
292
+
293
+ if (tax != null && typeof tax !== "object") {
294
+ throw new TypeError("priceDisplay.create: tax must be an object handle when present");
295
+ }
296
+ if (tax != null) {
297
+ if (typeof tax.calculateInclusive !== "function" ||
298
+ typeof tax.calculateExclusive !== "function") {
299
+ throw new TypeError(
300
+ "priceDisplay.create: tax handle must expose calculateInclusive + calculateExclusive"
301
+ );
302
+ }
303
+ }
304
+ if (geolocation != null && typeof geolocation !== "object") {
305
+ throw new TypeError(
306
+ "priceDisplay.create: geolocation must be an object handle when present"
307
+ );
308
+ }
309
+
310
+ function _now() { return Date.now(); }
311
+
312
+ // ---- defineRule ----------------------------------------------------
313
+
314
+ async function defineRule(input) {
315
+ if (!input || typeof input !== "object") {
316
+ throw new TypeError("priceDisplay.defineRule: input object required");
317
+ }
318
+ var country = _country(input.country, "country");
319
+ var mode = _mode(input.mode, "mode");
320
+ var statuses = _statusList(input.customer_status_in, "customer_status_in");
321
+
322
+ // Refuse a redefine — operators should `updateRule` to mutate an
323
+ // existing country. A blind INSERT against the country-PK column
324
+ // would error at the SQL layer; the explicit precheck produces a
325
+ // friendlier message.
326
+ var existing = (await query(
327
+ "SELECT country FROM price_display_rules WHERE country = ?1 LIMIT 1",
328
+ [country]
329
+ )).rows[0];
330
+ if (existing) {
331
+ throw new TypeError(
332
+ "priceDisplay.defineRule: country " + JSON.stringify(country) +
333
+ " already has a rule — use updateRule"
334
+ );
335
+ }
336
+
337
+ var ts = _now();
338
+ await query(
339
+ "INSERT INTO price_display_rules (country, mode, customer_status_in_json, " +
340
+ "archived_at, created_at, updated_at) " +
341
+ "VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
342
+ [country, mode, statuses == null ? null : JSON.stringify(statuses), ts]
343
+ );
344
+ return await getRule(country);
345
+ }
346
+
347
+ // ---- getRule / listRules -------------------------------------------
348
+
349
+ async function getRule(country) {
350
+ _country(country, "country");
351
+ var r = (await query(
352
+ "SELECT * FROM price_display_rules WHERE country = ?1 LIMIT 1",
353
+ [country]
354
+ )).rows[0];
355
+ return _hydrateRule(r);
356
+ }
357
+
358
+ async function listRules(listOpts) {
359
+ listOpts = listOpts || {};
360
+ var includeArchived = false;
361
+ if (listOpts.include_archived != null) {
362
+ if (typeof listOpts.include_archived !== "boolean") {
363
+ throw new TypeError(
364
+ "priceDisplay.listRules: include_archived must be a boolean"
365
+ );
366
+ }
367
+ includeArchived = listOpts.include_archived;
368
+ }
369
+ var limit = listOpts.limit == null ? 100 : listOpts.limit;
370
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
371
+ throw new TypeError(
372
+ "priceDisplay.listRules: limit must be an integer in [1, " +
373
+ MAX_LIST_LIMIT + "]"
374
+ );
375
+ }
376
+ var sql;
377
+ if (includeArchived) {
378
+ sql = "SELECT * FROM price_display_rules ORDER BY country ASC LIMIT ?1";
379
+ } else {
380
+ sql = "SELECT * FROM price_display_rules WHERE archived_at IS NULL " +
381
+ "ORDER BY country ASC LIMIT ?1";
382
+ }
383
+ var rows = (await query(sql, [limit])).rows;
384
+ var out = [];
385
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRule(rows[i]));
386
+ return out;
387
+ }
388
+
389
+ // ---- updateRule ----------------------------------------------------
390
+
391
+ async function updateRule(input) {
392
+ if (!input || typeof input !== "object") {
393
+ throw new TypeError("priceDisplay.updateRule: input object required");
394
+ }
395
+ var country = _country(input.country, "country");
396
+ var patch = input.patch;
397
+ if (!patch || typeof patch !== "object") {
398
+ throw new TypeError("priceDisplay.updateRule: patch object required");
399
+ }
400
+ var keys = Object.keys(patch);
401
+ if (!keys.length) {
402
+ throw new TypeError(
403
+ "priceDisplay.updateRule: patch must include at least one column"
404
+ );
405
+ }
406
+ var current = await getRule(country);
407
+ if (!current) {
408
+ throw new TypeError(
409
+ "priceDisplay.updateRule: country " + JSON.stringify(country) +
410
+ " has no rule"
411
+ );
412
+ }
413
+
414
+ var sets = [];
415
+ var params = [];
416
+ var idx = 1;
417
+ for (var i = 0; i < keys.length; i += 1) {
418
+ var col = keys[i];
419
+ if (ALLOWED_PATCH.indexOf(col) === -1) {
420
+ throw new TypeError(
421
+ "priceDisplay.updateRule: unsupported column " +
422
+ JSON.stringify(col)
423
+ );
424
+ }
425
+ if (col === "mode") {
426
+ sets.push("mode = ?" + idx);
427
+ params.push(_mode(patch[col], "mode"));
428
+ } else /* customer_status_in */ {
429
+ var v = _statusList(patch[col], "customer_status_in");
430
+ sets.push("customer_status_in_json = ?" + idx);
431
+ params.push(v == null ? null : JSON.stringify(v));
432
+ }
433
+ idx += 1;
434
+ }
435
+ sets.push("updated_at = ?" + idx);
436
+ params.push(_now());
437
+ idx += 1;
438
+ params.push(country);
439
+
440
+ var r = await query(
441
+ "UPDATE price_display_rules SET " + sets.join(", ") +
442
+ " WHERE country = ?" + idx,
443
+ params
444
+ );
445
+ if (Number(r.rowCount || 0) === 0) {
446
+ throw new TypeError(
447
+ "priceDisplay.updateRule: country " + JSON.stringify(country) +
448
+ " not found"
449
+ );
450
+ }
451
+ return await getRule(country);
452
+ }
453
+
454
+ // ---- archiveRule ---------------------------------------------------
455
+
456
+ async function archiveRule(country) {
457
+ _country(country, "country");
458
+ var ts = _now();
459
+ var r = await query(
460
+ "UPDATE price_display_rules SET archived_at = ?1, updated_at = ?1 " +
461
+ "WHERE country = ?2 AND archived_at IS NULL",
462
+ [ts, country]
463
+ );
464
+ if (Number(r.rowCount || 0) === 0) {
465
+ var existing = await getRule(country);
466
+ if (!existing) {
467
+ throw new TypeError(
468
+ "priceDisplay.archiveRule: country " + JSON.stringify(country) +
469
+ " not found"
470
+ );
471
+ }
472
+ // Idempotent — already archived. Return the existing row so a
473
+ // sweep that archives every legacy country doesn't have to
474
+ // special-case the ones a coworker already retired.
475
+ return existing;
476
+ }
477
+ return await getRule(country);
478
+ }
479
+
480
+ // ---- customer overrides --------------------------------------------
481
+
482
+ async function defineCustomerOverride(input) {
483
+ if (!input || typeof input !== "object") {
484
+ throw new TypeError(
485
+ "priceDisplay.defineCustomerOverride: input object required"
486
+ );
487
+ }
488
+ var customerId = _customerId(input.customer_id, "customer_id");
489
+ var mode = _mode(input.mode, "mode");
490
+ var ts = _now();
491
+ // Upsert — operators flip a customer's override in place without
492
+ // having to clear-then-set. The row identity IS the customer_id;
493
+ // re-issuing overwrites the mode + bumps set_at.
494
+ await query(
495
+ "INSERT INTO customer_price_display_overrides (customer_id, mode, set_at) " +
496
+ "VALUES (?1, ?2, ?3) " +
497
+ "ON CONFLICT(customer_id) DO UPDATE SET mode = excluded.mode, set_at = excluded.set_at",
498
+ [customerId, mode, ts]
499
+ );
500
+ return await customerOverride(customerId);
501
+ }
502
+
503
+ async function clearCustomerOverride(input) {
504
+ var customerId;
505
+ if (typeof input === "string") {
506
+ customerId = _customerId(input, "customer_id");
507
+ } else if (input && typeof input === "object") {
508
+ customerId = _customerId(input.customer_id, "customer_id");
509
+ } else {
510
+ throw new TypeError(
511
+ "priceDisplay.clearCustomerOverride: customer_id or { customer_id } required"
512
+ );
513
+ }
514
+ var r = await query(
515
+ "DELETE FROM customer_price_display_overrides WHERE customer_id = ?1",
516
+ [customerId]
517
+ );
518
+ return { cleared: Number(r.rowCount || 0) > 0, customer_id: customerId };
519
+ }
520
+
521
+ async function customerOverride(customerId) {
522
+ _customerId(customerId, "customer_id");
523
+ var r = (await query(
524
+ "SELECT * FROM customer_price_display_overrides WHERE customer_id = ?1 LIMIT 1",
525
+ [customerId]
526
+ )).rows[0];
527
+ return _hydrateOverride(r);
528
+ }
529
+
530
+ // ---- getModeFor ----------------------------------------------------
531
+ //
532
+ // Resolution precedence:
533
+ // 1. customer-override row (when input.customer_id is supplied AND
534
+ // a row exists)
535
+ // 2. country rule, when the rule has no customer_status_in filter
536
+ // OR the request's customer_status is in the filter list
537
+ // 3. null (no rule applies — caller decides the fallback)
538
+ //
539
+ // Archived rules never apply; they're retained for historic-audit
540
+ // reads via `listRules({ include_archived: true })`.
541
+
542
+ async function getModeFor(input) {
543
+ if (!input || typeof input !== "object") {
544
+ throw new TypeError("priceDisplay.getModeFor: input object required");
545
+ }
546
+ var country = _country(input.country, "country");
547
+ var status = input.customer_status == null
548
+ ? null
549
+ : _status(input.customer_status, "customer_status");
550
+ var customerId = input.customer_id == null
551
+ ? null
552
+ : _customerId(input.customer_id, "customer_id");
553
+
554
+ if (customerId) {
555
+ var ov = await customerOverride(customerId);
556
+ if (ov) {
557
+ return { mode: ov.mode, source: "customer_override" };
558
+ }
559
+ }
560
+ var rule = await getRule(country);
561
+ if (!rule || rule.archived_at != null) {
562
+ return { mode: null, source: null };
563
+ }
564
+ if (rule.customer_status_in == null) {
565
+ return { mode: rule.mode, source: "country_default" };
566
+ }
567
+ if (status && rule.customer_status_in.indexOf(status) !== -1) {
568
+ return { mode: rule.mode, source: "customer_status" };
569
+ }
570
+ return { mode: null, source: null };
571
+ }
572
+
573
+ // ---- formatPrice ---------------------------------------------------
574
+ //
575
+ // Resolves the display mode, optionally composes the tax adapter for
576
+ // a `{ net, tax, gross }` breakdown, and renders a locale-aware
577
+ // display label via b.money.format. The amount_minor argument is the
578
+ // catalog's stored value — the primitive interprets it AS the figure
579
+ // matching the resolved mode (i.e. when mode is `tax_included` the
580
+ // stored figure is the gross; when mode is `tax_excluded` it's the
581
+ // net). Operators that store one canonical form and want to render
582
+ // the other compose `convertAndFormat` upstream.
583
+
584
+ async function formatPrice(input) {
585
+ if (!input || typeof input !== "object") {
586
+ throw new TypeError("priceDisplay.formatPrice: input object required");
587
+ }
588
+ _nonNegInt(input.amount_minor, "amount_minor");
589
+ _currency(input.currency, "currency");
590
+ var country = input.country;
591
+ if (country == null && geolocation && typeof geolocation.resolveCountry === "function") {
592
+ country = await geolocation.resolveCountry(input);
593
+ }
594
+ _country(country, "country");
595
+ var status = input.customer_status == null
596
+ ? null
597
+ : _status(input.customer_status, "customer_status");
598
+ var customerId = input.customer_id == null
599
+ ? null
600
+ : _customerId(input.customer_id, "customer_id");
601
+ var locale = _locale(input.locale);
602
+
603
+ var resolved = await getModeFor({
604
+ country: country,
605
+ customer_status: status,
606
+ customer_id: customerId,
607
+ });
608
+ var mode = resolved.mode;
609
+
610
+ // Locale-aware money rendering — delegate to b.money so the ISO
611
+ // 4217 exponent (JPY 0, USD 2, BHD 3) is correct without local
612
+ // bookkeeping. Mirrors currencyDisplay.format.
613
+ var money = _b().money.fromMinorUnits(BigInt(input.amount_minor), input.currency);
614
+ var displayLabel = money.format(locale);
615
+
616
+ var out = {
617
+ display_minor: input.amount_minor,
618
+ display_label: displayLabel,
619
+ mode: mode,
620
+ source: resolved.source,
621
+ };
622
+
623
+ // No tax adapter wired OR no tax-rate resolvable for this country
624
+ // — return the bare display. The storefront can still render the
625
+ // line; it just lacks the inline net/tax/gross split.
626
+ if (!tax || !mode || mode === "tax_excluded" && taxRates == null) {
627
+ // Fall through — mode could still produce a label below; we
628
+ // only short-circuit the breakdown computation.
629
+ }
630
+
631
+ if (tax && mode) {
632
+ var rateBps = await _resolveRateBps(country, taxRates);
633
+ if (rateBps != null) {
634
+ if (mode === "tax_included") {
635
+ var inc = tax.calculateInclusive({
636
+ amount_minor: input.amount_minor,
637
+ rate_bps: rateBps,
638
+ currency: input.currency,
639
+ });
640
+ out.tax_breakdown = {
641
+ net: inc.net_minor,
642
+ tax: inc.tax_minor,
643
+ gross: inc.gross_minor,
644
+ };
645
+ } else if (mode === "tax_excluded") {
646
+ var exc = tax.calculateExclusive({
647
+ amount_minor: input.amount_minor,
648
+ rate_bps: rateBps,
649
+ currency: input.currency,
650
+ });
651
+ out.tax_breakdown = {
652
+ net: exc.net_minor,
653
+ tax: exc.tax_minor,
654
+ gross: exc.gross_minor,
655
+ };
656
+ } else /* both */ {
657
+ // `both` renders two labels — the stored amount_minor is
658
+ // treated as the gross by convention so the net + tax pair
659
+ // is derivable without ambiguity. Operators that store net
660
+ // for "both" callers extract the bps from the rule, recompute
661
+ // the gross, and pass the gross in.
662
+ var bo = tax.calculateInclusive({
663
+ amount_minor: input.amount_minor,
664
+ rate_bps: rateBps,
665
+ currency: input.currency,
666
+ });
667
+ out.tax_breakdown = {
668
+ net: bo.net_minor,
669
+ tax: bo.tax_minor,
670
+ gross: bo.gross_minor,
671
+ };
672
+ // Build a "both" label: "120,00 € incl. — 100,84 € ex."
673
+ var netMoney = _b().money.fromMinorUnits(BigInt(bo.net_minor), input.currency);
674
+ out.display_label = displayLabel + " incl. — " + netMoney.format(locale) + " ex.";
675
+ }
676
+ }
677
+ }
678
+ return out;
679
+ }
680
+
681
+ return {
682
+ MODES: MODES.slice(),
683
+ defineRule: defineRule,
684
+ getRule: getRule,
685
+ listRules: listRules,
686
+ updateRule: updateRule,
687
+ archiveRule: archiveRule,
688
+ defineCustomerOverride: defineCustomerOverride,
689
+ clearCustomerOverride: clearCustomerOverride,
690
+ customerOverride: customerOverride,
691
+ getModeFor: getModeFor,
692
+ formatPrice: formatPrice,
693
+ };
694
+ }
695
+
696
+ module.exports = {
697
+ create: create,
698
+ MODES: MODES.slice(),
699
+ };