@fire-spark/core 0.0.2 → 0.0.4
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/dist/index.d.ts +758 -35
- package/dist/index.js +2654 -362
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAttemptReference, createPaymentError, currencyDecimals, defaultReversalFor, isPaymentError, memoryPaymentStorage, missingDeviceFields, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, withTerminalLock } from "@fire-spark/payments";
|
|
1
2
|
import advancedFormat from "dayjs/plugin/advancedFormat.js";
|
|
2
3
|
import isBetween from "dayjs/plugin/isBetween.js";
|
|
3
4
|
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
|
|
@@ -27,10 +28,37 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
28
|
enumerable: true
|
|
28
29
|
}) : target, mod));
|
|
29
30
|
//#endregion
|
|
31
|
+
//#region src/productCustomization/clamp.utils.ts
|
|
32
|
+
const clampModifierQuantity = (type, quantity, min, max) => {
|
|
33
|
+
let next = Math.max(0, Math.floor(quantity));
|
|
34
|
+
if (type === "RADIO" || type === "CHECKBOX") next = next > 0 ? 1 : 0;
|
|
35
|
+
if (next === 0) return 0;
|
|
36
|
+
let lower = min;
|
|
37
|
+
if (type === "QUANTITY") lower = 0;
|
|
38
|
+
return Math.min(max, Math.max(lower, next));
|
|
39
|
+
};
|
|
40
|
+
const clampProductQuantity = (quantity, min, max) => {
|
|
41
|
+
return Math.min(max, Math.max(min, Math.max(0, Math.floor(quantity))));
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
30
44
|
//#region src/productCustomization/catalog.utils.ts
|
|
31
45
|
const resolvePricingModifier = (product, groupId) => {
|
|
32
46
|
return product.pricing.modifiers.find((modifier) => modifier.modifier_group_id === groupId);
|
|
33
47
|
};
|
|
48
|
+
/** Unit price and tax of a modifier in a group. A variant base listed in the
|
|
49
|
+
* group keeps the limits; the variant it resolves to sets what is charged. */
|
|
50
|
+
const modifierUnitPricing = (listed, ordered, groupId) => {
|
|
51
|
+
const product = ordered ?? listed;
|
|
52
|
+
const entry = resolvePricingModifier(product, groupId);
|
|
53
|
+
const { price, tax_rate, is_tax_inclusive } = product.pricing;
|
|
54
|
+
const taxRate = entry?.tax_rate ?? tax_rate;
|
|
55
|
+
const isTaxInclusive = entry?.is_tax_inclusive ?? is_tax_inclusive;
|
|
56
|
+
return {
|
|
57
|
+
price: entry?.price ?? price,
|
|
58
|
+
taxRate,
|
|
59
|
+
isTaxInclusive
|
|
60
|
+
};
|
|
61
|
+
};
|
|
34
62
|
const inferControlType = (pricingModifier, group) => {
|
|
35
63
|
if (pricingModifier?.display_type) return pricingModifier.display_type;
|
|
36
64
|
const gMin = group.minimum_quantity;
|
|
@@ -65,12 +93,12 @@ const collectReferencedGroupIds = (products) => {
|
|
|
65
93
|
for (const product of products) for (const groupId of product.pricing.modifier_group_ids ?? []) ids.add(groupId);
|
|
66
94
|
return ids;
|
|
67
95
|
};
|
|
68
|
-
const inferGroupFromProducts = (groupId, modifierIds,
|
|
96
|
+
const inferGroupFromProducts = (groupId, modifierIds, productsById) => {
|
|
69
97
|
let minimum_quantity = 0;
|
|
70
98
|
let maximum_quantity = 1;
|
|
71
99
|
let charge_above_quantity = null;
|
|
72
100
|
for (const modifierId of modifierIds) {
|
|
73
|
-
const entry =
|
|
101
|
+
const entry = productsById.get(modifierId)?.pricing.modifiers?.find((modifier) => modifier.modifier_group_id === groupId);
|
|
74
102
|
if (!entry) continue;
|
|
75
103
|
minimum_quantity = Math.max(minimum_quantity, entry.minimum_quantity ?? 0);
|
|
76
104
|
maximum_quantity = Math.max(maximum_quantity, entry.maximum_quantity ?? 1);
|
|
@@ -94,26 +122,324 @@ const resolveModifierGroupsForLookup = (products, modifierGroups) => {
|
|
|
94
122
|
const idsByGroup = collectModifierIdsByGroupId(products);
|
|
95
123
|
const referenced = collectReferencedGroupIds(products);
|
|
96
124
|
const needed = /* @__PURE__ */ new Set([...referenced, ...idsByGroup.keys()]);
|
|
125
|
+
let productsById = null;
|
|
97
126
|
for (const groupId of needed) {
|
|
98
127
|
if (byId.has(groupId)) continue;
|
|
99
128
|
const modifierIds = idsByGroup.get(groupId) ?? [];
|
|
100
129
|
if (modifierIds.length === 0) continue;
|
|
101
|
-
|
|
130
|
+
productsById ??= new Map(products.map((product) => [product.id, product]));
|
|
131
|
+
const args = [
|
|
132
|
+
groupId,
|
|
133
|
+
modifierIds,
|
|
134
|
+
productsById
|
|
135
|
+
];
|
|
136
|
+
byId.set(groupId, inferGroupFromProducts(...args));
|
|
102
137
|
}
|
|
103
138
|
return [...byId.values()];
|
|
104
139
|
};
|
|
105
140
|
//#endregion
|
|
106
|
-
//#region src/productCustomization/
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
//#region src/productCustomization/store.ts
|
|
142
|
+
const createStore = () => {
|
|
143
|
+
return {
|
|
144
|
+
quantities: {},
|
|
145
|
+
initialQuantities: {},
|
|
146
|
+
selections: {},
|
|
147
|
+
initialSelections: {}
|
|
148
|
+
};
|
|
114
149
|
};
|
|
115
|
-
const
|
|
116
|
-
return
|
|
150
|
+
const getQuantity = (store, path) => {
|
|
151
|
+
return store.quantities[path] ?? 0;
|
|
152
|
+
};
|
|
153
|
+
const setQuantity = (store, path, quantity) => {
|
|
154
|
+
if (quantity <= 0) delete store.quantities[path];
|
|
155
|
+
if (quantity <= 0) return;
|
|
156
|
+
store.quantities[path] = quantity;
|
|
157
|
+
};
|
|
158
|
+
const isInSubtree = (key, path) => {
|
|
159
|
+
return key === path || key.startsWith(`${path}/`);
|
|
160
|
+
};
|
|
161
|
+
const clearSubtree = (store, path) => {
|
|
162
|
+
for (const key of Object.keys(store.quantities)) if (isInSubtree(key, path)) delete store.quantities[key];
|
|
163
|
+
for (const key of Object.keys(store.selections)) if (isInSubtree(key, path)) delete store.selections[key];
|
|
164
|
+
};
|
|
165
|
+
/** True when `productId` already sits on `path` (root or any modifier), so
|
|
166
|
+
* descending into it again would loop through a cyclic menu. */
|
|
167
|
+
const pathVisits = (path, productId) => {
|
|
168
|
+
if (isInSubtree(path, `/${productId}`)) return true;
|
|
169
|
+
const segment = `/modifier/${productId}`;
|
|
170
|
+
return path.endsWith(segment) || path.includes(`${segment}/`);
|
|
171
|
+
};
|
|
172
|
+
/** Entries at or below `path`, e.g. to restore one product's subtree. */
|
|
173
|
+
const pickSubtree = (entries, path) => {
|
|
174
|
+
const picked = {};
|
|
175
|
+
for (const [key, value] of Object.entries(entries)) if (isInSubtree(key, path)) picked[key] = value;
|
|
176
|
+
return picked;
|
|
177
|
+
};
|
|
178
|
+
const cloneQuantities = (quantities) => {
|
|
179
|
+
return { ...quantities };
|
|
180
|
+
};
|
|
181
|
+
const cloneSelections = (selections) => {
|
|
182
|
+
const next = {};
|
|
183
|
+
for (const [key, value] of Object.entries(selections)) next[key] = { ...value };
|
|
184
|
+
return next;
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/productCustomization/variants.ts
|
|
188
|
+
const isSelectable = (product) => {
|
|
189
|
+
if (product.status === "INACTIVE") return false;
|
|
190
|
+
return product.availability.status !== "HIDDEN";
|
|
191
|
+
};
|
|
192
|
+
const isInStock = (product) => {
|
|
193
|
+
return product.availability.status !== "OUT_OF_STOCK";
|
|
194
|
+
};
|
|
195
|
+
const isVariantBase = (product) => {
|
|
196
|
+
return (product.pricing.variant_group_ids ?? []).length > 0;
|
|
197
|
+
};
|
|
198
|
+
const variantGroupsOf = (lookup, base) => {
|
|
199
|
+
const groups = [];
|
|
200
|
+
for (const groupId of base.pricing.variant_group_ids ?? []) {
|
|
201
|
+
const group = lookup.variantGroupsById?.[groupId];
|
|
202
|
+
if (group) groups.push(group);
|
|
203
|
+
}
|
|
204
|
+
return groups;
|
|
205
|
+
};
|
|
206
|
+
const selectionFor = (groups, entry) => {
|
|
207
|
+
const selection = {};
|
|
208
|
+
for (const group of groups) {
|
|
209
|
+
const option = group.variants.find((variant) => {
|
|
210
|
+
return entry.variant_group_option_ids.includes(variant.id);
|
|
211
|
+
});
|
|
212
|
+
if (option) selection[group.id] = option.id;
|
|
213
|
+
}
|
|
214
|
+
return selection;
|
|
215
|
+
};
|
|
216
|
+
/** Base selector for a product: itself when it has variant groups, else the
|
|
217
|
+
* first base it resolves from (with its options preselected). */
|
|
218
|
+
const resolveVariantBase = (lookup, product) => {
|
|
219
|
+
if (isVariantBase(product)) return {
|
|
220
|
+
base: product,
|
|
221
|
+
selection: {}
|
|
222
|
+
};
|
|
223
|
+
for (const entry of product.pricing.variants ?? []) {
|
|
224
|
+
const base = lookup.productsById[entry.product_id];
|
|
225
|
+
if (!base || !isVariantBase(base)) continue;
|
|
226
|
+
return {
|
|
227
|
+
base,
|
|
228
|
+
selection: selectionFor(variantGroupsOf(lookup, base), entry)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
};
|
|
233
|
+
const variantEntryFor = (product, baseId) => {
|
|
234
|
+
return product.pricing.variants?.find((entry) => entry.product_id === baseId);
|
|
235
|
+
};
|
|
236
|
+
const buildVariantCandidates = (lookup, base) => {
|
|
237
|
+
const byId = /* @__PURE__ */ new Map();
|
|
238
|
+
for (const group of variantGroupsOf(lookup, base)) for (const option of group.variants) for (const productId of option.product_ids) {
|
|
239
|
+
const product = lookup.productsById[productId];
|
|
240
|
+
if (!product || !isSelectable(product)) continue;
|
|
241
|
+
if (byId.has(productId)) continue;
|
|
242
|
+
const entry = variantEntryFor(product, base.id);
|
|
243
|
+
if (!entry) continue;
|
|
244
|
+
const optionIds = new Set(entry.variant_group_option_ids);
|
|
245
|
+
byId.set(productId, {
|
|
246
|
+
product,
|
|
247
|
+
optionIds
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return [...byId.values()];
|
|
251
|
+
};
|
|
252
|
+
const indexCache = /* @__PURE__ */ new WeakMap();
|
|
253
|
+
const variantIndexFor = (lookup, base) => {
|
|
254
|
+
let byBase = indexCache.get(lookup);
|
|
255
|
+
if (!byBase) {
|
|
256
|
+
byBase = /* @__PURE__ */ new Map();
|
|
257
|
+
indexCache.set(lookup, byBase);
|
|
258
|
+
}
|
|
259
|
+
const cached = byBase.get(base.id);
|
|
260
|
+
if (cached) return cached;
|
|
261
|
+
const index = {
|
|
262
|
+
groups: variantGroupsOf(lookup, base),
|
|
263
|
+
candidates: buildVariantCandidates(lookup, base)
|
|
264
|
+
};
|
|
265
|
+
byBase.set(base.id, index);
|
|
266
|
+
return index;
|
|
267
|
+
};
|
|
268
|
+
const variantCandidates = (lookup, base) => {
|
|
269
|
+
return variantIndexFor(lookup, base).candidates;
|
|
270
|
+
};
|
|
271
|
+
/** Normalized selection for a base and the product it resolves to, if any. */
|
|
272
|
+
const resolveVariantSelection = (lookup, base, selection) => {
|
|
273
|
+
const { groups, candidates } = variantIndexFor(lookup, base);
|
|
274
|
+
const next = normalizeSelection(groups, candidates, selection);
|
|
275
|
+
return {
|
|
276
|
+
selection: next,
|
|
277
|
+
product: findVariantProduct(groups, candidates, next)
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
/** Options of `base` that resolve to `product`, or null if it isn't one. */
|
|
281
|
+
const selectionForVariant = (lookup, base, product) => {
|
|
282
|
+
const entry = variantEntryFor(product, base.id);
|
|
283
|
+
if (!entry) return null;
|
|
284
|
+
return selectionFor(variantIndexFor(lookup, base).groups, entry);
|
|
285
|
+
};
|
|
286
|
+
/** True when `product` is what `base` resolves to for its own options. */
|
|
287
|
+
const resolvesFromBase = (lookup, base, product) => {
|
|
288
|
+
if (!isVariantBase(base)) return false;
|
|
289
|
+
const selection = selectionForVariant(lookup, base, product);
|
|
290
|
+
if (!selection) return false;
|
|
291
|
+
const { groups, candidates } = variantIndexFor(lookup, base);
|
|
292
|
+
return findVariantProduct(groups, candidates, selection)?.id === product.id;
|
|
293
|
+
};
|
|
294
|
+
/** True when some active, visible base still resolves to this product. */
|
|
295
|
+
const isOfferedVariant = (lookup, product) => {
|
|
296
|
+
return (product.pricing.variants ?? []).some((entry) => {
|
|
297
|
+
const base = lookup.productsById[entry.product_id];
|
|
298
|
+
if (!base || !isSelectable(base)) return false;
|
|
299
|
+
return resolvesFromBase(lookup, base, product);
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
const matching = (candidates, optionIds) => {
|
|
303
|
+
return candidates.filter((candidate) => {
|
|
304
|
+
return optionIds.every((id) => candidate.optionIds.has(id));
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
const priorOptionIds = (groups, index, selection) => {
|
|
308
|
+
const ids = [];
|
|
309
|
+
for (const group of groups.slice(0, index)) {
|
|
310
|
+
const selected = selection[group.id];
|
|
311
|
+
if (selected) ids.push(selected);
|
|
312
|
+
}
|
|
313
|
+
return ids;
|
|
314
|
+
};
|
|
315
|
+
const visibleOptions = (candidates, group, prior) => {
|
|
316
|
+
return group.variants.flatMap((option) => {
|
|
317
|
+
const matches = matching(candidates, [...prior, option.id]);
|
|
318
|
+
if (matches.length === 0) return [];
|
|
319
|
+
return [{
|
|
320
|
+
option,
|
|
321
|
+
disabled: !matches.some((match) => isInStock(match.product))
|
|
322
|
+
}];
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
/** Drops picks no longer reachable from earlier groups and auto-picks groups
|
|
326
|
+
* left with a single enabled option. Out-of-stock picks are kept so an
|
|
327
|
+
* explicit variant still resolves (and reports OUT_OF_STOCK downstream). */
|
|
328
|
+
const normalizeSelection = (groups, candidates, selection) => {
|
|
329
|
+
const next = {};
|
|
330
|
+
groups.forEach((group, index) => {
|
|
331
|
+
const prior = priorOptionIds(groups, index, next);
|
|
332
|
+
const visible = visibleOptions(candidates, group, prior);
|
|
333
|
+
const current = selection[group.id];
|
|
334
|
+
const kept = visible.find((entry) => entry.option.id === current);
|
|
335
|
+
if (kept) {
|
|
336
|
+
next[group.id] = kept.option.id;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const enabled = visible.filter((entry) => !entry.disabled);
|
|
340
|
+
if (enabled.length === 1 && enabled[0]) next[group.id] = enabled[0].option.id;
|
|
341
|
+
});
|
|
342
|
+
return next;
|
|
343
|
+
};
|
|
344
|
+
const findVariantProduct = (groups, candidates, selection) => {
|
|
345
|
+
const optionIds = groups.map((group) => selection[group.id]);
|
|
346
|
+
if (optionIds.some((id) => !id)) return null;
|
|
347
|
+
const [match] = matching(candidates, optionIds);
|
|
348
|
+
return match?.product ?? null;
|
|
349
|
+
};
|
|
350
|
+
const buildVariantGroupCtxs = (groups, candidates, selection, onSelect, pathPrefix = "") => {
|
|
351
|
+
return groups.map((group, index) => {
|
|
352
|
+
const path = `${pathPrefix}/variant/${group.id}`;
|
|
353
|
+
const prior = priorOptionIds(groups, index, selection);
|
|
354
|
+
const selectedId = selection[group.id] ?? null;
|
|
355
|
+
const options = visibleOptions(candidates, group, prior).map((entry) => {
|
|
356
|
+
return {
|
|
357
|
+
path: `${path}/option/${entry.option.id}`,
|
|
358
|
+
data: entry.option,
|
|
359
|
+
selected: entry.option.id === selectedId,
|
|
360
|
+
disabled: entry.disabled,
|
|
361
|
+
select: () => {
|
|
362
|
+
if (!entry.disabled) onSelect(group.id, entry.option.id);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
return {
|
|
367
|
+
path,
|
|
368
|
+
data: group,
|
|
369
|
+
selectedId,
|
|
370
|
+
status: selectedId ? "VALID" : "INCOMPLETE",
|
|
371
|
+
options
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/productCustomization/seed.ts
|
|
377
|
+
/** Product whose modifier groups hang below a modifier path: a variant base
|
|
378
|
+
* defers to the variant its stored options resolve to (auto-picks persist). */
|
|
379
|
+
const customizedProduct = (store, lookup, product, path) => {
|
|
380
|
+
if (!isVariantBase(product)) return product;
|
|
381
|
+
const { selection, product: resolved } = resolveVariantSelection(...[
|
|
382
|
+
lookup,
|
|
383
|
+
product,
|
|
384
|
+
store.selections[path] ?? {}
|
|
385
|
+
]);
|
|
386
|
+
if (Object.keys(selection).length > 0) store.selections[path] = selection;
|
|
387
|
+
else delete store.selections[path];
|
|
388
|
+
return resolved ?? product;
|
|
389
|
+
};
|
|
390
|
+
const applyCatalogDefaults = (store, lookup, product, path) => {
|
|
391
|
+
setQuantity(store, path, product.pricing.minimum_quantity);
|
|
392
|
+
applyNestedDefaults(store, lookup, product, path);
|
|
393
|
+
};
|
|
394
|
+
const applyNestedDefaults = (store, lookup, product, path) => {
|
|
395
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
396
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
397
|
+
if (!group) continue;
|
|
398
|
+
let radioDefaultApplied = false;
|
|
399
|
+
for (const modifierId of group.modifier_ids) {
|
|
400
|
+
const modifierProduct = lookup.productsById[modifierId];
|
|
401
|
+
if (!modifierProduct || pathVisits(path, modifierId)) continue;
|
|
402
|
+
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
403
|
+
const pricingModifier = resolvePricingModifier(modifierProduct, groupId);
|
|
404
|
+
const defaultQuantity = pricingModifier?.default_quantity ?? 0;
|
|
405
|
+
if (defaultQuantity <= 0) continue;
|
|
406
|
+
if (inferControlType(pricingModifier, group) === "RADIO") {
|
|
407
|
+
if (radioDefaultApplied) continue;
|
|
408
|
+
radioDefaultApplied = true;
|
|
409
|
+
}
|
|
410
|
+
setQuantity(store, modifierPath, defaultQuantity);
|
|
411
|
+
applyNestedDefaults(store, lookup, customizedProduct(...[
|
|
412
|
+
store,
|
|
413
|
+
lookup,
|
|
414
|
+
modifierProduct,
|
|
415
|
+
modifierPath
|
|
416
|
+
]), modifierPath);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
const applyNestedInitial = (store, lookup, product, path, quantities) => {
|
|
421
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
422
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
423
|
+
if (!group) continue;
|
|
424
|
+
for (const modifierId of group.modifier_ids) {
|
|
425
|
+
const modifierProduct = lookup.productsById[modifierId];
|
|
426
|
+
if (!modifierProduct || pathVisits(path, modifierId)) continue;
|
|
427
|
+
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
428
|
+
if (quantities[modifierPath] !== void 0) setQuantity(store, modifierPath, quantities[modifierPath]);
|
|
429
|
+
if (getQuantity(store, modifierPath) <= 0) continue;
|
|
430
|
+
const nested = customizedProduct(...[
|
|
431
|
+
store,
|
|
432
|
+
lookup,
|
|
433
|
+
modifierProduct,
|
|
434
|
+
modifierPath
|
|
435
|
+
]);
|
|
436
|
+
applyNestedInitial(store, lookup, nested, modifierPath, quantities);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const applyInitialQuantities = (store, lookup, product, path, quantities) => {
|
|
441
|
+
setQuantity(store, path, quantities[path] ?? product.pricing.minimum_quantity);
|
|
442
|
+
applyNestedInitial(store, lookup, product, path, quantities);
|
|
117
443
|
};
|
|
118
444
|
//#endregion
|
|
119
445
|
//#region ../../node_modules/.pnpm/big.js@6.2.1/node_modules/big.js/big.mjs
|
|
@@ -541,7 +867,11 @@ const forEachBillableUnit = (group, modifiers, onUnit) => {
|
|
|
541
867
|
for (const modifier of modifiers) {
|
|
542
868
|
if (modifier.quantity <= 0) continue;
|
|
543
869
|
const pricingModifier = resolvePricingModifier(modifier.data, group.id);
|
|
544
|
-
const unit = new Big(
|
|
870
|
+
const unit = new Big(modifierUnitPricing(...[
|
|
871
|
+
modifier.data,
|
|
872
|
+
modifier.resolved,
|
|
873
|
+
group.id
|
|
874
|
+
]).price);
|
|
545
875
|
const modifierChargeAbove = pricingModifier?.charge_above_quantity ?? 0;
|
|
546
876
|
let remaining = billableQuantity(modifier.quantity, modifierChargeAbove);
|
|
547
877
|
while (remaining > 0) {
|
|
@@ -584,95 +914,20 @@ const computeTotalPrice = (product) => {
|
|
|
584
914
|
return unit.plus(mods).times(product.quantity).toNumber();
|
|
585
915
|
};
|
|
586
916
|
//#endregion
|
|
587
|
-
//#region src/productCustomization/store.ts
|
|
588
|
-
const getQuantity = (store, path) => {
|
|
589
|
-
return store.quantities[path] ?? 0;
|
|
590
|
-
};
|
|
591
|
-
const setQuantity = (store, path, quantity) => {
|
|
592
|
-
if (quantity <= 0) delete store.quantities[path];
|
|
593
|
-
if (quantity <= 0) return;
|
|
594
|
-
store.quantities[path] = quantity;
|
|
595
|
-
};
|
|
596
|
-
const clearSubtree = (store, path) => {
|
|
597
|
-
for (const key of Object.keys(store.quantities)) if (key === path || key.startsWith(`${path}/`)) delete store.quantities[key];
|
|
598
|
-
};
|
|
599
|
-
const cloneQuantities = (quantities) => {
|
|
600
|
-
return { ...quantities };
|
|
601
|
-
};
|
|
602
|
-
//#endregion
|
|
603
917
|
//#region src/productCustomization/refresh.ts
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
modifier.min = pricingModifier?.minimum_quantity ?? 0;
|
|
607
|
-
modifier.max = pricingModifier?.maximum_quantity ?? group.data.maximum_quantity;
|
|
608
|
-
modifier.required = modifier.min > 0;
|
|
609
|
-
modifier.quantity = getQuantity(store, modifier.path);
|
|
610
|
-
modifier.type = inferControlType(pricingModifier, group.data);
|
|
611
|
-
};
|
|
612
|
-
const refreshProductTree = (product, store) => {
|
|
918
|
+
const refreshProductTree = (product, runtime) => {
|
|
919
|
+
const { store, variantSyncs } = runtime;
|
|
613
920
|
product.quantity = getQuantity(store, product.path);
|
|
614
921
|
for (const group of product.groups) {
|
|
922
|
+
let selectedQuantity = 0;
|
|
615
923
|
for (const modifier of group.modifiers) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
924
|
+
modifier.quantity = getQuantity(store, modifier.path);
|
|
925
|
+
selectedQuantity += modifier.quantity;
|
|
926
|
+
variantSyncs.get(modifier)?.();
|
|
927
|
+
if (modifier.product) refreshProductTree(modifier.product, runtime);
|
|
621
928
|
}
|
|
622
929
|
computeGroupModifierLinePrices(group.data, group.modifiers);
|
|
623
|
-
group.selectedQuantity =
|
|
624
|
-
return sum + modifier.quantity;
|
|
625
|
-
}, 0);
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
//#endregion
|
|
629
|
-
//#region src/productCustomization/seed.ts
|
|
630
|
-
const applyCatalogDefaults = (store, lookup, product, path) => {
|
|
631
|
-
setQuantity(store, path, product.pricing.minimum_quantity);
|
|
632
|
-
applyNestedDefaults(store, lookup, product, path);
|
|
633
|
-
};
|
|
634
|
-
const applyNestedDefaults = (store, lookup, product, path) => {
|
|
635
|
-
for (const groupId of product.pricing.modifier_group_ids) {
|
|
636
|
-
const group = lookup.modifierGroupsById[groupId];
|
|
637
|
-
if (!group) continue;
|
|
638
|
-
let radioDefaultApplied = false;
|
|
639
|
-
for (const modifierId of group.modifier_ids) {
|
|
640
|
-
const modifierProduct = lookup.productsById[modifierId];
|
|
641
|
-
if (!modifierProduct) continue;
|
|
642
|
-
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
643
|
-
const pricingModifier = resolvePricingModifier(modifierProduct, groupId);
|
|
644
|
-
const defaultQuantity = pricingModifier?.default_quantity ?? 0;
|
|
645
|
-
if (defaultQuantity <= 0) continue;
|
|
646
|
-
if (inferControlType(pricingModifier, group) === "RADIO") {
|
|
647
|
-
if (radioDefaultApplied) continue;
|
|
648
|
-
radioDefaultApplied = true;
|
|
649
|
-
}
|
|
650
|
-
setQuantity(store, modifierPath, defaultQuantity);
|
|
651
|
-
applyNestedDefaults(store, lookup, modifierProduct, modifierPath);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
|
-
const applyInitialQuantities = (store, lookup, product, path, quantities) => {
|
|
656
|
-
if (quantities[path] !== void 0) setQuantity(store, path, quantities[path]);
|
|
657
|
-
else setQuantity(store, path, product.pricing.minimum_quantity);
|
|
658
|
-
for (const groupId of product.pricing.modifier_group_ids) {
|
|
659
|
-
const group = lookup.modifierGroupsById[groupId];
|
|
660
|
-
if (!group) continue;
|
|
661
|
-
for (const modifierId of group.modifier_ids) {
|
|
662
|
-
const modifierProduct = lookup.productsById[modifierId];
|
|
663
|
-
if (!modifierProduct) continue;
|
|
664
|
-
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
665
|
-
if (quantities[modifierPath] !== void 0) setQuantity(store, modifierPath, quantities[modifierPath]);
|
|
666
|
-
if (getQuantity(store, modifierPath) > 0) {
|
|
667
|
-
const args1 = [
|
|
668
|
-
store,
|
|
669
|
-
lookup,
|
|
670
|
-
modifierProduct
|
|
671
|
-
];
|
|
672
|
-
const args2 = [modifierPath, quantities];
|
|
673
|
-
applyInitialQuantities(...args1, ...args2);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
930
|
+
group.selectedQuantity = selectedQuantity;
|
|
676
931
|
}
|
|
677
932
|
};
|
|
678
933
|
//#endregion
|
|
@@ -694,6 +949,7 @@ const validateModifier = (modifier, options) => {
|
|
|
694
949
|
if (nestedStatus === "INVALID") status = "INVALID";
|
|
695
950
|
else if (status === "VALID" && nestedStatus !== "VALID") status = "INCOMPLETE";
|
|
696
951
|
}
|
|
952
|
+
if (quantity > 0 && status === "VALID" && !modifier.resolved) status = "INCOMPLETE";
|
|
697
953
|
return finalize(status, soft);
|
|
698
954
|
};
|
|
699
955
|
const validateGroup = (group, options) => {
|
|
@@ -749,26 +1005,92 @@ const validateProduct = (product, options) => {
|
|
|
749
1005
|
product.status = "VALID";
|
|
750
1006
|
return product.status;
|
|
751
1007
|
};
|
|
1008
|
+
const validateRoot = (runtime, options) => {
|
|
1009
|
+
refreshProductTree(runtime.root, runtime);
|
|
1010
|
+
const status = validateProduct(runtime.root, options);
|
|
1011
|
+
if (status === "IDLE" || !runtime.pendingVariants?.()) return status;
|
|
1012
|
+
runtime.root.status = "INCOMPLETE";
|
|
1013
|
+
return runtime.root.status;
|
|
1014
|
+
};
|
|
752
1015
|
const runAfterChange = (runtime) => {
|
|
753
|
-
|
|
754
|
-
validateProduct(runtime.root, { soft: true });
|
|
1016
|
+
validateRoot(runtime, { soft: true });
|
|
755
1017
|
runtime.listeners.forEach((listener) => listener());
|
|
756
1018
|
};
|
|
757
1019
|
//#endregion
|
|
758
1020
|
//#region src/productCustomization/createCtx.ts
|
|
759
|
-
const
|
|
1021
|
+
const retargetModifierProduct = (runtime, ctx, product) => {
|
|
1022
|
+
const { store, lookup } = runtime;
|
|
1023
|
+
const prefix = `${ctx.path}/group/`;
|
|
1024
|
+
const carried = {};
|
|
1025
|
+
for (const [key, value] of Object.entries(store.quantities)) {
|
|
1026
|
+
if (!key.startsWith(prefix)) continue;
|
|
1027
|
+
carried[key] = value;
|
|
1028
|
+
delete store.quantities[key];
|
|
1029
|
+
}
|
|
1030
|
+
const args = [
|
|
1031
|
+
store,
|
|
1032
|
+
lookup,
|
|
1033
|
+
product,
|
|
1034
|
+
ctx.path
|
|
1035
|
+
];
|
|
1036
|
+
if (Object.keys(carried).length > 0) applyNestedInitial(...args, carried);
|
|
1037
|
+
else if (getQuantity(store, ctx.path) > 0) applyNestedDefaults(...args);
|
|
1038
|
+
assignProduct(runtime, ctx, product);
|
|
1039
|
+
};
|
|
1040
|
+
const attachModifierVariants = (runtime, modifier) => {
|
|
1041
|
+
const { store, lookup } = runtime;
|
|
1042
|
+
const base = modifier.data;
|
|
1043
|
+
const path = modifier.path;
|
|
1044
|
+
const { groups, candidates } = variantIndexFor(lookup, base);
|
|
1045
|
+
let lastKey = null;
|
|
1046
|
+
const selectHandler = (groupId, optionId) => {
|
|
1047
|
+
const current = store.selections[path] ?? {};
|
|
1048
|
+
store.selections[path] = {
|
|
1049
|
+
...current,
|
|
1050
|
+
[groupId]: optionId
|
|
1051
|
+
};
|
|
1052
|
+
if (getQuantity(store, path) > 0) {
|
|
1053
|
+
runAfterChange(runtime);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
modifier.onChange(Math.max(1, modifier.min));
|
|
1057
|
+
};
|
|
1058
|
+
const sync = () => {
|
|
1059
|
+
if (JSON.stringify(store.selections[path] ?? {}) === lastKey) return;
|
|
1060
|
+
const target = customizedProduct(store, lookup, base, path);
|
|
1061
|
+
const selection = store.selections[path] ?? {};
|
|
1062
|
+
lastKey = JSON.stringify(selection);
|
|
1063
|
+
modifier.resolved = target === base ? null : target;
|
|
1064
|
+
if (!modifier.product) modifier.product = createProductCtx(runtime, target, path, false);
|
|
1065
|
+
else if (modifier.product.data.id !== target.id) retargetModifierProduct(runtime, modifier.product, target);
|
|
1066
|
+
modifier.variants = buildVariantGroupCtxs(...[
|
|
1067
|
+
groups,
|
|
1068
|
+
candidates,
|
|
1069
|
+
selection,
|
|
1070
|
+
selectHandler,
|
|
1071
|
+
path
|
|
1072
|
+
]);
|
|
1073
|
+
};
|
|
1074
|
+
runtime.variantSyncs.set(modifier, sync);
|
|
1075
|
+
sync();
|
|
1076
|
+
};
|
|
1077
|
+
const createModifierCtx = (runtime, group, modifierProduct, parentPath) => {
|
|
1078
|
+
const modifierPath = `${group.path}/modifier/${modifierProduct.id}`;
|
|
760
1079
|
const pricingModifier = resolvePricingModifier(...[modifierProduct, group.data.id]);
|
|
761
1080
|
const type = inferControlType(pricingModifier, group.data);
|
|
1081
|
+
const min = pricingModifier?.minimum_quantity ?? 0;
|
|
762
1082
|
const modifier = {
|
|
763
1083
|
path: modifierPath,
|
|
764
|
-
min
|
|
1084
|
+
min,
|
|
765
1085
|
max: pricingModifier?.maximum_quantity ?? group.data.maximum_quantity,
|
|
766
|
-
quantity:
|
|
767
|
-
required:
|
|
1086
|
+
quantity: getQuantity(runtime.store, modifierPath),
|
|
1087
|
+
required: min > 0,
|
|
768
1088
|
status: "IDLE",
|
|
769
1089
|
data: modifierProduct,
|
|
770
1090
|
type,
|
|
771
1091
|
product: null,
|
|
1092
|
+
variants: [],
|
|
1093
|
+
resolved: modifierProduct,
|
|
772
1094
|
linePrice: 0,
|
|
773
1095
|
onChange: () => {}
|
|
774
1096
|
};
|
|
@@ -790,7 +1112,9 @@ const createModifierCtx = (runtime, productCtx, group, modifierProduct, modifier
|
|
|
790
1112
|
} else setQuantity(runtime.store, modifierPath, next);
|
|
791
1113
|
runAfterChange(runtime);
|
|
792
1114
|
};
|
|
793
|
-
if (modifierProduct.
|
|
1115
|
+
if (pathVisits(parentPath, modifierProduct.id)) return modifier;
|
|
1116
|
+
if (isVariantBase(modifierProduct)) attachModifierVariants(runtime, modifier);
|
|
1117
|
+
else if (modifierProduct.pricing.modifier_group_ids.length > 0) modifier.product = createProductCtx(...[
|
|
794
1118
|
runtime,
|
|
795
1119
|
modifierProduct,
|
|
796
1120
|
modifierPath,
|
|
@@ -798,10 +1122,9 @@ const createModifierCtx = (runtime, productCtx, group, modifierProduct, modifier
|
|
|
798
1122
|
]);
|
|
799
1123
|
return modifier;
|
|
800
1124
|
};
|
|
801
|
-
const createModifierGroupCtx = (runtime,
|
|
802
|
-
const groupPath = `${productPath}/group/${group.id}`;
|
|
1125
|
+
const createModifierGroupCtx = (runtime, group, productPath) => {
|
|
803
1126
|
const groupCtx = {
|
|
804
|
-
path:
|
|
1127
|
+
path: `${productPath}/group/${group.id}`,
|
|
805
1128
|
min: group.minimum_quantity,
|
|
806
1129
|
max: group.maximum_quantity,
|
|
807
1130
|
required: group.minimum_quantity > 0,
|
|
@@ -814,25 +1137,36 @@ const createModifierGroupCtx = (runtime, productCtx, group, productPath) => {
|
|
|
814
1137
|
for (const modifierId of group.modifier_ids) {
|
|
815
1138
|
const modifierProduct = runtime.lookup.productsById[modifierId];
|
|
816
1139
|
if (!modifierProduct) continue;
|
|
817
|
-
const
|
|
818
|
-
const args1 = [
|
|
1140
|
+
const args = [
|
|
819
1141
|
runtime,
|
|
820
|
-
|
|
821
|
-
|
|
1142
|
+
groupCtx,
|
|
1143
|
+
modifierProduct,
|
|
1144
|
+
productPath
|
|
822
1145
|
];
|
|
823
|
-
|
|
824
|
-
const ctx = createModifierCtx(...args1, ...args2);
|
|
825
|
-
groupCtx.modifiers.push(ctx);
|
|
1146
|
+
groupCtx.modifiers.push(createModifierCtx(...args));
|
|
826
1147
|
}
|
|
827
1148
|
return groupCtx;
|
|
828
1149
|
};
|
|
1150
|
+
const assignProduct = (runtime, productCtx, product) => {
|
|
1151
|
+
productCtx.data = product;
|
|
1152
|
+
productCtx.min = product.pricing.minimum_quantity;
|
|
1153
|
+
productCtx.max = product.pricing.maximum_quantity;
|
|
1154
|
+
productCtx.required = product.pricing.minimum_quantity > 0;
|
|
1155
|
+
const groups = [];
|
|
1156
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
1157
|
+
const group = runtime.lookup.modifierGroupsById[groupId];
|
|
1158
|
+
if (!group) continue;
|
|
1159
|
+
groups.push(createModifierGroupCtx(runtime, group, productCtx.path));
|
|
1160
|
+
}
|
|
1161
|
+
productCtx.groups = groups;
|
|
1162
|
+
};
|
|
829
1163
|
const createProductCtx = (runtime, product, path, isRoot) => {
|
|
830
1164
|
const productCtx = {
|
|
831
1165
|
path,
|
|
832
|
-
min:
|
|
833
|
-
max:
|
|
834
|
-
quantity:
|
|
835
|
-
required:
|
|
1166
|
+
min: 0,
|
|
1167
|
+
max: 0,
|
|
1168
|
+
quantity: getQuantity(runtime.store, path),
|
|
1169
|
+
required: false,
|
|
836
1170
|
status: "IDLE",
|
|
837
1171
|
data: product,
|
|
838
1172
|
groups: [],
|
|
@@ -840,28 +1174,23 @@ const createProductCtx = (runtime, product, path, isRoot) => {
|
|
|
840
1174
|
validate: () => "IDLE",
|
|
841
1175
|
reset: () => {}
|
|
842
1176
|
};
|
|
843
|
-
productCtx
|
|
844
|
-
const group = runtime.lookup.modifierGroupsById[groupId];
|
|
845
|
-
if (!group) return null;
|
|
846
|
-
return createModifierGroupCtx(runtime, productCtx, group, path);
|
|
847
|
-
}).filter((entry) => entry !== null);
|
|
1177
|
+
assignProduct(runtime, productCtx, product);
|
|
848
1178
|
productCtx.onChange = (quantity) => {
|
|
849
1179
|
const next = clampProductQuantity(quantity, productCtx.min, productCtx.max);
|
|
850
1180
|
if (next <= 0) clearSubtree(runtime.store, path);
|
|
851
1181
|
setQuantity(runtime.store, path, next);
|
|
852
1182
|
runAfterChange(runtime);
|
|
853
1183
|
};
|
|
854
|
-
productCtx.validate = (options) =>
|
|
855
|
-
refreshProductTree(runtime.root, runtime.store);
|
|
856
|
-
return validateProduct(runtime.root, options);
|
|
857
|
-
};
|
|
1184
|
+
productCtx.validate = (options) => validateRoot(runtime, options);
|
|
858
1185
|
productCtx.reset = (options) => {
|
|
859
|
-
|
|
1186
|
+
const { store, lookup } = runtime;
|
|
1187
|
+
clearSubtree(store, path);
|
|
860
1188
|
if (options?.force) {
|
|
861
|
-
if (isRoot) applyCatalogDefaults(
|
|
1189
|
+
if (isRoot) applyCatalogDefaults(store, lookup, productCtx.data, path);
|
|
862
1190
|
} else {
|
|
863
|
-
|
|
864
|
-
|
|
1191
|
+
Object.assign(store.quantities, pickSubtree(store.initialQuantities, path));
|
|
1192
|
+
for (const [key, value] of Object.entries(pickSubtree(store.initialSelections, path))) store.selections[key] = { ...value };
|
|
1193
|
+
if (isRoot && getQuantity(store, path) === 0) setQuantity(store, path, productCtx.data.pricing.minimum_quantity);
|
|
865
1194
|
}
|
|
866
1195
|
runAfterChange(runtime);
|
|
867
1196
|
};
|
|
@@ -869,55 +1198,146 @@ const createProductCtx = (runtime, product, path, isRoot) => {
|
|
|
869
1198
|
};
|
|
870
1199
|
//#endregion
|
|
871
1200
|
//#region src/productCustomization/createSession.ts
|
|
1201
|
+
const reroot = (entries, from, to) => {
|
|
1202
|
+
const next = {};
|
|
1203
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
1204
|
+
if (key === from) next[to] = value;
|
|
1205
|
+
if (key.startsWith(`${from}/`)) next[`${to}${key.slice(from.length)}`] = value;
|
|
1206
|
+
}
|
|
1207
|
+
return next;
|
|
1208
|
+
};
|
|
872
1209
|
const createSession = (lookup, productId, options) => {
|
|
873
1210
|
const product = lookup.productsById[productId];
|
|
874
1211
|
if (!product) throw new Error(`Product "${productId}" was not found in menu.`);
|
|
875
|
-
const
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
initialQuantities: {}
|
|
879
|
-
};
|
|
1212
|
+
const store = createStore();
|
|
1213
|
+
const snapshotVariants = options?.snapshot?.variants;
|
|
1214
|
+
if (snapshotVariants) store.selections = cloneSelections(snapshotVariants);
|
|
880
1215
|
const seed = options?.snapshot?.quantities ?? options?.initial ?? null;
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1216
|
+
const seedArgs = [
|
|
1217
|
+
store,
|
|
1218
|
+
lookup,
|
|
1219
|
+
product,
|
|
1220
|
+
`/${product.id}`
|
|
1221
|
+
];
|
|
1222
|
+
if (seed) applyInitialQuantities(...seedArgs, seed);
|
|
1223
|
+
else applyCatalogDefaults(...seedArgs);
|
|
888
1224
|
const listeners = /* @__PURE__ */ new Set();
|
|
889
1225
|
const runtime = {
|
|
890
1226
|
lookup,
|
|
891
1227
|
store,
|
|
892
1228
|
listeners,
|
|
1229
|
+
variantSyncs: /* @__PURE__ */ new WeakMap(),
|
|
893
1230
|
root: null
|
|
894
1231
|
};
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1232
|
+
const variantBase = resolveVariantBase(lookup, product);
|
|
1233
|
+
const { groups, candidates } = variantBase ? variantIndexFor(lookup, variantBase.base) : {
|
|
1234
|
+
groups: [],
|
|
1235
|
+
candidates: []
|
|
1236
|
+
};
|
|
1237
|
+
let selection = {};
|
|
1238
|
+
let variantCtxs = [];
|
|
1239
|
+
const moveStoreRoot = (from, next) => {
|
|
1240
|
+
const to = `/${next.id}`;
|
|
1241
|
+
const carried = reroot(store.quantities, from, to);
|
|
1242
|
+
const quantity = getQuantity(store, from);
|
|
1243
|
+
store.quantities = {};
|
|
1244
|
+
store.selections = reroot(store.selections, from, to);
|
|
1245
|
+
const hasModifiers = Object.keys(carried).length > 1;
|
|
1246
|
+
const args = [
|
|
1247
|
+
store,
|
|
1248
|
+
lookup,
|
|
1249
|
+
next,
|
|
1250
|
+
to
|
|
1251
|
+
];
|
|
1252
|
+
if (hasModifiers) applyInitialQuantities(...args, carried);
|
|
1253
|
+
else applyCatalogDefaults(...args);
|
|
1254
|
+
const { minimum_quantity: min, maximum_quantity: max } = next.pricing;
|
|
1255
|
+
setQuantity(store, to, clampProductQuantity(quantity, min, max));
|
|
1256
|
+
};
|
|
1257
|
+
const resolveRoot = (nextSelection, current) => {
|
|
1258
|
+
if (!variantBase) return current;
|
|
1259
|
+
selection = normalizeSelection(groups, candidates, nextSelection);
|
|
1260
|
+
const next = findVariantProduct(groups, candidates, selection) ?? variantBase.base;
|
|
1261
|
+
if (next.id !== current.id) moveStoreRoot(`/${current.id}`, next);
|
|
1262
|
+
return next;
|
|
1263
|
+
};
|
|
1264
|
+
const rebuildVariantCtxs = () => {
|
|
1265
|
+
if (!variantBase) return;
|
|
1266
|
+
variantCtxs = buildVariantGroupCtxs(...[
|
|
1267
|
+
groups,
|
|
1268
|
+
candidates,
|
|
1269
|
+
selection
|
|
1270
|
+
], selectHandler);
|
|
1271
|
+
};
|
|
1272
|
+
const applySelection = (nextSelection) => {
|
|
1273
|
+
const current = runtime.root.data;
|
|
1274
|
+
const next = resolveRoot(nextSelection, current);
|
|
1275
|
+
if (next.id !== current.id) runtime.root = createProductCtx(runtime, next, `/${next.id}`, true);
|
|
1276
|
+
rebuildVariantCtxs();
|
|
1277
|
+
};
|
|
1278
|
+
const selectHandler = (groupId, optionId) => {
|
|
1279
|
+
applySelection({
|
|
1280
|
+
...selection,
|
|
1281
|
+
[groupId]: optionId
|
|
1282
|
+
});
|
|
1283
|
+
runAfterChange(runtime);
|
|
1284
|
+
};
|
|
1285
|
+
runtime.pendingVariants = () => {
|
|
1286
|
+
return variantBase !== null && runtime.root.data.id === variantBase.base.id;
|
|
1287
|
+
};
|
|
1288
|
+
const rootProduct = resolveRoot(variantBase?.selection ?? {}, product);
|
|
1289
|
+
runtime.root = createProductCtx(runtime, rootProduct, `/${rootProduct.id}`, true);
|
|
1290
|
+
rebuildVariantCtxs();
|
|
1291
|
+
const initialSelection = { ...selection };
|
|
1292
|
+
store.initialQuantities = cloneQuantities(store.quantities);
|
|
1293
|
+
store.initialSelections = cloneSelections(store.selections);
|
|
1294
|
+
validateRoot(runtime);
|
|
1295
|
+
const activeSelections = () => {
|
|
1296
|
+
const variants = {};
|
|
1297
|
+
for (const [path, entry] of Object.entries(store.selections)) if (getQuantity(store, path) > 0) variants[path] = { ...entry };
|
|
1298
|
+
return variants;
|
|
1299
|
+
};
|
|
898
1300
|
return {
|
|
899
|
-
root
|
|
1301
|
+
get root() {
|
|
1302
|
+
return runtime.root;
|
|
1303
|
+
},
|
|
1304
|
+
get base() {
|
|
1305
|
+
return variantBase?.base ?? null;
|
|
1306
|
+
},
|
|
1307
|
+
get variants() {
|
|
1308
|
+
return variantCtxs;
|
|
1309
|
+
},
|
|
900
1310
|
subscribe: (listener) => {
|
|
901
1311
|
listeners.add(listener);
|
|
902
1312
|
return () => listeners.delete(listener);
|
|
903
1313
|
},
|
|
904
1314
|
snapshot: () => {
|
|
1315
|
+
const productId = runtime.root.data.id;
|
|
1316
|
+
const quantities = cloneQuantities(store.quantities);
|
|
1317
|
+
const variants = activeSelections();
|
|
1318
|
+
if (Object.keys(variants).length === 0) return {
|
|
1319
|
+
productId,
|
|
1320
|
+
quantities
|
|
1321
|
+
};
|
|
905
1322
|
return {
|
|
906
1323
|
productId,
|
|
907
|
-
quantities
|
|
1324
|
+
quantities,
|
|
1325
|
+
variants
|
|
908
1326
|
};
|
|
909
1327
|
},
|
|
910
1328
|
totalPrice: () => {
|
|
911
|
-
refreshProductTree(runtime.root, runtime
|
|
1329
|
+
refreshProductTree(runtime.root, runtime);
|
|
912
1330
|
return computeTotalPrice(runtime.root);
|
|
913
1331
|
},
|
|
914
|
-
validate: (
|
|
915
|
-
|
|
916
|
-
const status = validateProduct(runtime.root, options);
|
|
1332
|
+
validate: (validateOptions) => {
|
|
1333
|
+
const status = validateRoot(runtime, validateOptions);
|
|
917
1334
|
runtime.listeners.forEach((listener) => listener());
|
|
918
1335
|
return status;
|
|
919
1336
|
},
|
|
920
|
-
reset: (
|
|
1337
|
+
reset: (resetOptions) => {
|
|
1338
|
+
applySelection(initialSelection);
|
|
1339
|
+
runtime.root.reset(resetOptions);
|
|
1340
|
+
}
|
|
921
1341
|
};
|
|
922
1342
|
};
|
|
923
1343
|
//#endregion
|
|
@@ -988,33 +1408,74 @@ const flattenMenuCategories = (categories, categoriesById, options) => {
|
|
|
988
1408
|
};
|
|
989
1409
|
//#endregion
|
|
990
1410
|
//#region src/productCustomization/fromOrderLines.ts
|
|
1411
|
+
/** Group slot of a child line: listed directly, or as the variant of a base
|
|
1412
|
+
* listed in one of the parent's modifier groups. */
|
|
1413
|
+
const matchModifierLine = (lookup, parent, childId) => {
|
|
1414
|
+
const groups = parent.pricing.modifier_group_ids.flatMap((groupId) => {
|
|
1415
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
1416
|
+
return group ? [group] : [];
|
|
1417
|
+
});
|
|
1418
|
+
for (const group of groups) {
|
|
1419
|
+
if (!group.modifier_ids.includes(childId)) continue;
|
|
1420
|
+
return {
|
|
1421
|
+
groupId: group.id,
|
|
1422
|
+
modifierId: childId,
|
|
1423
|
+
selection: null
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
const child = lookup.productsById[childId];
|
|
1427
|
+
for (const entry of child?.pricing.variants ?? []) {
|
|
1428
|
+
const base = lookup.productsById[entry.product_id];
|
|
1429
|
+
if (!child || !base) continue;
|
|
1430
|
+
const group = groups.find((g) => g.modifier_ids.includes(base.id));
|
|
1431
|
+
if (!group) continue;
|
|
1432
|
+
const selection = selectionForVariant(lookup, base, child);
|
|
1433
|
+
if (!selection) continue;
|
|
1434
|
+
return {
|
|
1435
|
+
groupId: group.id,
|
|
1436
|
+
modifierId: base.id,
|
|
1437
|
+
selection
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
return null;
|
|
1441
|
+
};
|
|
991
1442
|
/**
|
|
992
1443
|
* Inverse of {@link toOrderLines}: rebuild a {@link Snapshot} from a root cart
|
|
993
1444
|
* line so {@link createSession} can reopen it via `{ snapshot }`.
|
|
994
1445
|
*/
|
|
995
1446
|
const fromOrderLines = (root, lines, lookup) => {
|
|
996
1447
|
const quantities = { [`/${root.id}`]: root.quantity };
|
|
997
|
-
const
|
|
998
|
-
|
|
1448
|
+
const variants = {};
|
|
1449
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
1450
|
+
for (const line of lines) {
|
|
1451
|
+
if (line.parent_uid == null) continue;
|
|
1452
|
+
const siblings = childrenByParent.get(line.parent_uid) ?? [];
|
|
1453
|
+
siblings.push(line);
|
|
1454
|
+
childrenByParent.set(line.parent_uid, siblings);
|
|
1455
|
+
}
|
|
1456
|
+
const walk = (parentLine, pathPrefix) => {
|
|
1457
|
+
const parentProduct = lookup.productsById[parentLine.id];
|
|
999
1458
|
if (!parentProduct) return;
|
|
1000
|
-
for (const child of
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
if (!matchedPath) continue;
|
|
1009
|
-
quantities[matchedPath] = child.quantity;
|
|
1010
|
-
walk(child.id, child.uid, matchedPath);
|
|
1459
|
+
for (const child of childrenByParent.get(parentLine.uid) ?? []) {
|
|
1460
|
+
const match = matchModifierLine(lookup, parentProduct, child.id);
|
|
1461
|
+
if (!match) continue;
|
|
1462
|
+
const { groupId, modifierId, selection } = match;
|
|
1463
|
+
const path = `${pathPrefix}/group/${groupId}/modifier/${modifierId}`;
|
|
1464
|
+
quantities[path] = child.quantity;
|
|
1465
|
+
if (selection) variants[path] = selection;
|
|
1466
|
+
walk(child, path);
|
|
1011
1467
|
}
|
|
1012
1468
|
};
|
|
1013
|
-
walk(root
|
|
1014
|
-
return {
|
|
1469
|
+
walk(root, `/${root.id}`);
|
|
1470
|
+
if (Object.keys(variants).length === 0) return {
|
|
1015
1471
|
productId: root.id,
|
|
1016
1472
|
quantities
|
|
1017
1473
|
};
|
|
1474
|
+
return {
|
|
1475
|
+
productId: root.id,
|
|
1476
|
+
quantities,
|
|
1477
|
+
variants
|
|
1478
|
+
};
|
|
1018
1479
|
};
|
|
1019
1480
|
//#endregion
|
|
1020
1481
|
//#region src/productCustomization/menu.ts
|
|
@@ -1024,6 +1485,8 @@ const indexMenu = (menu) => {
|
|
|
1024
1485
|
const modifierGroupsById = {};
|
|
1025
1486
|
const modifierGroups = resolveModifierGroupsForLookup(...[menu.products, menu.modifier_groups]);
|
|
1026
1487
|
for (const group of modifierGroups) modifierGroupsById[group.id] = group;
|
|
1488
|
+
const variantGroupsById = {};
|
|
1489
|
+
for (const group of menu.variant_groups ?? []) variantGroupsById[group.id] = group;
|
|
1027
1490
|
const categoriesById = {};
|
|
1028
1491
|
for (const category of menu.categories) categoriesById[category.id] = category;
|
|
1029
1492
|
const recommendationsById = {};
|
|
@@ -1031,21 +1494,149 @@ const indexMenu = (menu) => {
|
|
|
1031
1494
|
return {
|
|
1032
1495
|
productsById,
|
|
1033
1496
|
modifierGroupsById,
|
|
1497
|
+
variantGroupsById,
|
|
1034
1498
|
categoriesById,
|
|
1035
1499
|
recommendationsById
|
|
1036
1500
|
};
|
|
1037
1501
|
};
|
|
1038
1502
|
//#endregion
|
|
1039
|
-
//#region src/
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1503
|
+
//#region src/productCustomization/startingPrice.ts
|
|
1504
|
+
const marginalCost = (candidate) => {
|
|
1505
|
+
const { quantity, chargeAbove, unit, nested } = candidate;
|
|
1506
|
+
return quantity >= chargeAbove ? unit.plus(nested) : nested;
|
|
1507
|
+
};
|
|
1508
|
+
const pickCheapest = (candidates) => {
|
|
1509
|
+
let best = null;
|
|
1510
|
+
for (const candidate of candidates) {
|
|
1511
|
+
if (candidate.quantity >= candidate.max) continue;
|
|
1512
|
+
if (!best || marginalCost(candidate).lt(marginalCost(best))) best = candidate;
|
|
1513
|
+
}
|
|
1514
|
+
return best;
|
|
1515
|
+
};
|
|
1516
|
+
const groupCost = (group, candidates) => {
|
|
1517
|
+
const groupChargeAbove = group.charge_above_quantity ?? 0;
|
|
1518
|
+
let chargedCount = 0;
|
|
1519
|
+
let total = new Big(0);
|
|
1520
|
+
for (const candidate of candidates) {
|
|
1521
|
+
if (candidate.quantity <= 0) continue;
|
|
1522
|
+
const billable = billableQuantity(...[candidate.quantity, candidate.chargeAbove]);
|
|
1523
|
+
const freeLeft = Math.max(0, groupChargeAbove - chargedCount);
|
|
1524
|
+
const charged = billable - Math.min(billable, freeLeft);
|
|
1525
|
+
chargedCount += billable;
|
|
1526
|
+
total = total.plus(candidate.unit.times(charged));
|
|
1527
|
+
total = total.plus(candidate.nested.times(candidate.quantity));
|
|
1528
|
+
}
|
|
1529
|
+
return total;
|
|
1530
|
+
};
|
|
1531
|
+
const cheapestModifierUnit = (lookup, groupId, listed, walk) => {
|
|
1532
|
+
const { state, visiting } = walk;
|
|
1533
|
+
const options = isVariantBase(listed) ? variantCandidates(lookup, listed).map((c) => c.product) : [listed];
|
|
1534
|
+
let best = null;
|
|
1535
|
+
for (const ordered of options) {
|
|
1536
|
+
if (ordered !== listed && visiting.has(ordered.id)) {
|
|
1537
|
+
state.cycleHits += 1;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
const unit = new Big(modifierUnitPricing(...[
|
|
1541
|
+
listed,
|
|
1542
|
+
ordered,
|
|
1543
|
+
groupId
|
|
1544
|
+
]).price);
|
|
1545
|
+
const nested = nestedModifiersCost(lookup, ordered, state, visiting);
|
|
1546
|
+
if (best && unit.plus(nested).gte(best.unit.plus(best.nested))) continue;
|
|
1547
|
+
best = {
|
|
1548
|
+
unit,
|
|
1549
|
+
nested
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
return best;
|
|
1553
|
+
};
|
|
1554
|
+
const cheapestGroupCost = (lookup, group, state, visiting) => {
|
|
1555
|
+
const candidates = [];
|
|
1556
|
+
for (const modifierId of group.modifier_ids) {
|
|
1557
|
+
const modifierProduct = lookup.productsById[modifierId];
|
|
1558
|
+
if (!modifierProduct) continue;
|
|
1559
|
+
if (visiting.has(modifierId)) {
|
|
1560
|
+
state.cycleHits += 1;
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
const pricingModifier = resolvePricingModifier(modifierProduct, group.id);
|
|
1564
|
+
const cost = cheapestModifierUnit(lookup, group.id, modifierProduct, {
|
|
1565
|
+
state,
|
|
1566
|
+
visiting
|
|
1567
|
+
});
|
|
1568
|
+
if (!cost) continue;
|
|
1569
|
+
const quantity = pricingModifier?.minimum_quantity ?? 0;
|
|
1570
|
+
const max = pricingModifier?.maximum_quantity ?? group.maximum_quantity;
|
|
1571
|
+
const chargeAbove = pricingModifier?.charge_above_quantity ?? 0;
|
|
1572
|
+
candidates.push({
|
|
1573
|
+
quantity,
|
|
1574
|
+
max,
|
|
1575
|
+
...cost,
|
|
1576
|
+
chargeAbove
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
const selected = candidates.reduce((sum, c) => sum + c.quantity, 0);
|
|
1580
|
+
let remaining = group.minimum_quantity - selected;
|
|
1581
|
+
while (remaining > 0) {
|
|
1582
|
+
const cheapest = pickCheapest(candidates);
|
|
1583
|
+
if (!cheapest) break;
|
|
1584
|
+
const { quantity, chargeAbove, max } = cheapest;
|
|
1585
|
+
const step = Math.min(remaining, (quantity < chargeAbove ? Math.min(chargeAbove, max) : max) - quantity);
|
|
1586
|
+
cheapest.quantity += step;
|
|
1587
|
+
remaining -= step;
|
|
1588
|
+
}
|
|
1589
|
+
return groupCost(group, candidates);
|
|
1590
|
+
};
|
|
1591
|
+
const nestedModifiersCost = (lookup, product, state, visiting) => {
|
|
1592
|
+
const cached = state.cache.get(product.id);
|
|
1593
|
+
if (cached) return cached;
|
|
1594
|
+
const cycleHitsBefore = state.cycleHits;
|
|
1595
|
+
const nextVisiting = new Set(visiting).add(product.id);
|
|
1596
|
+
let total = new Big(0);
|
|
1597
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
1598
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
1599
|
+
if (!group) continue;
|
|
1600
|
+
const args = [
|
|
1601
|
+
lookup,
|
|
1602
|
+
group,
|
|
1603
|
+
state,
|
|
1604
|
+
nextVisiting
|
|
1605
|
+
];
|
|
1606
|
+
total = total.plus(cheapestGroupCost(...args));
|
|
1607
|
+
}
|
|
1608
|
+
if (state.cycleHits === cycleHitsBefore) state.cache.set(product.id, total);
|
|
1609
|
+
return total;
|
|
1610
|
+
};
|
|
1611
|
+
/** Lowest unit price reachable while satisfying every required modifier.
|
|
1612
|
+
* For a variant base, the cheapest of its variant products. */
|
|
1613
|
+
const computeStartingPrice = (lookup, product, cache = /* @__PURE__ */ new Map()) => {
|
|
1614
|
+
if (isVariantBase(product)) {
|
|
1615
|
+
const prices = variantCandidates(lookup, product).map((c) => computeStartingPrice(lookup, c.product, cache));
|
|
1616
|
+
if (prices.length > 0) return Math.min(...prices);
|
|
1617
|
+
}
|
|
1618
|
+
const modifiers = nestedModifiersCost(...[
|
|
1619
|
+
lookup,
|
|
1620
|
+
product,
|
|
1621
|
+
{
|
|
1622
|
+
cache,
|
|
1623
|
+
cycleHits: 0
|
|
1624
|
+
},
|
|
1625
|
+
/* @__PURE__ */ new Set()
|
|
1626
|
+
]);
|
|
1627
|
+
return new Big(product.pricing.price).plus(modifiers).toNumber();
|
|
1628
|
+
};
|
|
1629
|
+
//#endregion
|
|
1630
|
+
//#region src/shared/randomUUID.ts
|
|
1631
|
+
/** UUID v4 — works when `crypto.randomUUID` is missing (React Native / Hermes). */
|
|
1632
|
+
const randomUUID = () => {
|
|
1633
|
+
const c = globalThis.crypto;
|
|
1634
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
1635
|
+
if (typeof c?.getRandomValues !== "function") throw new Error("crypto.getRandomValues is required");
|
|
1636
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
1637
|
+
c.getRandomValues(bytes);
|
|
1638
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1639
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1049
1640
|
const toHex = (b) => b.toString(16).padStart(2, "0");
|
|
1050
1641
|
const hex = Array.from(bytes, toHex).join("");
|
|
1051
1642
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
@@ -1209,62 +1800,44 @@ const flatten = (node, out) => {
|
|
|
1209
1800
|
out.push(line);
|
|
1210
1801
|
for (const child of children) flatten(child, out);
|
|
1211
1802
|
};
|
|
1212
|
-
const
|
|
1213
|
-
for (const modifier of
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
});
|
|
1246
|
-
config.parent.children.push(line);
|
|
1247
|
-
if (modifier.product) for (const group of modifier.product.groups) {
|
|
1248
|
-
const n1 = { modifiers: group.modifiers };
|
|
1249
|
-
const n2 = {
|
|
1250
|
-
parentUid: line.uid,
|
|
1251
|
-
parentId: line.id
|
|
1252
|
-
};
|
|
1253
|
-
const n3 = {
|
|
1254
|
-
parent: line,
|
|
1255
|
-
locale: config.locale
|
|
1256
|
-
};
|
|
1257
|
-
appendModifierLines({
|
|
1258
|
-
...n1,
|
|
1259
|
-
...n2,
|
|
1260
|
-
...n3
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1803
|
+
const appendGroupLines = (groups, parent, locale) => {
|
|
1804
|
+
for (const group of groups) for (const modifier of group.modifiers) appendModifierLine(modifier, group.data.id, parent, locale);
|
|
1805
|
+
};
|
|
1806
|
+
const appendModifierLine = (modifier, groupId, parent, locale) => {
|
|
1807
|
+
const product = modifier.resolved;
|
|
1808
|
+
if (modifier.quantity <= 0 || !product) return;
|
|
1809
|
+
const { taxRate, isTaxInclusive } = modifierUnitPricing(...[
|
|
1810
|
+
modifier.data,
|
|
1811
|
+
product,
|
|
1812
|
+
groupId
|
|
1813
|
+
]);
|
|
1814
|
+
const unitSubtotal = new Big(modifier.linePrice).div(modifier.quantity);
|
|
1815
|
+
const l1 = {
|
|
1816
|
+
product,
|
|
1817
|
+
name: displayName(product.name, locale),
|
|
1818
|
+
quantity: modifier.quantity,
|
|
1819
|
+
unitSubtotal
|
|
1820
|
+
};
|
|
1821
|
+
const l2 = {
|
|
1822
|
+
taxRate,
|
|
1823
|
+
isTaxInclusive
|
|
1824
|
+
};
|
|
1825
|
+
const l3 = {
|
|
1826
|
+
parent_uid: parent.uid,
|
|
1827
|
+
parent_id: parent.id
|
|
1828
|
+
};
|
|
1829
|
+
const line = buildLine({
|
|
1830
|
+
...l1,
|
|
1831
|
+
...l2,
|
|
1832
|
+
...l3
|
|
1833
|
+
});
|
|
1834
|
+
parent.children.push(line);
|
|
1835
|
+
if (modifier.product) appendGroupLines(modifier.product.groups, line, locale);
|
|
1264
1836
|
};
|
|
1265
1837
|
/** Flatten a customized product tree into priced OrderLine rows. */
|
|
1266
1838
|
const toOrderLines = (root, locale) => {
|
|
1267
1839
|
if (root.quantity <= 0) return [];
|
|
1840
|
+
if (isVariantBase(root.data)) return [];
|
|
1268
1841
|
const name = displayName(root.data.name, locale);
|
|
1269
1842
|
const unitSubtotal = new Big(root.data.pricing.price);
|
|
1270
1843
|
const t1 = {
|
|
@@ -1287,22 +1860,7 @@ const toOrderLines = (root, locale) => {
|
|
|
1287
1860
|
...t3,
|
|
1288
1861
|
...t4
|
|
1289
1862
|
});
|
|
1290
|
-
|
|
1291
|
-
const n1 = { modifiers: group.modifiers };
|
|
1292
|
-
const n2 = {
|
|
1293
|
-
parentUid: rootLine.uid,
|
|
1294
|
-
parentId: rootLine.id
|
|
1295
|
-
};
|
|
1296
|
-
const n3 = {
|
|
1297
|
-
parent: rootLine,
|
|
1298
|
-
locale
|
|
1299
|
-
};
|
|
1300
|
-
appendModifierLines({
|
|
1301
|
-
...n1,
|
|
1302
|
-
...n2,
|
|
1303
|
-
...n3
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1863
|
+
appendGroupLines(root.groups, rootLine, locale);
|
|
1306
1864
|
finalizeGroupMoney(rootLine);
|
|
1307
1865
|
const lines = [];
|
|
1308
1866
|
flatten(rootLine, lines);
|
|
@@ -1409,6 +1967,105 @@ const buildCustomerSnapshot = (source) => {
|
|
|
1409
1967
|
} };
|
|
1410
1968
|
};
|
|
1411
1969
|
//#endregion
|
|
1970
|
+
//#region src/logs/limits.ts
|
|
1971
|
+
/** Mirrors the API limits so oversized logs are trimmed, not rejected. */
|
|
1972
|
+
const LOG_LIMITS = {
|
|
1973
|
+
message: 2e3,
|
|
1974
|
+
detail: 2e3,
|
|
1975
|
+
alias: 64,
|
|
1976
|
+
status: 64,
|
|
1977
|
+
contextId: 128,
|
|
1978
|
+
errorName: 128,
|
|
1979
|
+
stack: 8e3,
|
|
1980
|
+
cause: 2e3,
|
|
1981
|
+
batch: 50,
|
|
1982
|
+
keepaliveBytes: 6e4
|
|
1983
|
+
};
|
|
1984
|
+
const truncate = (value, max) => {
|
|
1985
|
+
if (value.length <= max) return value;
|
|
1986
|
+
return `${value.slice(0, max - 1)}…`;
|
|
1987
|
+
};
|
|
1988
|
+
//#endregion
|
|
1989
|
+
//#region src/logs/context.ts
|
|
1990
|
+
const emptyContext = () => {
|
|
1991
|
+
return {
|
|
1992
|
+
channel_id: null,
|
|
1993
|
+
fulfillment_id: null,
|
|
1994
|
+
store_id: null,
|
|
1995
|
+
brand_id: null,
|
|
1996
|
+
device_id: null
|
|
1997
|
+
};
|
|
1998
|
+
};
|
|
1999
|
+
let context = emptyContext();
|
|
2000
|
+
let posDevice = null;
|
|
2001
|
+
const cleanId = (value) => {
|
|
2002
|
+
if (typeof value !== "string") return null;
|
|
2003
|
+
const trimmed = value.trim();
|
|
2004
|
+
if (!trimmed) return null;
|
|
2005
|
+
return trimmed.slice(0, LOG_LIMITS.contextId);
|
|
2006
|
+
};
|
|
2007
|
+
const SCOPE_KEYS = [
|
|
2008
|
+
"channel_id",
|
|
2009
|
+
"fulfillment_id",
|
|
2010
|
+
"store_id",
|
|
2011
|
+
"brand_id"
|
|
2012
|
+
];
|
|
2013
|
+
/** Only keys present in `scope` change; `null` clears one. */
|
|
2014
|
+
const captureLogScope = (scope) => {
|
|
2015
|
+
if (!scope) return;
|
|
2016
|
+
const next = { ...context };
|
|
2017
|
+
for (const key of SCOPE_KEYS) {
|
|
2018
|
+
if (scope[key] === void 0) continue;
|
|
2019
|
+
next[key] = cleanId(scope[key]);
|
|
2020
|
+
}
|
|
2021
|
+
context = next;
|
|
2022
|
+
};
|
|
2023
|
+
const captureLogDevice = (device_id) => {
|
|
2024
|
+
const id = cleanId(device_id);
|
|
2025
|
+
if (id) context = {
|
|
2026
|
+
...context,
|
|
2027
|
+
device_id: id
|
|
2028
|
+
};
|
|
2029
|
+
};
|
|
2030
|
+
/** `{ device: { uid }, scope }` as returned by kiosk link and POS pair. */
|
|
2031
|
+
const captureLogDeviceConfig = (config) => {
|
|
2032
|
+
if (!config || typeof config !== "object") return;
|
|
2033
|
+
const value = config;
|
|
2034
|
+
captureLogDevice(value.device?.uid);
|
|
2035
|
+
captureLogScope(value.scope);
|
|
2036
|
+
};
|
|
2037
|
+
/** POS calls carry device credentials; logs reuse them for the POS route. */
|
|
2038
|
+
const capturePosLogDevice = (input, options) => {
|
|
2039
|
+
if (!input || typeof input !== "object") return;
|
|
2040
|
+
const value = input;
|
|
2041
|
+
const device_uid = cleanId(value.device_uid);
|
|
2042
|
+
const { device_secret } = value;
|
|
2043
|
+
if (!device_uid || typeof device_secret !== "string") return;
|
|
2044
|
+
if (!device_secret) return;
|
|
2045
|
+
posDevice = {
|
|
2046
|
+
device_uid,
|
|
2047
|
+
device_secret,
|
|
2048
|
+
http: {
|
|
2049
|
+
baseUrl: options?.baseUrl,
|
|
2050
|
+
fetchImpl: options?.fetchImpl
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
captureLogDevice(device_uid);
|
|
2054
|
+
};
|
|
2055
|
+
const clearLogDevice = () => {
|
|
2056
|
+
posDevice = null;
|
|
2057
|
+
context = {
|
|
2058
|
+
...context,
|
|
2059
|
+
device_id: null
|
|
2060
|
+
};
|
|
2061
|
+
};
|
|
2062
|
+
const getLogContext = () => {
|
|
2063
|
+
return { ...context };
|
|
2064
|
+
};
|
|
2065
|
+
const getPosLogDevice = () => {
|
|
2066
|
+
return posDevice;
|
|
2067
|
+
};
|
|
2068
|
+
//#endregion
|
|
1412
2069
|
//#region src/services/http.ts
|
|
1413
2070
|
var StorefrontHttpError = class extends Error {
|
|
1414
2071
|
status;
|
|
@@ -1526,6 +2183,7 @@ const preferencesQuery = (scope) => {
|
|
|
1526
2183
|
return query ? `${path}?${query}` : path;
|
|
1527
2184
|
};
|
|
1528
2185
|
const getPreferences = (scope = {}, options) => {
|
|
2186
|
+
captureLogScope(scope);
|
|
1529
2187
|
return storefrontJson(...[
|
|
1530
2188
|
preferencesQuery(scope),
|
|
1531
2189
|
{},
|
|
@@ -1614,7 +2272,7 @@ const kioskFetch = async (path, init, options) => {
|
|
|
1614
2272
|
};
|
|
1615
2273
|
return fetchImpl(kioskUrl(path, options), req);
|
|
1616
2274
|
};
|
|
1617
|
-
const readError = async (response) => {
|
|
2275
|
+
const readError$1 = async (response) => {
|
|
1618
2276
|
const text = await response.text();
|
|
1619
2277
|
try {
|
|
1620
2278
|
return JSON.parse(text);
|
|
@@ -1642,10 +2300,12 @@ const getKioskConfig = async (input, options) => {
|
|
|
1642
2300
|
};
|
|
1643
2301
|
const response = await kioskFetch(path, init, options);
|
|
1644
2302
|
if (!response.ok) {
|
|
1645
|
-
const body = await readError(response);
|
|
2303
|
+
const body = await readError$1(response);
|
|
1646
2304
|
throwKioskError(body, response.status);
|
|
1647
2305
|
}
|
|
1648
|
-
|
|
2306
|
+
const json = await response.json();
|
|
2307
|
+
captureLogDeviceConfig(json.data);
|
|
2308
|
+
return json.data;
|
|
1649
2309
|
};
|
|
1650
2310
|
const postKioskHeartbeat = async (input, options) => {
|
|
1651
2311
|
const path = "/api/storefront/v1/kiosk/devices/me";
|
|
@@ -1655,9 +2315,10 @@ const postKioskHeartbeat = async (input, options) => {
|
|
|
1655
2315
|
};
|
|
1656
2316
|
const response = await kioskFetch(path, init, options);
|
|
1657
2317
|
if (!response.ok) {
|
|
1658
|
-
const body = await readError(response);
|
|
2318
|
+
const body = await readError$1(response);
|
|
1659
2319
|
throwKioskError(body, response.status);
|
|
1660
2320
|
}
|
|
2321
|
+
captureLogDevice(input.device_uid);
|
|
1661
2322
|
return await response.json();
|
|
1662
2323
|
};
|
|
1663
2324
|
const postKioskHealthSamples = async (input, options) => {
|
|
@@ -1668,9 +2329,10 @@ const postKioskHealthSamples = async (input, options) => {
|
|
|
1668
2329
|
};
|
|
1669
2330
|
const response = await kioskFetch(path, init, options);
|
|
1670
2331
|
if (!response.ok) {
|
|
1671
|
-
const body = await readError(response);
|
|
2332
|
+
const body = await readError$1(response);
|
|
1672
2333
|
throwKioskError(body, response.status);
|
|
1673
2334
|
}
|
|
2335
|
+
captureLogDevice(input.device_uid);
|
|
1674
2336
|
return await response.json();
|
|
1675
2337
|
};
|
|
1676
2338
|
const patchKioskOverrides = async (input, options) => {
|
|
@@ -1681,7 +2343,7 @@ const patchKioskOverrides = async (input, options) => {
|
|
|
1681
2343
|
};
|
|
1682
2344
|
const response = await kioskFetch(path, init, options);
|
|
1683
2345
|
if (!response.ok) {
|
|
1684
|
-
const body = await readError(response);
|
|
2346
|
+
const body = await readError$1(response);
|
|
1685
2347
|
throwKioskError(body, response.status);
|
|
1686
2348
|
}
|
|
1687
2349
|
return (await response.json()).data;
|
|
@@ -1694,9 +2356,10 @@ const unlinkKioskSelf = async (input, options) => {
|
|
|
1694
2356
|
};
|
|
1695
2357
|
const response = await kioskFetch(path, init, options);
|
|
1696
2358
|
if (!response.ok) {
|
|
1697
|
-
const body = await readError(response);
|
|
2359
|
+
const body = await readError$1(response);
|
|
1698
2360
|
throwKioskError(body, response.status);
|
|
1699
2361
|
}
|
|
2362
|
+
clearLogDevice();
|
|
1700
2363
|
return (await response.json()).data;
|
|
1701
2364
|
};
|
|
1702
2365
|
/** 403 wrong or expired code, 429 too many tries for the current code. */
|
|
@@ -1708,7 +2371,7 @@ const verifyKioskAdminCode = async (input, options) => {
|
|
|
1708
2371
|
};
|
|
1709
2372
|
const response = await kioskFetch(path, init, options);
|
|
1710
2373
|
if (!response.ok) {
|
|
1711
|
-
const body = await readError(response);
|
|
2374
|
+
const body = await readError$1(response);
|
|
1712
2375
|
throwKioskError(body, response.status);
|
|
1713
2376
|
}
|
|
1714
2377
|
return (await response.json()).data;
|
|
@@ -1727,9 +2390,10 @@ const fetchKioskRealtimeToken = async (input, options) => {
|
|
|
1727
2390
|
};
|
|
1728
2391
|
const response = await kioskFetch(path, init, options);
|
|
1729
2392
|
if (!response.ok) {
|
|
1730
|
-
const body = await readError(response);
|
|
2393
|
+
const body = await readError$1(response);
|
|
1731
2394
|
throwKioskError(body, response.status);
|
|
1732
2395
|
}
|
|
2396
|
+
captureLogDevice(input.device_uid);
|
|
1733
2397
|
return (await response.json()).data;
|
|
1734
2398
|
};
|
|
1735
2399
|
//#endregion
|
|
@@ -1747,9 +2411,11 @@ const menuQuery = (scope, stream) => {
|
|
|
1747
2411
|
return `/api/storefront/v1/menus${query ? `?${query}` : ""}`;
|
|
1748
2412
|
};
|
|
1749
2413
|
const listMenus = (scope = {}, options) => {
|
|
2414
|
+
captureLogScope(scope);
|
|
1750
2415
|
return storefrontJson(menuQuery(scope), {}, options);
|
|
1751
2416
|
};
|
|
1752
2417
|
const listMenusStream = (scope = {}, options = {}) => {
|
|
2418
|
+
captureLogScope(scope);
|
|
1753
2419
|
const { category_limit, product_limit, ...http } = options;
|
|
1754
2420
|
return storefrontNdjson(menuQuery(scope, {
|
|
1755
2421
|
streaming: true,
|
|
@@ -1815,6 +2481,18 @@ const placeOrder = (id, body = {}, options) => {
|
|
|
1815
2481
|
options
|
|
1816
2482
|
]);
|
|
1817
2483
|
};
|
|
2484
|
+
/** Records (or updates by `metadata.attempt_reference`) one payment intent. */
|
|
2485
|
+
const recordOrderPayment = (id, body, options) => {
|
|
2486
|
+
const init = {
|
|
2487
|
+
method: "POST",
|
|
2488
|
+
body: JSON.stringify(body)
|
|
2489
|
+
};
|
|
2490
|
+
return storefrontJson(...[
|
|
2491
|
+
`${orderPath(id)}/payments`,
|
|
2492
|
+
init,
|
|
2493
|
+
options
|
|
2494
|
+
]);
|
|
2495
|
+
};
|
|
1818
2496
|
const getOrderFulfillment = (id, options) => {
|
|
1819
2497
|
return storefrontJson(...[
|
|
1820
2498
|
`${orderPath(id)}/fulfillment`,
|
|
@@ -1893,6 +2571,229 @@ const listPayments = (input, options) => {
|
|
|
1893
2571
|
for (const [key, value] of Object.entries(input)) if (value) params.set(key, value);
|
|
1894
2572
|
return storefrontJson(`/api/storefront/v1/payments?${params.toString()}`, {}, options);
|
|
1895
2573
|
};
|
|
2574
|
+
/** Server half of an adapter (`POST /payments/:id/operations/:operation`). */
|
|
2575
|
+
const callPaymentOperation = (paymentId, operation, body, options) => {
|
|
2576
|
+
return storefrontJson(`/api/storefront/v1/payments/${encodeURIComponent(paymentId)}/operations/${encodeURIComponent(operation)}`, {
|
|
2577
|
+
method: "POST",
|
|
2578
|
+
body: JSON.stringify(body)
|
|
2579
|
+
}, options);
|
|
2580
|
+
};
|
|
2581
|
+
/** Reports a failed terminal operation (`payment.terminal_failed`). */
|
|
2582
|
+
const reportTerminalFailure = (paymentId, body, options) => {
|
|
2583
|
+
return storefrontJson(`/api/storefront/v1/payments/${encodeURIComponent(paymentId)}/terminal-failures`, {
|
|
2584
|
+
method: "POST",
|
|
2585
|
+
body: JSON.stringify(body)
|
|
2586
|
+
}, options);
|
|
2587
|
+
};
|
|
2588
|
+
//#endregion
|
|
2589
|
+
//#region src/i18n/pos.ts
|
|
2590
|
+
const enUS = {
|
|
2591
|
+
DEVICE_NOT_LINKED: "This register is not linked.",
|
|
2592
|
+
DEVICE_NOT_ACTIVE: "This register is not active.",
|
|
2593
|
+
PAIRING_CODE_INVALID: "That pairing code is not valid.",
|
|
2594
|
+
RELINK_CODE_INVALID: "That re-link code is not valid.",
|
|
2595
|
+
RELINK_CODE_EXPIRED: "That re-link code has expired.",
|
|
2596
|
+
PIN_INVALID: "The PIN is incorrect.",
|
|
2597
|
+
PIN_LOCKED: "This PIN is locked. Try again later.",
|
|
2598
|
+
OPERATOR_NOT_ASSIGNED: "This operator is not assigned to this store.",
|
|
2599
|
+
AUTHORIZER_NOT_ALLOWED: "This person cannot authorize this action.",
|
|
2600
|
+
SESSION_ALREADY_OPEN: "A cash session is already open on this register.",
|
|
2601
|
+
SESSION_NOT_OPEN: "Open a cash session first.",
|
|
2602
|
+
BREAKDOWN_MISMATCH: "The cash breakdown does not match the amount.",
|
|
2603
|
+
DIFFERENCE_NEEDS_AUTH: "The difference needs a manager PIN.",
|
|
2604
|
+
KIOSK_ORDER_NOT_SETTLEABLE: "This kiosk order cannot be settled here.",
|
|
2605
|
+
PAYMENT_NOT_AVAILABLE: "This payment method is not available on this register."
|
|
2606
|
+
};
|
|
2607
|
+
const posErrorMessages = {
|
|
2608
|
+
en: enUS,
|
|
2609
|
+
"en-US": enUS,
|
|
2610
|
+
es: {
|
|
2611
|
+
DEVICE_NOT_LINKED: "Esta caja no está vinculada.",
|
|
2612
|
+
DEVICE_NOT_ACTIVE: "Esta caja no está activa.",
|
|
2613
|
+
PAIRING_CODE_INVALID: "Ese código de vinculación no es válido.",
|
|
2614
|
+
RELINK_CODE_INVALID: "Ese código de revinculación no es válido.",
|
|
2615
|
+
RELINK_CODE_EXPIRED: "Ese código de revinculación expiró.",
|
|
2616
|
+
PIN_INVALID: "El PIN es incorrecto.",
|
|
2617
|
+
PIN_LOCKED: "Este PIN está bloqueado. Inténtalo más tarde.",
|
|
2618
|
+
OPERATOR_NOT_ASSIGNED: "Este operador no está asignado a esta tienda.",
|
|
2619
|
+
AUTHORIZER_NOT_ALLOWED: "Esta persona no puede autorizar esta acción.",
|
|
2620
|
+
SESSION_ALREADY_OPEN: "Ya hay una sesión de caja abierta aquí.",
|
|
2621
|
+
SESSION_NOT_OPEN: "Abre una sesión de caja primero.",
|
|
2622
|
+
BREAKDOWN_MISMATCH: "El desglose de efectivo no coincide con el monto.",
|
|
2623
|
+
DIFFERENCE_NEEDS_AUTH: "La diferencia necesita el PIN de un supervisor.",
|
|
2624
|
+
KIOSK_ORDER_NOT_SETTLEABLE: "Este pedido de kiosco no se puede cobrar aquí.",
|
|
2625
|
+
PAYMENT_NOT_AVAILABLE: "Este método de pago no está disponible en esta caja."
|
|
2626
|
+
},
|
|
2627
|
+
"pt-BR": {
|
|
2628
|
+
DEVICE_NOT_LINKED: "Este caixa não está vinculado.",
|
|
2629
|
+
DEVICE_NOT_ACTIVE: "Este caixa não está ativo.",
|
|
2630
|
+
PAIRING_CODE_INVALID: "Esse código de vinculação não é válido.",
|
|
2631
|
+
RELINK_CODE_INVALID: "Esse código de revinculação não é válido.",
|
|
2632
|
+
RELINK_CODE_EXPIRED: "Esse código de revinculação expirou.",
|
|
2633
|
+
PIN_INVALID: "O PIN está incorreto.",
|
|
2634
|
+
PIN_LOCKED: "Este PIN está bloqueado. Tente novamente mais tarde.",
|
|
2635
|
+
OPERATOR_NOT_ASSIGNED: "Este operador não está cadastrado nesta loja.",
|
|
2636
|
+
AUTHORIZER_NOT_ALLOWED: "Esta pessoa não pode autorizar esta ação.",
|
|
2637
|
+
SESSION_ALREADY_OPEN: "Já existe uma sessão de caixa aberta aqui.",
|
|
2638
|
+
SESSION_NOT_OPEN: "Abra uma sessão de caixa primeiro.",
|
|
2639
|
+
BREAKDOWN_MISMATCH: "A composição do dinheiro não confere com o valor.",
|
|
2640
|
+
DIFFERENCE_NEEDS_AUTH: "A diferença precisa do PIN de um supervisor.",
|
|
2641
|
+
KIOSK_ORDER_NOT_SETTLEABLE: "Este pedido do totem não pode ser cobrado aqui.",
|
|
2642
|
+
PAYMENT_NOT_AVAILABLE: "Esta forma de pagamento não está disponível neste caixa."
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
const posErrorMessage = (code, locale = "en-US") => {
|
|
2646
|
+
if (!code) return void 0;
|
|
2647
|
+
return (posErrorMessages[locale] ?? posErrorMessages["en-US"])[code];
|
|
2648
|
+
};
|
|
2649
|
+
//#endregion
|
|
2650
|
+
//#region src/services/pos.ts
|
|
2651
|
+
const posUrl = (path, options) => {
|
|
2652
|
+
const override = options?.baseUrl;
|
|
2653
|
+
const base = override ? resolveBaseUrl(override) : resolveBaseUrl();
|
|
2654
|
+
if (!path.startsWith("/")) return `${base}/${path}`;
|
|
2655
|
+
return `${base}${path}`;
|
|
2656
|
+
};
|
|
2657
|
+
const posFetch = async (path, init, options) => {
|
|
2658
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
2659
|
+
const headers = new Headers(init.headers);
|
|
2660
|
+
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
2661
|
+
const signal = options?.signal ?? init.signal;
|
|
2662
|
+
const req = {
|
|
2663
|
+
...init,
|
|
2664
|
+
headers,
|
|
2665
|
+
signal
|
|
2666
|
+
};
|
|
2667
|
+
return fetchImpl(posUrl(path, options), req);
|
|
2668
|
+
};
|
|
2669
|
+
const readError = async (response) => {
|
|
2670
|
+
const text = await response.text();
|
|
2671
|
+
try {
|
|
2672
|
+
return JSON.parse(text);
|
|
2673
|
+
} catch {
|
|
2674
|
+
return { error: text || response.statusText };
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
const validationDetails = (details) => {
|
|
2678
|
+
if (!details || typeof details !== "object") return null;
|
|
2679
|
+
const record = details;
|
|
2680
|
+
if (!Array.isArray(record.issues) || !record.lines) return null;
|
|
2681
|
+
return {
|
|
2682
|
+
issues: record.issues,
|
|
2683
|
+
lines: record.lines
|
|
2684
|
+
};
|
|
2685
|
+
};
|
|
2686
|
+
/** A sale the menu rejects comes back as a 422 with issues, like checkout. */
|
|
2687
|
+
const throwPosError = (body, status) => {
|
|
2688
|
+
const details = status === 422 ? validationDetails(body.details) : null;
|
|
2689
|
+
if (details) throw new OrderValidationError(details.issues, details.lines);
|
|
2690
|
+
throw new StorefrontHttpError(body.error ?? "Request failed", status, body.code);
|
|
2691
|
+
};
|
|
2692
|
+
const postJson = async (path, input, options) => {
|
|
2693
|
+
const init = {
|
|
2694
|
+
method: "POST",
|
|
2695
|
+
body: JSON.stringify(input)
|
|
2696
|
+
};
|
|
2697
|
+
const response = await posFetch(path, init, options);
|
|
2698
|
+
if (!response.ok) {
|
|
2699
|
+
const body = await readError(response);
|
|
2700
|
+
throwPosError(body, response.status);
|
|
2701
|
+
}
|
|
2702
|
+
capturePosLogDevice(input, options);
|
|
2703
|
+
return await response.json();
|
|
2704
|
+
};
|
|
2705
|
+
/** Pairing answers with the device config; the secret came from the app. */
|
|
2706
|
+
const captureLinkedDevice = (data, device_secret, options) => {
|
|
2707
|
+
captureLogDeviceConfig(data);
|
|
2708
|
+
const device = data?.device;
|
|
2709
|
+
capturePosLogDevice({
|
|
2710
|
+
device_uid: device?.uid,
|
|
2711
|
+
device_secret
|
|
2712
|
+
}, options);
|
|
2713
|
+
};
|
|
2714
|
+
const isPosDeviceNotLinked = (error) => {
|
|
2715
|
+
if (!(error instanceof StorefrontHttpError)) return false;
|
|
2716
|
+
if (error.code === "DEVICE_NOT_LINKED") return true;
|
|
2717
|
+
if (error.status !== 404) return false;
|
|
2718
|
+
return error.message.includes("DEVICE_NOT_LINKED");
|
|
2719
|
+
};
|
|
2720
|
+
const isPosUnauthorized = (error) => {
|
|
2721
|
+
return error instanceof StorefrontHttpError && error.status === 401;
|
|
2722
|
+
};
|
|
2723
|
+
const isPosPinLocked = (error) => {
|
|
2724
|
+
return error instanceof StorefrontHttpError && error.code === "PIN_LOCKED";
|
|
2725
|
+
};
|
|
2726
|
+
const localizePosError = (error, locale = "en-US") => {
|
|
2727
|
+
if (!(error instanceof StorefrontHttpError)) return void 0;
|
|
2728
|
+
return posErrorMessage(error.code, locale);
|
|
2729
|
+
};
|
|
2730
|
+
const pairPosDevice = async (input, options) => {
|
|
2731
|
+
const json = await postJson("/api/storefront/v1/pos/devices/pair", input, options);
|
|
2732
|
+
captureLinkedDevice(json.data, input.device_secret, options);
|
|
2733
|
+
return json.data;
|
|
2734
|
+
};
|
|
2735
|
+
const relinkPosDevice = async (input, options) => {
|
|
2736
|
+
const json = await postJson("/api/storefront/v1/pos/devices/relink", input, options);
|
|
2737
|
+
captureLinkedDevice(json.data, input.device_secret, options);
|
|
2738
|
+
return json.data;
|
|
2739
|
+
};
|
|
2740
|
+
const postPosHeartbeat = async (input, options) => {
|
|
2741
|
+
return postJson("/api/storefront/v1/pos/devices/me", input, options);
|
|
2742
|
+
};
|
|
2743
|
+
const postPosHealthSamples = async (input, options) => {
|
|
2744
|
+
return postJson("/api/storefront/v1/pos/devices/me/health", input, options);
|
|
2745
|
+
};
|
|
2746
|
+
const postPosDeviceLogs = async (input, options) => {
|
|
2747
|
+
return postJson("/api/storefront/v1/pos/devices/me/logs", input, options);
|
|
2748
|
+
};
|
|
2749
|
+
const listPosOperators = async (input, options) => {
|
|
2750
|
+
return (await postJson("/api/storefront/v1/pos/operators", input, options)).data;
|
|
2751
|
+
};
|
|
2752
|
+
const verifyPosOperator = async (input, options) => {
|
|
2753
|
+
return (await postJson("/api/storefront/v1/pos/operators/verify", input, options)).data;
|
|
2754
|
+
};
|
|
2755
|
+
const listPosAuthorizers = async (input, options) => {
|
|
2756
|
+
return (await postJson("/api/storefront/v1/pos/authorizers", input, options)).data;
|
|
2757
|
+
};
|
|
2758
|
+
const verifyPosAuthorizer = async (input, options) => {
|
|
2759
|
+
return (await postJson("/api/storefront/v1/pos/auth/verify", input, options)).data;
|
|
2760
|
+
};
|
|
2761
|
+
const openPosSession = async (input, options) => {
|
|
2762
|
+
return (await postJson("/api/storefront/v1/pos/sessions/open", input, options)).data;
|
|
2763
|
+
};
|
|
2764
|
+
const countPosSession = async (input, options) => {
|
|
2765
|
+
return (await postJson("/api/storefront/v1/pos/sessions/count", input, options)).data;
|
|
2766
|
+
};
|
|
2767
|
+
const withdrawPosSession = async (input, options) => {
|
|
2768
|
+
return (await postJson("/api/storefront/v1/pos/sessions/withdraw", input, options)).data;
|
|
2769
|
+
};
|
|
2770
|
+
const drawerPosSession = async (input, options) => {
|
|
2771
|
+
return (await postJson("/api/storefront/v1/pos/sessions/drawer", input, options)).data;
|
|
2772
|
+
};
|
|
2773
|
+
const closePosSession = async (input, options) => {
|
|
2774
|
+
return (await postJson("/api/storefront/v1/pos/sessions/close", input, options)).data;
|
|
2775
|
+
};
|
|
2776
|
+
const shiftEndPosSession = async (input, options) => {
|
|
2777
|
+
return (await postJson("/api/storefront/v1/pos/sessions/shift-end", input, options)).data;
|
|
2778
|
+
};
|
|
2779
|
+
/** The device's open caja and its events, or `{ session: null }`. */
|
|
2780
|
+
const getPosCurrentSession = async (input, options) => {
|
|
2781
|
+
return (await postJson("/api/storefront/v1/pos/sessions/current", input, options)).data;
|
|
2782
|
+
};
|
|
2783
|
+
const listPosKioskOrders = async (input, options) => {
|
|
2784
|
+
return (await postJson("/api/storefront/v1/pos/kiosk-orders", input, options)).data;
|
|
2785
|
+
};
|
|
2786
|
+
/** Safe to retry with the same `client_id`; the server dedupes the settle. */
|
|
2787
|
+
const settlePosKioskOrder = async (input, options) => {
|
|
2788
|
+
return (await postJson("/api/storefront/v1/pos/kiosk-orders/settle", input, options)).data;
|
|
2789
|
+
};
|
|
2790
|
+
const getPosCatalog = async (input, options) => {
|
|
2791
|
+
return (await postJson("/api/storefront/v1/pos/catalog", input, options)).data;
|
|
2792
|
+
};
|
|
2793
|
+
/** Safe to retry with the same `client_id`; the server dedupes the sale. */
|
|
2794
|
+
const createPosOrder = async (input, options) => {
|
|
2795
|
+
return (await postJson("/api/storefront/v1/pos/orders", input, options)).data;
|
|
2796
|
+
};
|
|
1896
2797
|
//#endregion
|
|
1897
2798
|
//#region src/services/stores.ts
|
|
1898
2799
|
const storesPath = (suffix) => {
|
|
@@ -1921,8 +2822,8 @@ const listNearbyStores = (input, options) => {
|
|
|
1921
2822
|
};
|
|
1922
2823
|
return storefrontJson(withQuery(storesPath("nearby"), query), {}, options);
|
|
1923
2824
|
};
|
|
1924
|
-
const getStore = (id, options) => {
|
|
1925
|
-
return storefrontJson(storesPath(encodeURIComponent(id)), {}, options);
|
|
2825
|
+
const getStore = (id, input = {}, options) => {
|
|
2826
|
+
return storefrontJson(withQuery(storesPath(encodeURIComponent(id)), { ...input }), {}, options);
|
|
1926
2827
|
};
|
|
1927
2828
|
//#endregion
|
|
1928
2829
|
//#region src/services/index.ts
|
|
@@ -1955,10 +2856,15 @@ const services = {
|
|
|
1955
2856
|
reorder: reorderOrder,
|
|
1956
2857
|
validate: validateOrder,
|
|
1957
2858
|
place: placeOrder,
|
|
2859
|
+
recordPayment: recordOrderPayment,
|
|
1958
2860
|
getFulfillment: getOrderFulfillment,
|
|
1959
2861
|
listFulfillments: listOrderFulfillments
|
|
1960
2862
|
},
|
|
1961
|
-
payments: {
|
|
2863
|
+
payments: {
|
|
2864
|
+
list: listPayments,
|
|
2865
|
+
operation: callPaymentOperation,
|
|
2866
|
+
terminalFailure: reportTerminalFailure
|
|
2867
|
+
},
|
|
1962
2868
|
stores: {
|
|
1963
2869
|
list: listStores,
|
|
1964
2870
|
nearby: listNearbyStores,
|
|
@@ -1976,6 +2882,32 @@ const services = {
|
|
|
1976
2882
|
isAdminCodeLocked: isKioskAdminCodeLocked,
|
|
1977
2883
|
isDeviceNotLinked,
|
|
1978
2884
|
isKioskUnauthorized
|
|
2885
|
+
},
|
|
2886
|
+
pos: {
|
|
2887
|
+
pair: pairPosDevice,
|
|
2888
|
+
relink: relinkPosDevice,
|
|
2889
|
+
heartbeat: postPosHeartbeat,
|
|
2890
|
+
postHealthSamples: postPosHealthSamples,
|
|
2891
|
+
postLogs: postPosDeviceLogs,
|
|
2892
|
+
listOperators: listPosOperators,
|
|
2893
|
+
verifyOperator: verifyPosOperator,
|
|
2894
|
+
listAuthorizers: listPosAuthorizers,
|
|
2895
|
+
verifyAuthorizer: verifyPosAuthorizer,
|
|
2896
|
+
openSession: openPosSession,
|
|
2897
|
+
countSession: countPosSession,
|
|
2898
|
+
withdraw: withdrawPosSession,
|
|
2899
|
+
drawer: drawerPosSession,
|
|
2900
|
+
closeSession: closePosSession,
|
|
2901
|
+
shiftEnd: shiftEndPosSession,
|
|
2902
|
+
currentSession: getPosCurrentSession,
|
|
2903
|
+
listKioskOrders: listPosKioskOrders,
|
|
2904
|
+
settleKioskOrder: settlePosKioskOrder,
|
|
2905
|
+
getCatalog: getPosCatalog,
|
|
2906
|
+
createOrder: createPosOrder,
|
|
2907
|
+
isDeviceNotLinked: isPosDeviceNotLinked,
|
|
2908
|
+
isUnauthorized: isPosUnauthorized,
|
|
2909
|
+
isPinLocked: isPosPinLocked,
|
|
2910
|
+
localizeError: localizePosError
|
|
1979
2911
|
}
|
|
1980
2912
|
};
|
|
1981
2913
|
const DISALLOWED = /[^A-Za-z0-9]/g;
|
|
@@ -2057,7 +2989,7 @@ const genOrderId = (storeId) => {
|
|
|
2057
2989
|
};
|
|
2058
2990
|
//#endregion
|
|
2059
2991
|
//#region src/order/retry.ts
|
|
2060
|
-
const defaultSleep = (ms) => {
|
|
2992
|
+
const defaultSleep$1 = (ms) => {
|
|
2061
2993
|
return new Promise((resolve) => {
|
|
2062
2994
|
setTimeout(resolve, ms);
|
|
2063
2995
|
});
|
|
@@ -2066,7 +2998,7 @@ const defaultSleep = (ms) => {
|
|
|
2066
2998
|
const withExponentialRetry = async (run, options = {}) => {
|
|
2067
2999
|
const extraAttempts = options.extraAttempts ?? 3;
|
|
2068
3000
|
const baseDelayMs = options.baseDelayMs ?? 100;
|
|
2069
|
-
const sleep = options.sleep ?? defaultSleep;
|
|
3001
|
+
const sleep = options.sleep ?? defaultSleep$1;
|
|
2070
3002
|
let lastError;
|
|
2071
3003
|
for (let attempt = 0; attempt <= extraAttempts; attempt += 1) try {
|
|
2072
3004
|
return await run();
|
|
@@ -2317,104 +3249,873 @@ const removeSubtree = (lines, rootUid) => {
|
|
|
2317
3249
|
grew = true;
|
|
2318
3250
|
}
|
|
2319
3251
|
}
|
|
2320
|
-
return lines.filter((line) => !drop.has(line.uid));
|
|
2321
|
-
};
|
|
2322
|
-
const scaleLineQuantity = (line, nextQty) => {
|
|
2323
|
-
const qty = new Big(nextQty);
|
|
2324
|
-
const subtotal = new Big(line.unit_subtotal).times(qty).toNumber();
|
|
2325
|
-
const discount_total = new Big(line.unit_discount).times(qty).toNumber();
|
|
2326
|
-
const tax_total = new Big(line.unit_tax_total).times(qty).toNumber();
|
|
2327
|
-
const total = new Big(line.unit_total).times(qty).toNumber();
|
|
2328
|
-
const gSub = new Big(line.unit_group_subtotal).times(qty).toNumber();
|
|
2329
|
-
const gDisc = new Big(line.unit_group_discount).times(qty).toNumber();
|
|
2330
|
-
const gTax = new Big(line.unit_group_tax_total).times(qty).toNumber();
|
|
2331
|
-
const gTot = new Big(line.unit_group_total).times(qty).toNumber();
|
|
2332
|
-
const b1 = {
|
|
2333
|
-
...line,
|
|
2334
|
-
quantity: nextQty,
|
|
2335
|
-
subtotal,
|
|
2336
|
-
discount_total
|
|
2337
|
-
};
|
|
2338
|
-
const b2 = {
|
|
2339
|
-
tax_total,
|
|
2340
|
-
total,
|
|
2341
|
-
group_subtotal: gSub,
|
|
2342
|
-
group_discount: gDisc
|
|
2343
|
-
};
|
|
2344
|
-
const b3 = {
|
|
2345
|
-
group_tax_total: gTax,
|
|
2346
|
-
group_total: gTot
|
|
2347
|
-
};
|
|
2348
|
-
return {
|
|
2349
|
-
...b1,
|
|
2350
|
-
...b2,
|
|
2351
|
-
...b3
|
|
2352
|
-
};
|
|
2353
|
-
};
|
|
2354
|
-
const collectDescendantUids = (lines, rootUid) => {
|
|
2355
|
-
const uids = /* @__PURE__ */ new Set([rootUid]);
|
|
2356
|
-
let grew = true;
|
|
2357
|
-
while (grew) {
|
|
2358
|
-
grew = false;
|
|
2359
|
-
for (const line of lines) if (line.parent_uid && uids.has(line.parent_uid) && !uids.has(line.uid)) {
|
|
2360
|
-
uids.add(line.uid);
|
|
2361
|
-
grew = true;
|
|
3252
|
+
return lines.filter((line) => !drop.has(line.uid));
|
|
3253
|
+
};
|
|
3254
|
+
const scaleLineQuantity = (line, nextQty) => {
|
|
3255
|
+
const qty = new Big(nextQty);
|
|
3256
|
+
const subtotal = new Big(line.unit_subtotal).times(qty).toNumber();
|
|
3257
|
+
const discount_total = new Big(line.unit_discount).times(qty).toNumber();
|
|
3258
|
+
const tax_total = new Big(line.unit_tax_total).times(qty).toNumber();
|
|
3259
|
+
const total = new Big(line.unit_total).times(qty).toNumber();
|
|
3260
|
+
const gSub = new Big(line.unit_group_subtotal).times(qty).toNumber();
|
|
3261
|
+
const gDisc = new Big(line.unit_group_discount).times(qty).toNumber();
|
|
3262
|
+
const gTax = new Big(line.unit_group_tax_total).times(qty).toNumber();
|
|
3263
|
+
const gTot = new Big(line.unit_group_total).times(qty).toNumber();
|
|
3264
|
+
const b1 = {
|
|
3265
|
+
...line,
|
|
3266
|
+
quantity: nextQty,
|
|
3267
|
+
subtotal,
|
|
3268
|
+
discount_total
|
|
3269
|
+
};
|
|
3270
|
+
const b2 = {
|
|
3271
|
+
tax_total,
|
|
3272
|
+
total,
|
|
3273
|
+
group_subtotal: gSub,
|
|
3274
|
+
group_discount: gDisc
|
|
3275
|
+
};
|
|
3276
|
+
const b3 = {
|
|
3277
|
+
group_tax_total: gTax,
|
|
3278
|
+
group_total: gTot
|
|
3279
|
+
};
|
|
3280
|
+
return {
|
|
3281
|
+
...b1,
|
|
3282
|
+
...b2,
|
|
3283
|
+
...b3
|
|
3284
|
+
};
|
|
3285
|
+
};
|
|
3286
|
+
const collectDescendantUids = (lines, rootUid) => {
|
|
3287
|
+
const uids = /* @__PURE__ */ new Set([rootUid]);
|
|
3288
|
+
let grew = true;
|
|
3289
|
+
while (grew) {
|
|
3290
|
+
grew = false;
|
|
3291
|
+
for (const line of lines) if (line.parent_uid && uids.has(line.parent_uid) && !uids.has(line.uid)) {
|
|
3292
|
+
uids.add(line.uid);
|
|
3293
|
+
grew = true;
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
return uids;
|
|
3297
|
+
};
|
|
3298
|
+
/** Partition a flat batch into root trees (root first, then descendants). */
|
|
3299
|
+
const partitionTrees = (batch) => {
|
|
3300
|
+
const roots = batch.filter((line) => line.parent_uid == null);
|
|
3301
|
+
const trees = [];
|
|
3302
|
+
for (const root of roots) {
|
|
3303
|
+
const uids = collectDescendantUids(batch, root.uid);
|
|
3304
|
+
trees.push(batch.filter((line) => uids.has(line.uid)));
|
|
3305
|
+
}
|
|
3306
|
+
return trees;
|
|
3307
|
+
};
|
|
3308
|
+
const addLines = (lines, incoming) => {
|
|
3309
|
+
const batch = Array.isArray(incoming) ? incoming : [incoming];
|
|
3310
|
+
let next = lines;
|
|
3311
|
+
for (const tree of partitionTrees(batch)) {
|
|
3312
|
+
const root = tree.find((line) => line.parent_uid == null);
|
|
3313
|
+
if (!root) continue;
|
|
3314
|
+
const fingerprint = computeLineFingerprint(root, tree);
|
|
3315
|
+
const existingRoot = next.find((line) => {
|
|
3316
|
+
if (line.parent_uid != null) return false;
|
|
3317
|
+
return computeLineFingerprint(line, next) === fingerprint;
|
|
3318
|
+
});
|
|
3319
|
+
if (existingRoot) {
|
|
3320
|
+
const mergedQty = existingRoot.quantity + root.quantity;
|
|
3321
|
+
next = next.map((line) => {
|
|
3322
|
+
if (line.uid !== existingRoot.uid) return line;
|
|
3323
|
+
return scaleLineQuantity(line, mergedQty);
|
|
3324
|
+
});
|
|
3325
|
+
continue;
|
|
3326
|
+
}
|
|
3327
|
+
next = [...next, ...tree];
|
|
3328
|
+
}
|
|
3329
|
+
return next;
|
|
3330
|
+
};
|
|
3331
|
+
const removeLine = (lines, lineUid) => {
|
|
3332
|
+
return removeSubtree(lines, lineUid);
|
|
3333
|
+
};
|
|
3334
|
+
const subtractLine = (lines, lineUid, quantity = 1) => {
|
|
3335
|
+
const target = lines.find((line) => line.uid === lineUid);
|
|
3336
|
+
if (!target) return lines;
|
|
3337
|
+
const nextQty = target.quantity - quantity;
|
|
3338
|
+
if (nextQty <= 0) return removeSubtree(lines, lineUid);
|
|
3339
|
+
return lines.map((line) => {
|
|
3340
|
+
if (line.uid !== lineUid) return line;
|
|
3341
|
+
return scaleLineQuantity(line, nextQty);
|
|
3342
|
+
});
|
|
3343
|
+
};
|
|
3344
|
+
/** Rescale one line's money to `quantity`; zero or less drops its subtree. */
|
|
3345
|
+
const setLineQuantity = (lines, lineUid, quantity) => {
|
|
3346
|
+
if (quantity <= 0) return removeSubtree(lines, lineUid);
|
|
3347
|
+
return lines.map((line) => {
|
|
3348
|
+
if (line.uid !== lineUid) return line;
|
|
3349
|
+
return scaleLineQuantity(line, quantity);
|
|
3350
|
+
});
|
|
3351
|
+
};
|
|
3352
|
+
/** Drop a root subtree, then add the replacement tree (may merge by fingerprint). */
|
|
3353
|
+
const replaceLine = (lines, lineUid, next) => {
|
|
3354
|
+
return addLines(removeSubtree(lines, lineUid), next);
|
|
3355
|
+
};
|
|
3356
|
+
const emptyLines = () => {
|
|
3357
|
+
return [];
|
|
3358
|
+
};
|
|
3359
|
+
//#endregion
|
|
3360
|
+
//#region src/order/payments/orchestrator.ts
|
|
3361
|
+
const AMBIGUOUS_QUERIES = 3;
|
|
3362
|
+
const defaultSleep = (ms, signal) => {
|
|
3363
|
+
return new Promise((resolve) => {
|
|
3364
|
+
const timer = setTimeout(resolve, ms);
|
|
3365
|
+
signal?.addEventListener("abort", () => {
|
|
3366
|
+
clearTimeout(timer);
|
|
3367
|
+
resolve();
|
|
3368
|
+
});
|
|
3369
|
+
});
|
|
3370
|
+
};
|
|
3371
|
+
const defaultClock = {
|
|
3372
|
+
now: () => Date.now(),
|
|
3373
|
+
sleep: defaultSleep
|
|
3374
|
+
};
|
|
3375
|
+
var TimeoutSignal = class extends Error {};
|
|
3376
|
+
/**
|
|
3377
|
+
* Provider deadline that pauses while the customer is answering `ctx.ui`;
|
|
3378
|
+
* user waits run on `interaction_timeout_ms` instead.
|
|
3379
|
+
*/
|
|
3380
|
+
const createDeadline = (timeoutMs, onExpire) => {
|
|
3381
|
+
let remaining = timeoutMs;
|
|
3382
|
+
let startedAt = 0;
|
|
3383
|
+
let timer = null;
|
|
3384
|
+
let paused = 0;
|
|
3385
|
+
const run = () => {
|
|
3386
|
+
startedAt = Date.now();
|
|
3387
|
+
timer = setTimeout(onExpire, Math.max(remaining, 0));
|
|
3388
|
+
};
|
|
3389
|
+
const stop = () => {
|
|
3390
|
+
if (!timer) return;
|
|
3391
|
+
clearTimeout(timer);
|
|
3392
|
+
timer = null;
|
|
3393
|
+
remaining -= Date.now() - startedAt;
|
|
3394
|
+
};
|
|
3395
|
+
return {
|
|
3396
|
+
start: run,
|
|
3397
|
+
pause: () => {
|
|
3398
|
+
if (paused === 0) stop();
|
|
3399
|
+
paused += 1;
|
|
3400
|
+
},
|
|
3401
|
+
resume: () => {
|
|
3402
|
+
paused -= 1;
|
|
3403
|
+
if (paused === 0) run();
|
|
3404
|
+
},
|
|
3405
|
+
clear: stop
|
|
3406
|
+
};
|
|
3407
|
+
};
|
|
3408
|
+
const withUserTimeout = (promise, ms) => {
|
|
3409
|
+
return new Promise((resolve, reject) => {
|
|
3410
|
+
const timer = setTimeout(() => {
|
|
3411
|
+
reject(new PaymentUserTimeout());
|
|
3412
|
+
}, ms);
|
|
3413
|
+
promise.then((value) => {
|
|
3414
|
+
clearTimeout(timer);
|
|
3415
|
+
resolve(value);
|
|
3416
|
+
}, (error) => {
|
|
3417
|
+
clearTimeout(timer);
|
|
3418
|
+
reject(error);
|
|
3419
|
+
});
|
|
3420
|
+
});
|
|
3421
|
+
};
|
|
3422
|
+
var PaymentUserTimeout = class extends Error {};
|
|
3423
|
+
const timedUI = (ui, deadline, interactionMs) => {
|
|
3424
|
+
const wrap = (call) => {
|
|
3425
|
+
deadline.pause();
|
|
3426
|
+
return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
|
|
3427
|
+
};
|
|
3428
|
+
return {
|
|
3429
|
+
message: (input) => ui.message(input),
|
|
3430
|
+
action: (input) => ui.action(input),
|
|
3431
|
+
select: (input) => {
|
|
3432
|
+
return wrap(() => ui.select(input));
|
|
3433
|
+
},
|
|
3434
|
+
input: (input) => wrap(() => ui.input(input)),
|
|
3435
|
+
confirm: (input) => wrap(() => ui.confirm(input))
|
|
3436
|
+
};
|
|
3437
|
+
};
|
|
3438
|
+
/** Runs one adapter call with the provider timeout and the user clock. */
|
|
3439
|
+
const runTimed = async (ctx, call) => {
|
|
3440
|
+
const controller = new AbortController();
|
|
3441
|
+
const parent = ctx.signal;
|
|
3442
|
+
const abort = () => controller.abort();
|
|
3443
|
+
parent.addEventListener("abort", abort);
|
|
3444
|
+
let expire = () => void 0;
|
|
3445
|
+
const expired = new Promise((_, reject) => {
|
|
3446
|
+
expire = () => {
|
|
3447
|
+
controller.abort();
|
|
3448
|
+
reject(new TimeoutSignal());
|
|
3449
|
+
};
|
|
3450
|
+
});
|
|
3451
|
+
const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
|
|
3452
|
+
const interaction = ctx.policy.interaction_timeout_ms;
|
|
3453
|
+
const ui = timedUI(ctx.ui, deadline, interaction);
|
|
3454
|
+
const scoped = {
|
|
3455
|
+
...ctx,
|
|
3456
|
+
ui,
|
|
3457
|
+
signal: controller.signal
|
|
3458
|
+
};
|
|
3459
|
+
deadline.start();
|
|
3460
|
+
try {
|
|
3461
|
+
return await Promise.race([call(scoped), expired]);
|
|
3462
|
+
} finally {
|
|
3463
|
+
deadline.clear();
|
|
3464
|
+
parent.removeEventListener("abort", abort);
|
|
3465
|
+
}
|
|
3466
|
+
};
|
|
3467
|
+
const failed = (ctx, error, extra = {}) => {
|
|
3468
|
+
const metadata = {
|
|
3469
|
+
attempt_reference: ctx.attemptReference,
|
|
3470
|
+
error,
|
|
3471
|
+
...extra
|
|
3472
|
+
};
|
|
3473
|
+
return {
|
|
3474
|
+
status: "FAILED",
|
|
3475
|
+
amount: ctx.amount.value,
|
|
3476
|
+
metadata,
|
|
3477
|
+
error
|
|
3478
|
+
};
|
|
3479
|
+
};
|
|
3480
|
+
const errorOf = (ctx, error, startedAt, clock) => {
|
|
3481
|
+
const elapsed_ms = clock.now() - startedAt;
|
|
3482
|
+
const attempt = ctx.attempt;
|
|
3483
|
+
if (isPaymentError(error)) return {
|
|
3484
|
+
...error.error,
|
|
3485
|
+
attempt,
|
|
3486
|
+
elapsed_ms
|
|
3487
|
+
};
|
|
3488
|
+
let code = "UNKNOWN";
|
|
3489
|
+
if (error instanceof TimeoutSignal) code = "TIMEOUT";
|
|
3490
|
+
if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
|
|
3491
|
+
const extra = {
|
|
3492
|
+
attempt,
|
|
3493
|
+
elapsed_ms,
|
|
3494
|
+
provider_message: error instanceof Error ? error.message : null
|
|
3495
|
+
};
|
|
3496
|
+
return createPaymentError(code, extra);
|
|
3497
|
+
};
|
|
3498
|
+
/** `start` that timed out after leaving the device is ambiguous. */
|
|
3499
|
+
const startError = (error) => {
|
|
3500
|
+
if (error.code !== "TIMEOUT") return error;
|
|
3501
|
+
return createPaymentError("AMBIGUOUS", {
|
|
3502
|
+
attempt: error.attempt,
|
|
3503
|
+
elapsed_ms: error.elapsed_ms
|
|
3504
|
+
});
|
|
3505
|
+
};
|
|
3506
|
+
const isFinal = (result) => result.status !== "PENDING";
|
|
3507
|
+
const callOperation = async (ctx, operation, result, clock) => {
|
|
3508
|
+
const startedAt = clock.now();
|
|
3509
|
+
try {
|
|
3510
|
+
return await runTimed(ctx, (scoped) => operation(scoped, result));
|
|
3511
|
+
} catch (error) {
|
|
3512
|
+
return failed(ctx, errorOf(ctx, error, startedAt, clock));
|
|
3513
|
+
}
|
|
3514
|
+
};
|
|
3515
|
+
/** Query up to 3 times, cancel once, else FAILED + needs_review. */
|
|
3516
|
+
const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
|
|
3517
|
+
if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
|
|
3518
|
+
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
3519
|
+
const next = await callOperation(ctx, adapter.query, pending, clock);
|
|
3520
|
+
if (next.status === "PAID") return next;
|
|
3521
|
+
const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
|
|
3522
|
+
if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
|
|
3523
|
+
}
|
|
3524
|
+
if (adapter.cancel) {
|
|
3525
|
+
const cancelled = await callOperation(ctx, adapter.cancel, pending, clock);
|
|
3526
|
+
if (cancelled.status === "CANCELLED") return cancelled;
|
|
3527
|
+
}
|
|
3528
|
+
const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
|
|
3529
|
+
return failed(ctx, error, { needs_review: true });
|
|
3530
|
+
};
|
|
3531
|
+
/** PENDING with an action: poll until paid, rejected or the QR expires. */
|
|
3532
|
+
const pollPending = async (adapter, ctx, pending, clock, options) => {
|
|
3533
|
+
if (pending.metadata.confirm_after_inject) return pending;
|
|
3534
|
+
if (!adapter.query) return pending;
|
|
3535
|
+
const expiresAt = clock.now() + ctx.policy.qr_ttl_ms;
|
|
3536
|
+
let current = pending;
|
|
3537
|
+
while (clock.now() < expiresAt) {
|
|
3538
|
+
if (ctx.signal.aborted) break;
|
|
3539
|
+
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
3540
|
+
if (ctx.signal.aborted) break;
|
|
3541
|
+
const next = await callOperation(ctx, adapter.query, current, clock);
|
|
3542
|
+
if (next.status === "FAILED" && next.error?.retryable) continue;
|
|
3543
|
+
if (isFinal(next)) return next;
|
|
3544
|
+
current = next;
|
|
3545
|
+
await options.onProgress?.(current);
|
|
3546
|
+
}
|
|
3547
|
+
if (adapter.cancel) await callOperation(ctx, adapter.cancel, current, clock);
|
|
3548
|
+
const code = ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED";
|
|
3549
|
+
return failed(ctx, createPaymentError(code, { attempt: ctx.attempt }));
|
|
3550
|
+
};
|
|
3551
|
+
const runStart = async (adapter, ctx, clock) => {
|
|
3552
|
+
const startedAt = clock.now();
|
|
3553
|
+
try {
|
|
3554
|
+
return await runTimed(ctx, (scoped) => adapter.start(scoped));
|
|
3555
|
+
} catch (error) {
|
|
3556
|
+
const raw = errorOf(ctx, error, startedAt, clock);
|
|
3557
|
+
return failed(ctx, startError(raw));
|
|
3558
|
+
}
|
|
3559
|
+
};
|
|
3560
|
+
const toTerminalContext = (ctx) => {
|
|
3561
|
+
const b1 = {
|
|
3562
|
+
paymentId: ctx.paymentId,
|
|
3563
|
+
attempt: ctx.attempt
|
|
3564
|
+
};
|
|
3565
|
+
const b2 = { attemptReference: ctx.attemptReference };
|
|
3566
|
+
const b3 = {
|
|
3567
|
+
environment: ctx.environment,
|
|
3568
|
+
config: ctx.config
|
|
3569
|
+
};
|
|
3570
|
+
const b4 = {
|
|
3571
|
+
deviceSettings: ctx.deviceSettings,
|
|
3572
|
+
policy: ctx.policy
|
|
3573
|
+
};
|
|
3574
|
+
const b5 = {
|
|
3575
|
+
ui: ctx.ui,
|
|
3576
|
+
storage: ctx.storage,
|
|
3577
|
+
server: ctx.server
|
|
3578
|
+
};
|
|
3579
|
+
return {
|
|
3580
|
+
...b1,
|
|
3581
|
+
...b2,
|
|
3582
|
+
...b3,
|
|
3583
|
+
...b4,
|
|
3584
|
+
...b5,
|
|
3585
|
+
signal: ctx.signal
|
|
3586
|
+
};
|
|
3587
|
+
};
|
|
3588
|
+
const closeBatch = async (adapter, ctx) => {
|
|
3589
|
+
const close = adapter.terminal?.closeBatch;
|
|
3590
|
+
if (!close) return false;
|
|
3591
|
+
const done = await close(toTerminalContext(ctx)).catch(() => null);
|
|
3592
|
+
return Boolean(done?.ok);
|
|
3593
|
+
};
|
|
3594
|
+
/**
|
|
3595
|
+
* One payment attempt with the policy of section 7: timeout, retries on
|
|
3596
|
+
* retryable errors (same `attempt_reference`), batch close and retry,
|
|
3597
|
+
* polling for actions and resolution of ambiguous results.
|
|
3598
|
+
*/
|
|
3599
|
+
const runPaymentAttempt = async (adapter, ctx, options = {}) => {
|
|
3600
|
+
const clock = options.clock ?? defaultClock;
|
|
3601
|
+
return withTerminalLock(ctx.paymentId, async () => {
|
|
3602
|
+
let batchClosed = false;
|
|
3603
|
+
let attempt = 1;
|
|
3604
|
+
while (true) {
|
|
3605
|
+
const current = {
|
|
3606
|
+
...ctx,
|
|
3607
|
+
attempt
|
|
3608
|
+
};
|
|
3609
|
+
const result = await runStart(adapter, current, clock);
|
|
3610
|
+
const code = result.error?.code;
|
|
3611
|
+
if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
|
|
3612
|
+
batchClosed = true;
|
|
3613
|
+
if (await closeBatch(adapter, current)) continue;
|
|
3614
|
+
}
|
|
3615
|
+
if (code === "AMBIGUOUS") {
|
|
3616
|
+
await options.onProgress?.({
|
|
3617
|
+
...result,
|
|
3618
|
+
status: "PENDING"
|
|
3619
|
+
});
|
|
3620
|
+
return resolveAmbiguous(adapter, current, result, clock);
|
|
3621
|
+
}
|
|
3622
|
+
const canRetry = attempt < ctx.policy.max_attempts;
|
|
3623
|
+
if (result.status === "FAILED" && result.error?.retryable && canRetry) {
|
|
3624
|
+
attempt += 1;
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
if (result.status !== "PENDING") return result;
|
|
3628
|
+
await options.onProgress?.(result);
|
|
3629
|
+
return pollPending(adapter, current, result, clock, options);
|
|
3630
|
+
}
|
|
3631
|
+
});
|
|
3632
|
+
};
|
|
3633
|
+
/** Undo a charge after a failed injection or an incomplete split payment. */
|
|
3634
|
+
const reversePayment = async (adapter, ctx, result, options = {}) => {
|
|
3635
|
+
const clock = options.clock ?? defaultClock;
|
|
3636
|
+
const now = options.now ?? new Date(clock.now());
|
|
3637
|
+
let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
|
|
3638
|
+
const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
|
|
3639
|
+
if (kind !== "manual" && !operation) kind = "manual";
|
|
3640
|
+
if (kind === "manual" || !operation) {
|
|
3641
|
+
const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
|
|
3642
|
+
const m1 = {
|
|
3643
|
+
...result.metadata,
|
|
3644
|
+
error,
|
|
3645
|
+
needs_review: true
|
|
3646
|
+
};
|
|
3647
|
+
return {
|
|
3648
|
+
kind: "manual",
|
|
3649
|
+
result: {
|
|
3650
|
+
...result,
|
|
3651
|
+
metadata: m1,
|
|
3652
|
+
error
|
|
3653
|
+
}
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
const run = () => callOperation(ctx, operation, result, clock);
|
|
3657
|
+
const next = await withTerminalLock(ctx.paymentId, run);
|
|
3658
|
+
return {
|
|
3659
|
+
kind,
|
|
3660
|
+
result: next
|
|
3661
|
+
};
|
|
3662
|
+
};
|
|
3663
|
+
//#endregion
|
|
3664
|
+
//#region src/order/payments/session.ts
|
|
3665
|
+
const noopUI = {
|
|
3666
|
+
message: () => void 0,
|
|
3667
|
+
action: () => void 0,
|
|
3668
|
+
select: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
|
|
3669
|
+
input: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
|
|
3670
|
+
confirm: () => Promise.resolve(false)
|
|
3671
|
+
};
|
|
3672
|
+
const adapterKey = (provider, method) => {
|
|
3673
|
+
return `${provider}:${method}`;
|
|
3674
|
+
};
|
|
3675
|
+
const record = (value) => {
|
|
3676
|
+
if (!value || typeof value !== "object") return {};
|
|
3677
|
+
return value;
|
|
3678
|
+
};
|
|
3679
|
+
/** PAID plus PENDING with `confirm_after_inject`, in the order currency. */
|
|
3680
|
+
const coveredAmount = (intents) => {
|
|
3681
|
+
return intents.reduce((sum, intent) => {
|
|
3682
|
+
const confirm = record(intent.metadata).confirm_after_inject === true;
|
|
3683
|
+
const pendingConfirm = intent.status === "PENDING" && confirm;
|
|
3684
|
+
if (intent.status !== "PAID" && !pendingConfirm) return sum;
|
|
3685
|
+
return sum + intent.totals.total;
|
|
3686
|
+
}, 0);
|
|
3687
|
+
};
|
|
3688
|
+
const remainingAmount = (order) => {
|
|
3689
|
+
const decimals = currencyDecimals(order.totals.currency);
|
|
3690
|
+
const left = order.totals.total - coveredAmount(order.payment_intents);
|
|
3691
|
+
return Math.max(roundTo(left, decimals), 0);
|
|
3692
|
+
};
|
|
3693
|
+
const isPlaced = (order) => order.status !== "OPEN";
|
|
3694
|
+
/** Orchestrator failures carry the charge-currency amount, not the order's. */
|
|
3695
|
+
const withOrderAmount = (result, amount) => {
|
|
3696
|
+
if (result.status !== "FAILED") return result;
|
|
3697
|
+
return {
|
|
3698
|
+
...result,
|
|
3699
|
+
amount
|
|
3700
|
+
};
|
|
3701
|
+
};
|
|
3702
|
+
const toFailure = (error) => {
|
|
3703
|
+
const message = error instanceof Error ? error.message : "Terminal failed";
|
|
3704
|
+
return {
|
|
3705
|
+
ok: false,
|
|
3706
|
+
message,
|
|
3707
|
+
error: createPaymentError("UNKNOWN", { provider_message: message })
|
|
3708
|
+
};
|
|
3709
|
+
};
|
|
3710
|
+
/**
|
|
3711
|
+
* Payment state of one order: adapters, device settings and the attempt
|
|
3712
|
+
* orchestration. The app only provides adapters, UI and storage.
|
|
3713
|
+
*/
|
|
3714
|
+
var PaymentSession = class {
|
|
3715
|
+
deps;
|
|
3716
|
+
adapters = /* @__PURE__ */ new Map();
|
|
3717
|
+
payments = /* @__PURE__ */ new Map();
|
|
3718
|
+
ui = noopUI;
|
|
3719
|
+
storage = memoryPaymentStorage();
|
|
3720
|
+
options = {};
|
|
3721
|
+
constructor(deps) {
|
|
3722
|
+
this.deps = deps;
|
|
3723
|
+
}
|
|
3724
|
+
setAdapters(adapters) {
|
|
3725
|
+
this.adapters.clear();
|
|
3726
|
+
for (const adapter of adapters) this.adapters.set(adapterKey(adapter.provider, adapter.method), adapter);
|
|
3727
|
+
}
|
|
3728
|
+
setUI(ui) {
|
|
3729
|
+
this.ui = ui;
|
|
3730
|
+
}
|
|
3731
|
+
setStorage(storage) {
|
|
3732
|
+
this.storage = storage;
|
|
3733
|
+
}
|
|
3734
|
+
setOptions(options) {
|
|
3735
|
+
this.options = {
|
|
3736
|
+
...this.options,
|
|
3737
|
+
...options
|
|
3738
|
+
};
|
|
3739
|
+
captureLogDevice(options.deviceId);
|
|
3740
|
+
}
|
|
3741
|
+
adapterFor(payment) {
|
|
3742
|
+
const key = adapterKey(payment.provider.provider_id, payment.method.method);
|
|
3743
|
+
return this.adapters.get(key) ?? null;
|
|
3744
|
+
}
|
|
3745
|
+
/** Device fields still missing for a payment on this device. */
|
|
3746
|
+
missingFields(payment) {
|
|
3747
|
+
const adapter = this.adapterFor(payment);
|
|
3748
|
+
if (!adapter) return [];
|
|
3749
|
+
const fromCatalog = record(payment.method.config).device_fields;
|
|
3750
|
+
const fields = adapter.deviceFields ?? fromCatalog;
|
|
3751
|
+
const settings = this.options.deviceSettings?.[payment.id];
|
|
3752
|
+
return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
|
|
3753
|
+
}
|
|
3754
|
+
/**
|
|
3755
|
+
* Merchant payments with an installed adapter (provider + method) that
|
|
3756
|
+
* are fully configured on this device.
|
|
3757
|
+
*/
|
|
3758
|
+
available(payments) {
|
|
3759
|
+
return payments.filter((payment) => {
|
|
3760
|
+
if (!this.adapterFor(payment)) return false;
|
|
3761
|
+
this.payments.set(payment.id, payment);
|
|
3762
|
+
return this.missingFields(payment).length === 0;
|
|
3763
|
+
});
|
|
3764
|
+
}
|
|
3765
|
+
remaining() {
|
|
3766
|
+
return remainingAmount(this.deps.getOrder());
|
|
3767
|
+
}
|
|
3768
|
+
server(payment, attempt) {
|
|
3769
|
+
const order_id = this.deps.getOrder().id;
|
|
3770
|
+
const http = this.deps.http;
|
|
3771
|
+
return { call: (operation, input) => {
|
|
3772
|
+
const body = {
|
|
3773
|
+
order_id,
|
|
3774
|
+
attempt_reference: attempt,
|
|
3775
|
+
input
|
|
3776
|
+
};
|
|
3777
|
+
const op = services.payments.operation;
|
|
3778
|
+
return op(payment.id, operation, body, http);
|
|
3779
|
+
} };
|
|
3780
|
+
}
|
|
3781
|
+
async chargeAmount(payment, value, server) {
|
|
3782
|
+
const config = record(payment.config);
|
|
3783
|
+
const orderCurrency = this.deps.getOrder().totals.currency;
|
|
3784
|
+
const chargeCurrency = config.charge_currency;
|
|
3785
|
+
if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
|
|
3786
|
+
value,
|
|
3787
|
+
orderCurrency
|
|
3788
|
+
});
|
|
3789
|
+
const exchange = record(config.exchange_rate);
|
|
3790
|
+
let rate = typeof exchange.rate === "number" ? exchange.rate : null;
|
|
3791
|
+
if (exchange.source === "provider") {
|
|
3792
|
+
const input = {
|
|
3793
|
+
from: orderCurrency,
|
|
3794
|
+
to: chargeCurrency
|
|
3795
|
+
};
|
|
3796
|
+
rate = (await server.call("exchange_rate", input)).rate;
|
|
3797
|
+
}
|
|
3798
|
+
return toChargeAmount({
|
|
3799
|
+
value,
|
|
3800
|
+
orderCurrency,
|
|
3801
|
+
chargeCurrency,
|
|
3802
|
+
rate
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
async buildContext(payment, amount, attemptReference, signal) {
|
|
3806
|
+
const order = this.deps.getOrder();
|
|
3807
|
+
const server = this.server(payment, attemptReference);
|
|
3808
|
+
const conversion = await this.chargeAmount(payment, amount, server);
|
|
3809
|
+
const config = record(payment.config);
|
|
3810
|
+
const methodConfig = record(payment.method.config);
|
|
3811
|
+
const share = order.totals.total > 0 ? amount / order.totals.total : 1;
|
|
3812
|
+
const decimals = currencyDecimals(order.totals.currency);
|
|
3813
|
+
const taxes = taxBreakdown(order.lines, {
|
|
3814
|
+
share,
|
|
3815
|
+
decimals
|
|
3816
|
+
});
|
|
3817
|
+
const info = order.customer.customer_info;
|
|
3818
|
+
const customer = {
|
|
3819
|
+
document: info.document_number ?? null,
|
|
3820
|
+
documentType: info.document_type ?? null,
|
|
3821
|
+
phone: info.phone ?? null
|
|
3822
|
+
};
|
|
3823
|
+
const environment = resolveProviderEnvironment({
|
|
3824
|
+
isProduction: this.options.isProduction ?? false,
|
|
3825
|
+
paymentConfig: config
|
|
3826
|
+
});
|
|
3827
|
+
const policy = resolvePaymentPolicy({
|
|
3828
|
+
methodConfig,
|
|
3829
|
+
paymentConfig: config
|
|
3830
|
+
});
|
|
3831
|
+
const prefix = `payments:${payment.id}`;
|
|
3832
|
+
const storage = scopedPaymentStorage(this.storage, prefix);
|
|
3833
|
+
const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
|
|
3834
|
+
const totals = order.totals;
|
|
3835
|
+
const o1 = {
|
|
3836
|
+
id: order.id,
|
|
3837
|
+
currency: totals.currency,
|
|
3838
|
+
totals
|
|
3839
|
+
};
|
|
3840
|
+
const b1 = {
|
|
3841
|
+
paymentId: payment.id,
|
|
3842
|
+
order: o1,
|
|
3843
|
+
customer
|
|
3844
|
+
};
|
|
3845
|
+
const b2 = {
|
|
3846
|
+
amount: conversion.amount,
|
|
3847
|
+
taxes,
|
|
3848
|
+
attemptReference
|
|
3849
|
+
};
|
|
3850
|
+
const b3 = {
|
|
3851
|
+
collectAt: this.collectAt(payment),
|
|
3852
|
+
attempt: 1
|
|
3853
|
+
};
|
|
3854
|
+
const b4 = {
|
|
3855
|
+
environment,
|
|
3856
|
+
config,
|
|
3857
|
+
deviceSettings,
|
|
3858
|
+
policy
|
|
3859
|
+
};
|
|
3860
|
+
const b5 = {
|
|
3861
|
+
ui: this.ui,
|
|
3862
|
+
storage,
|
|
3863
|
+
server,
|
|
3864
|
+
signal
|
|
3865
|
+
};
|
|
3866
|
+
return {
|
|
3867
|
+
...b1,
|
|
3868
|
+
...b2,
|
|
3869
|
+
...b3,
|
|
3870
|
+
...b4,
|
|
3871
|
+
...b5
|
|
3872
|
+
};
|
|
3873
|
+
}
|
|
3874
|
+
collectAt(payment) {
|
|
3875
|
+
if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
|
|
3876
|
+
return this.options.collectAt ?? "DEVICE";
|
|
3877
|
+
}
|
|
3878
|
+
toIntent(payment, result, placedAt) {
|
|
3879
|
+
const totals = {
|
|
3880
|
+
...this.deps.getOrder().totals,
|
|
3881
|
+
total: result.amount
|
|
3882
|
+
};
|
|
3883
|
+
const resolved_at = result.status !== "PENDING" && result.status !== "IDLE" ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
|
|
3884
|
+
const message = result.error?.message;
|
|
3885
|
+
const b1 = {
|
|
3886
|
+
payment_id: payment.id,
|
|
3887
|
+
method: payment.method.method
|
|
3888
|
+
};
|
|
3889
|
+
const b2 = { provider: payment.provider.provider_id };
|
|
3890
|
+
const b3 = {
|
|
3891
|
+
collect_at: this.collectAt(payment),
|
|
3892
|
+
status: result.status
|
|
3893
|
+
};
|
|
3894
|
+
const b4 = {
|
|
3895
|
+
totals,
|
|
3896
|
+
placed_at: placedAt,
|
|
3897
|
+
resolved_at
|
|
3898
|
+
};
|
|
3899
|
+
const b5 = {
|
|
3900
|
+
metadata: { ...result.metadata },
|
|
3901
|
+
error_message: message
|
|
3902
|
+
};
|
|
3903
|
+
return {
|
|
3904
|
+
...b1,
|
|
3905
|
+
...b2,
|
|
3906
|
+
...b3,
|
|
3907
|
+
...b4,
|
|
3908
|
+
...b5
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
async recordResult(payment, result, placedAt) {
|
|
3912
|
+
const order = this.deps.getOrder();
|
|
3913
|
+
const intent = this.toIntent(payment, result, placedAt);
|
|
3914
|
+
const http = this.deps.http;
|
|
3915
|
+
const p1 = [
|
|
3916
|
+
order.id,
|
|
3917
|
+
{ intent },
|
|
3918
|
+
http
|
|
3919
|
+
];
|
|
3920
|
+
const next = await services.orders.recordPayment(...p1);
|
|
3921
|
+
this.deps.applyOrder(next);
|
|
3922
|
+
}
|
|
3923
|
+
/**
|
|
3924
|
+
* Charges `payment`: places the order with a PENDING intent if needed,
|
|
3925
|
+
* runs the adapter with the merchant policy and records every result.
|
|
3926
|
+
*/
|
|
3927
|
+
async pay(payment, options = {}) {
|
|
3928
|
+
const adapter = this.adapterFor(payment);
|
|
3929
|
+
if (!adapter) {
|
|
3930
|
+
const id = payment.id;
|
|
3931
|
+
throw new Error(`No payment adapter installed for payment ${id}`);
|
|
3932
|
+
}
|
|
3933
|
+
this.payments.set(payment.id, payment);
|
|
3934
|
+
const missing = this.missingFields(payment);
|
|
3935
|
+
if (missing.length > 0) {
|
|
3936
|
+
const keys = missing.map((field) => field.key).join(", ");
|
|
3937
|
+
const id = payment.id;
|
|
3938
|
+
throw new Error(`Payment ${id} is missing device fields: ${keys}`);
|
|
3939
|
+
}
|
|
3940
|
+
const remaining = this.remaining();
|
|
3941
|
+
const amount = Math.min(options.amount ?? remaining, remaining);
|
|
3942
|
+
if (amount <= 0) throw new Error("Order is already covered");
|
|
3943
|
+
const attemptReference = createAttemptReference();
|
|
3944
|
+
const placedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3945
|
+
const pending = {
|
|
3946
|
+
status: payment.method.method === "CASH" ? "IDLE" : "PENDING",
|
|
3947
|
+
amount: isPlaced(this.deps.getOrder()) ? amount : remaining,
|
|
3948
|
+
metadata: { attempt_reference: attemptReference }
|
|
3949
|
+
};
|
|
3950
|
+
const order = this.deps.getOrder();
|
|
3951
|
+
if (!isPlaced(order)) {
|
|
3952
|
+
const intent = this.toIntent(payment, pending, placedAt);
|
|
3953
|
+
const p1 = {
|
|
3954
|
+
id: payment.id,
|
|
3955
|
+
intents: [intent]
|
|
3956
|
+
};
|
|
3957
|
+
await this.deps.placeOrder({ payment: p1 });
|
|
3958
|
+
} else await this.recordResult(payment, pending, placedAt);
|
|
3959
|
+
const controller = new AbortController();
|
|
3960
|
+
const abort = () => controller.abort();
|
|
3961
|
+
options.signal?.addEventListener("abort", abort);
|
|
3962
|
+
try {
|
|
3963
|
+
const a1 = [
|
|
3964
|
+
payment,
|
|
3965
|
+
amount,
|
|
3966
|
+
attemptReference,
|
|
3967
|
+
controller.signal
|
|
3968
|
+
];
|
|
3969
|
+
const ctx = await this.buildContext(...a1);
|
|
3970
|
+
const onProgress = async (progress) => {
|
|
3971
|
+
await options.onProgress?.(progress);
|
|
3972
|
+
if (progress.status !== "PENDING") return;
|
|
3973
|
+
await this.recordResult(payment, progress, placedAt);
|
|
3974
|
+
};
|
|
3975
|
+
const attempt = await runPaymentAttempt(adapter, ctx, {
|
|
3976
|
+
...this.deps.runOptions,
|
|
3977
|
+
onProgress
|
|
3978
|
+
});
|
|
3979
|
+
const result = withOrderAmount(attempt, amount);
|
|
3980
|
+
if (result.status !== "IDLE") await this.recordResult(payment, result, placedAt);
|
|
3981
|
+
return result;
|
|
3982
|
+
} finally {
|
|
3983
|
+
options.signal?.removeEventListener("abort", abort);
|
|
2362
3984
|
}
|
|
2363
3985
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
3986
|
+
intentResult(intent) {
|
|
3987
|
+
const metadata = record(intent.metadata);
|
|
3988
|
+
const error = metadata.error;
|
|
3989
|
+
return {
|
|
3990
|
+
status: intent.status,
|
|
3991
|
+
amount: intent.totals.total,
|
|
3992
|
+
metadata,
|
|
3993
|
+
...error ? { error } : {}
|
|
3994
|
+
};
|
|
2373
3995
|
}
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
const
|
|
2381
|
-
if (!
|
|
2382
|
-
const
|
|
2383
|
-
const
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
3996
|
+
async contextFor(intent) {
|
|
3997
|
+
const payment = intent.payment_id ? this.payments.get(intent.payment_id) : null;
|
|
3998
|
+
if (!payment) return null;
|
|
3999
|
+
const adapter = this.adapterFor(payment);
|
|
4000
|
+
if (!adapter) return null;
|
|
4001
|
+
const result = this.intentResult(intent);
|
|
4002
|
+
const reference = result.metadata.attempt_reference;
|
|
4003
|
+
if (!reference) return null;
|
|
4004
|
+
const signal = new AbortController().signal;
|
|
4005
|
+
const a1 = [
|
|
4006
|
+
payment,
|
|
4007
|
+
intent.totals.total,
|
|
4008
|
+
reference,
|
|
4009
|
+
signal
|
|
4010
|
+
];
|
|
4011
|
+
return {
|
|
4012
|
+
payment,
|
|
4013
|
+
adapter,
|
|
4014
|
+
result,
|
|
4015
|
+
ctx: await this.buildContext(...a1),
|
|
4016
|
+
placedAt: intent.placed_at
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
/** After the order is injected: confirm PENDING `confirm_after_inject`. */
|
|
4020
|
+
async confirmAfterInject() {
|
|
4021
|
+
const intents = this.deps.getOrder().payment_intents;
|
|
4022
|
+
const results = [];
|
|
4023
|
+
for (const intent of intents) {
|
|
4024
|
+
const metadata = record(intent.metadata);
|
|
4025
|
+
if (intent.status !== "PENDING") continue;
|
|
4026
|
+
if (metadata.confirm_after_inject !== true) continue;
|
|
4027
|
+
const found = await this.contextFor(intent);
|
|
4028
|
+
const confirm = found?.adapter.confirm;
|
|
4029
|
+
if (!found || !confirm) continue;
|
|
4030
|
+
const { payment, result, ctx, placedAt } = found;
|
|
4031
|
+
const confirmed = await confirm(ctx, result).catch((error) => {
|
|
4032
|
+
const err = createPaymentError("UNKNOWN", { provider_message: error instanceof Error ? error.message : null });
|
|
4033
|
+
const m1 = {
|
|
4034
|
+
...result.metadata,
|
|
4035
|
+
error: err
|
|
4036
|
+
};
|
|
4037
|
+
return {
|
|
4038
|
+
...result,
|
|
4039
|
+
status: "FAILED",
|
|
4040
|
+
metadata: m1,
|
|
4041
|
+
error: err
|
|
4042
|
+
};
|
|
2392
4043
|
});
|
|
2393
|
-
|
|
4044
|
+
const next = {
|
|
4045
|
+
...confirmed,
|
|
4046
|
+
metadata: { ...confirmed.metadata }
|
|
4047
|
+
};
|
|
4048
|
+
delete next.metadata.confirm_after_inject;
|
|
4049
|
+
await this.recordResult(payment, next, placedAt);
|
|
4050
|
+
results.push(next);
|
|
2394
4051
|
}
|
|
2395
|
-
|
|
4052
|
+
return results;
|
|
4053
|
+
}
|
|
4054
|
+
/**
|
|
4055
|
+
* Undo charges of this order (injection failed or split payment
|
|
4056
|
+
* abandoned): cancel, refund or flag for manual reversal.
|
|
4057
|
+
*/
|
|
4058
|
+
async reverseAll() {
|
|
4059
|
+
const intents = this.deps.getOrder().payment_intents;
|
|
4060
|
+
const outcomes = [];
|
|
4061
|
+
for (const intent of intents) {
|
|
4062
|
+
const confirm = record(intent.metadata).confirm_after_inject === true;
|
|
4063
|
+
if (!(intent.status === "PAID" || confirm) || intent.status === "IDLE") continue;
|
|
4064
|
+
const found = await this.contextFor(intent);
|
|
4065
|
+
if (!found) continue;
|
|
4066
|
+
const { payment, adapter, result, ctx, placedAt } = found;
|
|
4067
|
+
const outcome = await reversePayment(adapter, ctx, result);
|
|
4068
|
+
const reversed = withOrderAmount(outcome.result, intent.totals.total);
|
|
4069
|
+
await this.recordResult(payment, reversed, placedAt);
|
|
4070
|
+
outcomes.push(outcome);
|
|
4071
|
+
}
|
|
4072
|
+
return outcomes;
|
|
4073
|
+
}
|
|
4074
|
+
/** Runs a terminal operation (init, test, closeBatch, status, extra). */
|
|
4075
|
+
async runTerminal(payment, key) {
|
|
4076
|
+
const terminal = this.adapterFor(payment)?.terminal;
|
|
4077
|
+
if (!terminal) return {
|
|
4078
|
+
ok: false,
|
|
4079
|
+
message: "No terminal operations"
|
|
4080
|
+
};
|
|
4081
|
+
const base = {
|
|
4082
|
+
init: terminal.init,
|
|
4083
|
+
test: terminal.test
|
|
4084
|
+
};
|
|
4085
|
+
const more = {
|
|
4086
|
+
closeBatch: terminal.closeBatch,
|
|
4087
|
+
status: terminal.status
|
|
4088
|
+
};
|
|
4089
|
+
const named = {
|
|
4090
|
+
...base,
|
|
4091
|
+
...more
|
|
4092
|
+
};
|
|
4093
|
+
const extra = terminal.extra?.find((item) => item.key === key)?.run;
|
|
4094
|
+
const operation = named[key] ?? extra;
|
|
4095
|
+
if (!operation) return {
|
|
4096
|
+
ok: false,
|
|
4097
|
+
message: `Unknown operation ${key}`
|
|
4098
|
+
};
|
|
4099
|
+
const signal = new AbortController().signal;
|
|
4100
|
+
const reference = createAttemptReference();
|
|
4101
|
+
const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
|
|
4102
|
+
if (!result.ok) await this.reportTerminal(payment, key, result);
|
|
4103
|
+
return result;
|
|
4104
|
+
}
|
|
4105
|
+
async reportTerminal(payment, operation, result) {
|
|
4106
|
+
const fallback = createPaymentError("UNKNOWN");
|
|
4107
|
+
const error = result.error ?? {
|
|
4108
|
+
...fallback,
|
|
4109
|
+
message: result.message ?? fallback.message
|
|
4110
|
+
};
|
|
4111
|
+
const body = {
|
|
4112
|
+
operation,
|
|
4113
|
+
device_id: this.options.deviceId ?? null,
|
|
4114
|
+
error
|
|
4115
|
+
};
|
|
4116
|
+
const report = services.payments.terminalFailure;
|
|
4117
|
+
await report(payment.id, body, this.deps.http).catch(() => void 0);
|
|
2396
4118
|
}
|
|
2397
|
-
return next;
|
|
2398
|
-
};
|
|
2399
|
-
const removeLine = (lines, lineUid) => {
|
|
2400
|
-
return removeSubtree(lines, lineUid);
|
|
2401
|
-
};
|
|
2402
|
-
const subtractLine = (lines, lineUid, quantity = 1) => {
|
|
2403
|
-
const target = lines.find((line) => line.uid === lineUid);
|
|
2404
|
-
if (!target) return lines;
|
|
2405
|
-
const nextQty = target.quantity - quantity;
|
|
2406
|
-
if (nextQty <= 0) return removeSubtree(lines, lineUid);
|
|
2407
|
-
return lines.map((line) => {
|
|
2408
|
-
if (line.uid !== lineUid) return line;
|
|
2409
|
-
return scaleLineQuantity(line, nextQty);
|
|
2410
|
-
});
|
|
2411
|
-
};
|
|
2412
|
-
/** Drop a root subtree, then add the replacement tree (may merge by fingerprint). */
|
|
2413
|
-
const replaceLine = (lines, lineUid, next) => {
|
|
2414
|
-
return addLines(removeSubtree(lines, lineUid), next);
|
|
2415
|
-
};
|
|
2416
|
-
const emptyLines = () => {
|
|
2417
|
-
return [];
|
|
2418
4119
|
};
|
|
2419
4120
|
//#endregion
|
|
2420
4121
|
//#region src/schedule/schedule.ts
|
|
@@ -3086,9 +4787,11 @@ var Order = class Order {
|
|
|
3086
4787
|
externalNotify;
|
|
3087
4788
|
http;
|
|
3088
4789
|
flush;
|
|
4790
|
+
payments;
|
|
3089
4791
|
constructor(config, notify) {
|
|
3090
4792
|
this.draft = cloneOrder(config.order);
|
|
3091
4793
|
this.synced = cloneOrder(config.order);
|
|
4794
|
+
this.rememberLogScope();
|
|
3092
4795
|
this.persisted = config.persisted;
|
|
3093
4796
|
this.http = config.http;
|
|
3094
4797
|
this.externalNotify = notify;
|
|
@@ -3112,6 +4815,16 @@ var Order = class Order {
|
|
|
3112
4815
|
fresh: config.fresh ?? false,
|
|
3113
4816
|
sleep: config.sleep
|
|
3114
4817
|
});
|
|
4818
|
+
this.payments = new PaymentSession({
|
|
4819
|
+
getOrder: () => this.draft,
|
|
4820
|
+
placeOrder: (input) => this.placeOrder(input),
|
|
4821
|
+
applyOrder: (next) => {
|
|
4822
|
+
this.draft = cloneOrder(next);
|
|
4823
|
+
this.synced = cloneOrder(next);
|
|
4824
|
+
this.notify();
|
|
4825
|
+
},
|
|
4826
|
+
http: this.http
|
|
4827
|
+
});
|
|
3115
4828
|
}
|
|
3116
4829
|
/**
|
|
3117
4830
|
* Opens a cart session: reuses an existing OPEN order for the scope, or a
|
|
@@ -3324,6 +5037,12 @@ var Order = class Order {
|
|
|
3324
5037
|
});
|
|
3325
5038
|
this.commitLines({ lines });
|
|
3326
5039
|
}
|
|
5040
|
+
rememberLogScope() {
|
|
5041
|
+
captureLogScope({
|
|
5042
|
+
...this.draft,
|
|
5043
|
+
brand_id: this.draft.brand_id ?? null
|
|
5044
|
+
});
|
|
5045
|
+
}
|
|
3327
5046
|
setScope(scope) {
|
|
3328
5047
|
if (this.deleted) return;
|
|
3329
5048
|
this.draft = {
|
|
@@ -3333,6 +5052,7 @@ var Order = class Order {
|
|
|
3333
5052
|
fulfillment_id: scope.fulfillment_id ?? this.draft.fulfillment_id,
|
|
3334
5053
|
brand_id: scope.brand_id === void 0 ? this.draft.brand_id : optionalBrandId(scope.brand_id)
|
|
3335
5054
|
};
|
|
5055
|
+
this.rememberLogScope();
|
|
3336
5056
|
this.scheduleAfterMutate();
|
|
3337
5057
|
}
|
|
3338
5058
|
/** Shallow-merges into order metadata; `null` values are kept as-is. */
|
|
@@ -3447,6 +5167,50 @@ var Order = class Order {
|
|
|
3447
5167
|
this.synced = cloneOrder(next);
|
|
3448
5168
|
this.notify();
|
|
3449
5169
|
}
|
|
5170
|
+
/** Installed `@fire-spark/payments-*` adapters (provider + method). */
|
|
5171
|
+
setPaymentAdapters(adapters) {
|
|
5172
|
+
this.payments.setAdapters(adapters);
|
|
5173
|
+
}
|
|
5174
|
+
/** Template modal/sheet the adapters use to talk to the customer. */
|
|
5175
|
+
setPaymentUI(ui) {
|
|
5176
|
+
this.payments.setUI(ui);
|
|
5177
|
+
}
|
|
5178
|
+
/** Persistent device storage (terminal keys, last batch close). */
|
|
5179
|
+
setPaymentStorage(storage) {
|
|
5180
|
+
this.payments.setStorage(storage);
|
|
5181
|
+
}
|
|
5182
|
+
/** Environment, collect point and this device's `payment_settings`. */
|
|
5183
|
+
setPaymentOptions(options) {
|
|
5184
|
+
this.payments.setOptions(options);
|
|
5185
|
+
}
|
|
5186
|
+
/** Merchant payments with an adapter installed and configured here. */
|
|
5187
|
+
getPaymentOptions(payments) {
|
|
5188
|
+
return this.payments.available(payments);
|
|
5189
|
+
}
|
|
5190
|
+
/** Device fields still missing for `payment` on this device. */
|
|
5191
|
+
getMissingPaymentFields(payment) {
|
|
5192
|
+
return this.payments.missingFields(payment);
|
|
5193
|
+
}
|
|
5194
|
+
/** Order total minus PAID (and confirm-after-inject) intents. */
|
|
5195
|
+
getRemainingAmount() {
|
|
5196
|
+
return this.payments.remaining();
|
|
5197
|
+
}
|
|
5198
|
+
/** Places the order if needed and charges `payment` (all or part). */
|
|
5199
|
+
pay(payment, options) {
|
|
5200
|
+
return this.payments.pay(payment, options);
|
|
5201
|
+
}
|
|
5202
|
+
/** Confirms intents that waited for the order injection. */
|
|
5203
|
+
confirmPaymentsAfterInject() {
|
|
5204
|
+
return this.payments.confirmAfterInject();
|
|
5205
|
+
}
|
|
5206
|
+
/** Cancels or refunds every charge of this order (or flags it manual). */
|
|
5207
|
+
reversePayments() {
|
|
5208
|
+
return this.payments.reverseAll();
|
|
5209
|
+
}
|
|
5210
|
+
/** Terminal admin operation (init, test, closeBatch, status, extra). */
|
|
5211
|
+
runPaymentTerminal(payment, key) {
|
|
5212
|
+
return this.payments.runTerminal(payment, key);
|
|
5213
|
+
}
|
|
3450
5214
|
/** Applies lines/redeem, replaying redemption steps and totals. */
|
|
3451
5215
|
commitLines(patch) {
|
|
3452
5216
|
const lines = patch.lines ?? this.draft.lines;
|
|
@@ -3746,20 +5510,27 @@ const childrenOf = (lines, parentUid) => {
|
|
|
3746
5510
|
children.sort((a, b) => a.id.localeCompare(b.id));
|
|
3747
5511
|
return children;
|
|
3748
5512
|
};
|
|
3749
|
-
const matchModifier = (parent, childId, lookup) => {
|
|
3750
|
-
for (const groupId of parent.pricing.modifier_group_ids) if (lookup.modifierGroupsById[groupId]?.modifier_ids.includes(childId)) return true;
|
|
3751
|
-
return false;
|
|
3752
|
-
};
|
|
3753
5513
|
const collectUnmatchedModifiers = (lines, lookup, parentProductId, parentUid, out) => {
|
|
3754
5514
|
const parent = lookup.productsById[parentProductId];
|
|
3755
5515
|
if (!parent) return;
|
|
3756
5516
|
for (const child of lines) {
|
|
3757
5517
|
if (child.parent_uid !== parentUid) continue;
|
|
3758
5518
|
if (child.type === "FEE") continue;
|
|
3759
|
-
|
|
5519
|
+
const childProduct = lookup.productsById[child.id];
|
|
5520
|
+
if (childProduct && isVariantBase(childProduct)) {
|
|
5521
|
+
pushLineIssue(out, child.uid, { code: "VARIANT_BASE_NOT_ORDERABLE" });
|
|
5522
|
+
continue;
|
|
5523
|
+
}
|
|
5524
|
+
const match = matchModifierLine(lookup, parent, child.id);
|
|
5525
|
+
if (!match) {
|
|
3760
5526
|
pushLineIssue(out, child.uid, { code: "MODIFIER_NOT_FOUND" });
|
|
3761
5527
|
continue;
|
|
3762
5528
|
}
|
|
5529
|
+
const base = lookup.productsById[match.modifierId];
|
|
5530
|
+
if (match.selection && childProduct && base && !resolvesFromBase(lookup, base, childProduct)) {
|
|
5531
|
+
pushLineIssue(out, child.uid, { code: "VARIANT_NOT_OFFERED" });
|
|
5532
|
+
continue;
|
|
5533
|
+
}
|
|
3763
5534
|
collectUnmatchedModifiers(lines, lookup, child.id, child.uid, out);
|
|
3764
5535
|
}
|
|
3765
5536
|
};
|
|
@@ -3969,6 +5740,11 @@ const validateProductRoot = (root, lines, lookup, locale, out) => {
|
|
|
3969
5740
|
pushLineIssue(out, root.uid, { code: "PRODUCT_NOT_FOUND" });
|
|
3970
5741
|
return;
|
|
3971
5742
|
}
|
|
5743
|
+
if (isVariantBase(product)) {
|
|
5744
|
+
pushLineIssue(out, root.uid, { code: "VARIANT_BASE_NOT_ORDERABLE" });
|
|
5745
|
+
return;
|
|
5746
|
+
}
|
|
5747
|
+
if ((product.pricing.variants ?? []).length > 0 && !isOfferedVariant(lookup, product)) pushLineIssue(out, root.uid, { code: "VARIANT_NOT_OFFERED" });
|
|
3972
5748
|
const status = product.availability.status;
|
|
3973
5749
|
if (status === "HIDDEN") pushLineIssue(out, root.uid, { code: "HIDDEN" });
|
|
3974
5750
|
if (status === "OUT_OF_STOCK") pushLineIssue(out, root.uid, { code: "OUT_OF_STOCK" });
|
|
@@ -4203,6 +5979,12 @@ const issuesForTree = (lines, rootUid, lineIssues) => {
|
|
|
4203
5979
|
}
|
|
4204
5980
|
return issues;
|
|
4205
5981
|
};
|
|
5982
|
+
const UNRECOVERABLE = /* @__PURE__ */ new Set([
|
|
5983
|
+
"PRODUCT_NOT_FOUND",
|
|
5984
|
+
"HIDDEN",
|
|
5985
|
+
"VARIANT_BASE_NOT_ORDERABLE",
|
|
5986
|
+
"VARIANT_NOT_OFFERED"
|
|
5987
|
+
]);
|
|
4206
5988
|
const collectOrderLineDiffs = (input) => {
|
|
4207
5989
|
const locale = input.locale ?? "en_us";
|
|
4208
5990
|
const roots = input.lines.filter((line) => {
|
|
@@ -4215,9 +5997,7 @@ const collectOrderLineDiffs = (input) => {
|
|
|
4215
5997
|
if (issues.length === 0) continue;
|
|
4216
5998
|
const treeUids = descendantUids(input.lines, root.uid);
|
|
4217
5999
|
const previous = input.lines.filter((line) => treeUids.has(line.uid));
|
|
4218
|
-
const missing = issues.some((issue) =>
|
|
4219
|
-
return issue.code === "PRODUCT_NOT_FOUND" || issue.code === "HIDDEN";
|
|
4220
|
-
});
|
|
6000
|
+
const missing = issues.some((issue) => UNRECOVERABLE.has(issue.code));
|
|
4221
6001
|
const product = lookup?.productsById[root.id];
|
|
4222
6002
|
if (missing || !product || !lookup) {
|
|
4223
6003
|
diffs.push({
|
|
@@ -4249,6 +6029,201 @@ const collectOrderLineDiffs = (input) => {
|
|
|
4249
6029
|
return diffs;
|
|
4250
6030
|
};
|
|
4251
6031
|
//#endregion
|
|
6032
|
+
//#region src/logs/serializeError.ts
|
|
6033
|
+
const UNKNOWN_MESSAGE = "Unknown error";
|
|
6034
|
+
const MAX_CAUSE_DEPTH = 3;
|
|
6035
|
+
const safeStringify = (value) => {
|
|
6036
|
+
try {
|
|
6037
|
+
const json = JSON.stringify(value);
|
|
6038
|
+
return json === void 0 ? String(value) : json;
|
|
6039
|
+
} catch {
|
|
6040
|
+
return String(value);
|
|
6041
|
+
}
|
|
6042
|
+
};
|
|
6043
|
+
const messageOf = (value) => {
|
|
6044
|
+
if (value instanceof Error) return value.message || value.name;
|
|
6045
|
+
if (typeof value === "string") return value;
|
|
6046
|
+
if (value && typeof value === "object" && "message" in value) {
|
|
6047
|
+
const message = value.message;
|
|
6048
|
+
if (typeof message === "string") return message;
|
|
6049
|
+
}
|
|
6050
|
+
if (value === void 0) return "";
|
|
6051
|
+
return safeStringify(value);
|
|
6052
|
+
};
|
|
6053
|
+
/** `TypeError: boom ← caused by: Error: socket closed` style chain. */
|
|
6054
|
+
const describeCause = (cause, depth = 0) => {
|
|
6055
|
+
if (cause instanceof Error) {
|
|
6056
|
+
const head = `${cause.name}: ${cause.message}`;
|
|
6057
|
+
const inner = cause.cause;
|
|
6058
|
+
if (inner === void 0 || depth >= MAX_CAUSE_DEPTH) return head;
|
|
6059
|
+
return `${head} ← caused by: ${describeCause(inner, depth + 1)}`;
|
|
6060
|
+
}
|
|
6061
|
+
return messageOf(cause);
|
|
6062
|
+
};
|
|
6063
|
+
const nameOf = (value) => {
|
|
6064
|
+
if (value instanceof Error) return value.name;
|
|
6065
|
+
if (value && typeof value === "object" && "name" in value) {
|
|
6066
|
+
const name = value.name;
|
|
6067
|
+
if (typeof name === "string") return name;
|
|
6068
|
+
}
|
|
6069
|
+
return null;
|
|
6070
|
+
};
|
|
6071
|
+
const stackOf = (value) => {
|
|
6072
|
+
if (value && typeof value === "object" && "stack" in value) {
|
|
6073
|
+
const stack = value.stack;
|
|
6074
|
+
if (typeof stack === "string" && stack) return stack;
|
|
6075
|
+
}
|
|
6076
|
+
return null;
|
|
6077
|
+
};
|
|
6078
|
+
const causeOf = (value) => {
|
|
6079
|
+
if (!value || typeof value !== "object" || !("cause" in value)) return null;
|
|
6080
|
+
const cause = value.cause;
|
|
6081
|
+
if (cause === void 0 || cause === null) return null;
|
|
6082
|
+
return describeCause(cause) || null;
|
|
6083
|
+
};
|
|
6084
|
+
/** Accepts anything a `catch` can receive. */
|
|
6085
|
+
const serializeError = (value) => {
|
|
6086
|
+
const message = truncate(messageOf(value).trim() || UNKNOWN_MESSAGE, LOG_LIMITS.message);
|
|
6087
|
+
const name = nameOf(value);
|
|
6088
|
+
const stack = stackOf(value);
|
|
6089
|
+
const cause = causeOf(value);
|
|
6090
|
+
const e1 = { name: name ? truncate(name, LOG_LIMITS.errorName) : null };
|
|
6091
|
+
const e2 = { stack: stack ? truncate(stack, LOG_LIMITS.stack) : null };
|
|
6092
|
+
const e3 = { cause: cause ? truncate(cause, LOG_LIMITS.cause) : null };
|
|
6093
|
+
return {
|
|
6094
|
+
message,
|
|
6095
|
+
error: {
|
|
6096
|
+
...e1,
|
|
6097
|
+
...e2,
|
|
6098
|
+
...e3
|
|
6099
|
+
}
|
|
6100
|
+
};
|
|
6101
|
+
};
|
|
6102
|
+
//#endregion
|
|
6103
|
+
//#region src/logs/log.ts
|
|
6104
|
+
const LOGS_PATH = "/api/storefront/v1/logs";
|
|
6105
|
+
const POS_LOGS_PATH = "/api/storefront/v1/pos/logs";
|
|
6106
|
+
const FLUSH_DELAY_MS = 500;
|
|
6107
|
+
let queue = [];
|
|
6108
|
+
let timer = null;
|
|
6109
|
+
const optionalText = (value, max) => {
|
|
6110
|
+
const trimmed = value?.trim();
|
|
6111
|
+
if (!trimmed) return null;
|
|
6112
|
+
return truncate(trimmed, max);
|
|
6113
|
+
};
|
|
6114
|
+
const buildEntry = (level, message, options, error) => {
|
|
6115
|
+
const text = typeof message === "string" ? message.trim() : "";
|
|
6116
|
+
if (!text) return null;
|
|
6117
|
+
const b1 = {
|
|
6118
|
+
level,
|
|
6119
|
+
message: truncate(text, LOG_LIMITS.message)
|
|
6120
|
+
};
|
|
6121
|
+
const b2 = { alias: optionalText(options.alias, LOG_LIMITS.alias) };
|
|
6122
|
+
const b3 = { detail: optionalText(options.detail, LOG_LIMITS.detail) };
|
|
6123
|
+
const b4 = { status: optionalText(options.status, LOG_LIMITS.status) };
|
|
6124
|
+
const b5 = {
|
|
6125
|
+
error: error ?? null,
|
|
6126
|
+
occurred_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
6127
|
+
};
|
|
6128
|
+
return {
|
|
6129
|
+
...b1,
|
|
6130
|
+
...b2,
|
|
6131
|
+
...b3,
|
|
6132
|
+
...b4,
|
|
6133
|
+
...getLogContext(),
|
|
6134
|
+
...b5
|
|
6135
|
+
};
|
|
6136
|
+
};
|
|
6137
|
+
const isAuthConfigured = () => {
|
|
6138
|
+
try {
|
|
6139
|
+
getBaseUrl();
|
|
6140
|
+
return true;
|
|
6141
|
+
} catch {
|
|
6142
|
+
return false;
|
|
6143
|
+
}
|
|
6144
|
+
};
|
|
6145
|
+
/** Browsers cap keepalive bodies at 64 KB; bigger batches send normally. */
|
|
6146
|
+
const postInit = (body) => {
|
|
6147
|
+
return {
|
|
6148
|
+
method: "POST",
|
|
6149
|
+
body,
|
|
6150
|
+
keepalive: body.length < LOG_LIMITS.keepaliveBytes
|
|
6151
|
+
};
|
|
6152
|
+
};
|
|
6153
|
+
const sendPos = async (device, logs) => {
|
|
6154
|
+
const fetchImpl = device.http.fetchImpl ?? fetch;
|
|
6155
|
+
const url = `${resolveBaseUrl(device.http.baseUrl)}${POS_LOGS_PATH}`;
|
|
6156
|
+
const { device_uid, device_secret } = device;
|
|
6157
|
+
const body = JSON.stringify({
|
|
6158
|
+
device_uid,
|
|
6159
|
+
device_secret,
|
|
6160
|
+
logs
|
|
6161
|
+
});
|
|
6162
|
+
const headers = { "Content-Type": "application/json" };
|
|
6163
|
+
return (await fetchImpl(url, {
|
|
6164
|
+
...postInit(body),
|
|
6165
|
+
headers
|
|
6166
|
+
})).ok;
|
|
6167
|
+
};
|
|
6168
|
+
const sendBatch = async (logs) => {
|
|
6169
|
+
try {
|
|
6170
|
+
const device = getPosLogDevice();
|
|
6171
|
+
if (device) return await sendPos(device, logs);
|
|
6172
|
+
if (!isAuthConfigured()) return false;
|
|
6173
|
+
await storefrontJson(LOGS_PATH, postInit(JSON.stringify({ logs })));
|
|
6174
|
+
return true;
|
|
6175
|
+
} catch {
|
|
6176
|
+
return false;
|
|
6177
|
+
}
|
|
6178
|
+
};
|
|
6179
|
+
/** Sends queued logs now; resolves once every batch has been attempted. */
|
|
6180
|
+
const flushLogs = async () => {
|
|
6181
|
+
if (timer) clearTimeout(timer);
|
|
6182
|
+
timer = null;
|
|
6183
|
+
const pending = queue;
|
|
6184
|
+
queue = [];
|
|
6185
|
+
const jobs = [];
|
|
6186
|
+
for (let i = 0; i < pending.length; i += LOG_LIMITS.batch) {
|
|
6187
|
+
const chunk = pending.slice(i, i + LOG_LIMITS.batch);
|
|
6188
|
+
const entries = chunk.map((item) => item.entry);
|
|
6189
|
+
const job = sendBatch(entries).then((sent) => {
|
|
6190
|
+
for (const item of chunk) item.resolve(sent);
|
|
6191
|
+
});
|
|
6192
|
+
jobs.push(job);
|
|
6193
|
+
}
|
|
6194
|
+
await Promise.all(jobs);
|
|
6195
|
+
};
|
|
6196
|
+
const enqueue = (entry) => {
|
|
6197
|
+
return new Promise((resolve) => {
|
|
6198
|
+
queue.push({
|
|
6199
|
+
entry,
|
|
6200
|
+
resolve
|
|
6201
|
+
});
|
|
6202
|
+
if (queue.length >= LOG_LIMITS.batch) {
|
|
6203
|
+
flushLogs();
|
|
6204
|
+
return;
|
|
6205
|
+
}
|
|
6206
|
+
if (!timer) timer = setTimeout(() => void flushLogs(), FLUSH_DELAY_MS);
|
|
6207
|
+
});
|
|
6208
|
+
};
|
|
6209
|
+
/**
|
|
6210
|
+
* Reports a log to the merchant dashboard with the SDK's current context
|
|
6211
|
+
* (channel, fulfillment, store, brand, device). Never throws; resolves to
|
|
6212
|
+
* `false` when the log could not be delivered.
|
|
6213
|
+
*/
|
|
6214
|
+
const log = (level, message, options = {}) => {
|
|
6215
|
+
const entry = buildEntry(level, message, options);
|
|
6216
|
+
if (!entry) return Promise.resolve(false);
|
|
6217
|
+
return enqueue(entry);
|
|
6218
|
+
};
|
|
6219
|
+
/** `log("error", …)` with message, name, stack and cause read from `error`. */
|
|
6220
|
+
const logError = (error, options = {}) => {
|
|
6221
|
+
const { message, error: info } = serializeError(error);
|
|
6222
|
+
const entry = buildEntry("error", message, options, info);
|
|
6223
|
+
if (!entry) return Promise.resolve(false);
|
|
6224
|
+
return enqueue(entry);
|
|
6225
|
+
};
|
|
6226
|
+
//#endregion
|
|
4252
6227
|
//#region src/rules/assert.ts
|
|
4253
6228
|
const assert = (value, message) => {
|
|
4254
6229
|
if (value) return;
|
|
@@ -4362,7 +6337,8 @@ const MULTI_CUSTOM_TYPES = [
|
|
|
4362
6337
|
"Fulfillments",
|
|
4363
6338
|
"Stores",
|
|
4364
6339
|
"Channels",
|
|
4365
|
-
"Brands"
|
|
6340
|
+
"Brands",
|
|
6341
|
+
"Payments"
|
|
4366
6342
|
];
|
|
4367
6343
|
const isMultiCustomType = (type) => {
|
|
4368
6344
|
return MULTI_CUSTOM_TYPES.includes(type);
|
|
@@ -5248,6 +7224,10 @@ var RulesEngine = class {
|
|
|
5248
7224
|
throw new Error(msg);
|
|
5249
7225
|
};
|
|
5250
7226
|
assertCustom = (condition) => {
|
|
7227
|
+
if (condition.type === "Payment" || condition.type === "Payments") {
|
|
7228
|
+
const payment = this.getDependency("payment", condition.type);
|
|
7229
|
+
return matchCustom(condition, payment.id);
|
|
7230
|
+
}
|
|
5251
7231
|
const order = this.getDependency("order", condition.type);
|
|
5252
7232
|
return assertCustom(condition, order);
|
|
5253
7233
|
};
|
|
@@ -5539,4 +7519,316 @@ const paymentMatchesClientConditions = (conditions, order, options) => {
|
|
|
5539
7519
|
return Boolean(engine.matchOrConditions(groups).match);
|
|
5540
7520
|
};
|
|
5541
7521
|
//#endregion
|
|
5542
|
-
|
|
7522
|
+
//#region src/card/card.ts
|
|
7523
|
+
const DEFAULT_GAPS = [
|
|
7524
|
+
4,
|
|
7525
|
+
8,
|
|
7526
|
+
12,
|
|
7527
|
+
16
|
|
7528
|
+
];
|
|
7529
|
+
const spec = (s) => s;
|
|
7530
|
+
/** Detection order matters: narrower prefixes first. */
|
|
7531
|
+
const CARD_BRANDS = [
|
|
7532
|
+
spec({
|
|
7533
|
+
brand: "amex",
|
|
7534
|
+
label: "American Express",
|
|
7535
|
+
pattern: /^3[47]/,
|
|
7536
|
+
lengths: [15],
|
|
7537
|
+
gaps: [4, 10],
|
|
7538
|
+
cvcLengths: [4]
|
|
7539
|
+
}),
|
|
7540
|
+
spec({
|
|
7541
|
+
brand: "diners",
|
|
7542
|
+
label: "Diners Club",
|
|
7543
|
+
pattern: /^3(?:0[0-59]|[689])/,
|
|
7544
|
+
lengths: [
|
|
7545
|
+
14,
|
|
7546
|
+
16,
|
|
7547
|
+
19
|
|
7548
|
+
],
|
|
7549
|
+
gaps: [4, 10],
|
|
7550
|
+
cvcLengths: [3]
|
|
7551
|
+
}),
|
|
7552
|
+
spec({
|
|
7553
|
+
brand: "jcb",
|
|
7554
|
+
label: "JCB",
|
|
7555
|
+
pattern: /^35(?:2[89]|[3-8])/,
|
|
7556
|
+
lengths: [
|
|
7557
|
+
16,
|
|
7558
|
+
17,
|
|
7559
|
+
18,
|
|
7560
|
+
19
|
|
7561
|
+
],
|
|
7562
|
+
gaps: DEFAULT_GAPS,
|
|
7563
|
+
cvcLengths: [3]
|
|
7564
|
+
}),
|
|
7565
|
+
spec({
|
|
7566
|
+
brand: "visa",
|
|
7567
|
+
label: "Visa",
|
|
7568
|
+
pattern: /^4/,
|
|
7569
|
+
lengths: [
|
|
7570
|
+
13,
|
|
7571
|
+
16,
|
|
7572
|
+
19
|
|
7573
|
+
],
|
|
7574
|
+
gaps: DEFAULT_GAPS,
|
|
7575
|
+
cvcLengths: [3]
|
|
7576
|
+
}),
|
|
7577
|
+
spec({
|
|
7578
|
+
brand: "mastercard",
|
|
7579
|
+
label: "Mastercard",
|
|
7580
|
+
pattern: /^(?:5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/,
|
|
7581
|
+
lengths: [16],
|
|
7582
|
+
gaps: DEFAULT_GAPS,
|
|
7583
|
+
cvcLengths: [3]
|
|
7584
|
+
}),
|
|
7585
|
+
spec({
|
|
7586
|
+
brand: "discover",
|
|
7587
|
+
label: "Discover",
|
|
7588
|
+
pattern: /^(?:6011|64[4-9]|65)/,
|
|
7589
|
+
lengths: [
|
|
7590
|
+
16,
|
|
7591
|
+
17,
|
|
7592
|
+
18,
|
|
7593
|
+
19
|
|
7594
|
+
],
|
|
7595
|
+
gaps: DEFAULT_GAPS,
|
|
7596
|
+
cvcLengths: [3]
|
|
7597
|
+
}),
|
|
7598
|
+
spec({
|
|
7599
|
+
brand: "unionpay",
|
|
7600
|
+
label: "UnionPay",
|
|
7601
|
+
pattern: /^62/,
|
|
7602
|
+
lengths: [
|
|
7603
|
+
16,
|
|
7604
|
+
17,
|
|
7605
|
+
18,
|
|
7606
|
+
19
|
|
7607
|
+
],
|
|
7608
|
+
gaps: DEFAULT_GAPS,
|
|
7609
|
+
cvcLengths: [3]
|
|
7610
|
+
})
|
|
7611
|
+
];
|
|
7612
|
+
const UNKNOWN_SPEC = {
|
|
7613
|
+
brand: "unknown",
|
|
7614
|
+
label: "",
|
|
7615
|
+
pattern: /^$/,
|
|
7616
|
+
lengths: [
|
|
7617
|
+
12,
|
|
7618
|
+
13,
|
|
7619
|
+
14,
|
|
7620
|
+
15,
|
|
7621
|
+
16,
|
|
7622
|
+
17,
|
|
7623
|
+
18,
|
|
7624
|
+
19
|
|
7625
|
+
],
|
|
7626
|
+
gaps: DEFAULT_GAPS,
|
|
7627
|
+
cvcLengths: [3, 4]
|
|
7628
|
+
};
|
|
7629
|
+
const last = (values) => values[values.length - 1] ?? 0;
|
|
7630
|
+
const cardDigits = (value) => value.replace(/\D/g, "");
|
|
7631
|
+
/** Mod-10 checksum. Expects digits only (non-digits are ignored). */
|
|
7632
|
+
const luhnCheck = (value) => {
|
|
7633
|
+
const digits = cardDigits(value);
|
|
7634
|
+
if (!digits) return false;
|
|
7635
|
+
let sum = 0;
|
|
7636
|
+
for (let i = 0; i < digits.length; i++) {
|
|
7637
|
+
let digit = Number(digits[digits.length - 1 - i]);
|
|
7638
|
+
if (i % 2 === 1) {
|
|
7639
|
+
digit *= 2;
|
|
7640
|
+
if (digit > 9) digit -= 9;
|
|
7641
|
+
}
|
|
7642
|
+
sum += digit;
|
|
7643
|
+
}
|
|
7644
|
+
return sum % 10 === 0;
|
|
7645
|
+
};
|
|
7646
|
+
const cardBrandSpec = (brand) => {
|
|
7647
|
+
return CARD_BRANDS.find((item) => item.brand === brand) ?? UNKNOWN_SPEC;
|
|
7648
|
+
};
|
|
7649
|
+
/** Brand from the typed prefix; `unknown` until the prefix is unambiguous. */
|
|
7650
|
+
const detectCardBrand = (value) => {
|
|
7651
|
+
const digits = cardDigits(value);
|
|
7652
|
+
if (!digits) return "unknown";
|
|
7653
|
+
return CARD_BRANDS.find((item) => item.pattern.test(digits))?.brand ?? "unknown";
|
|
7654
|
+
};
|
|
7655
|
+
const toPaymentCardBrand = (brand) => {
|
|
7656
|
+
if (brand === "jcb" || brand === "unionpay" || brand === "unknown") return "other";
|
|
7657
|
+
return brand;
|
|
7658
|
+
};
|
|
7659
|
+
const cardNumberMaxLength = (brand) => {
|
|
7660
|
+
return last(cardBrandSpec(brand).lengths);
|
|
7661
|
+
};
|
|
7662
|
+
/** Space positions for a PAN of `length` digits (Diners 14 → 4-6-4). */
|
|
7663
|
+
const cardNumberGaps = (brand, length) => {
|
|
7664
|
+
if (brand === "diners" && length > 14) return DEFAULT_GAPS;
|
|
7665
|
+
return cardBrandSpec(brand).gaps;
|
|
7666
|
+
};
|
|
7667
|
+
/** Digits only, capped to the brand max length, grouped with spaces. */
|
|
7668
|
+
const formatCardNumber = (value) => {
|
|
7669
|
+
const raw = cardDigits(value);
|
|
7670
|
+
const brand = detectCardBrand(raw);
|
|
7671
|
+
const digits = raw.slice(0, cardNumberMaxLength(brand));
|
|
7672
|
+
const gaps = cardNumberGaps(brand, digits.length);
|
|
7673
|
+
let out = "";
|
|
7674
|
+
for (let i = 0; i < digits.length; i++) {
|
|
7675
|
+
if (i > 0 && gaps.includes(i)) out += " ";
|
|
7676
|
+
out += digits[i];
|
|
7677
|
+
}
|
|
7678
|
+
return out;
|
|
7679
|
+
};
|
|
7680
|
+
/** Caret index in `formatted` right after its `digitCount`-th digit. */
|
|
7681
|
+
const caretAfterDigits = (formatted, digitCount) => {
|
|
7682
|
+
if (digitCount <= 0) return 0;
|
|
7683
|
+
let seen = 0;
|
|
7684
|
+
for (let i = 0; i < formatted.length; i++) {
|
|
7685
|
+
if (/\d/.test(formatted[i] ?? "")) seen += 1;
|
|
7686
|
+
if (seen === digitCount) return i + 1;
|
|
7687
|
+
}
|
|
7688
|
+
return formatted.length;
|
|
7689
|
+
};
|
|
7690
|
+
const validateCardNumber = (value) => {
|
|
7691
|
+
const digits = cardDigits(value);
|
|
7692
|
+
if (!digits) return "required";
|
|
7693
|
+
const { lengths } = cardBrandSpec(detectCardBrand(digits));
|
|
7694
|
+
if (digits.length < (lengths[0] ?? 0)) return "incomplete";
|
|
7695
|
+
if (!lengths.includes(digits.length)) return "incomplete";
|
|
7696
|
+
if (!luhnCheck(digits)) return "invalid";
|
|
7697
|
+
return null;
|
|
7698
|
+
};
|
|
7699
|
+
/** Ready to move focus on: a valid length for the brand that passes Luhn. */
|
|
7700
|
+
const isCardNumberComplete = (value) => {
|
|
7701
|
+
const digits = cardDigits(value);
|
|
7702
|
+
const brand = detectCardBrand(digits);
|
|
7703
|
+
if (brand === "unknown") return digits.length === cardNumberMaxLength(brand) && luhnCheck(digits);
|
|
7704
|
+
return validateCardNumber(digits) === null;
|
|
7705
|
+
};
|
|
7706
|
+
/** Full-length PAN that fails Luhn — safe to flag while the user types. */
|
|
7707
|
+
const isCardNumberDefinitelyInvalid = (value) => {
|
|
7708
|
+
const digits = cardDigits(value);
|
|
7709
|
+
const max = cardNumberMaxLength(detectCardBrand(digits));
|
|
7710
|
+
return digits.length === max && !luhnCheck(digits);
|
|
7711
|
+
};
|
|
7712
|
+
const SEPARATED_EXPIRY = /^(\d{1,2})\s*[/\-.\s]\s*(\d*)$/;
|
|
7713
|
+
const shortYear = (year) => {
|
|
7714
|
+
if (year.length >= 4) return year.slice(2, 4);
|
|
7715
|
+
return year.slice(0, 2);
|
|
7716
|
+
};
|
|
7717
|
+
const joinExpiry = (month, year) => {
|
|
7718
|
+
if (!year) return month;
|
|
7719
|
+
return `${month}/${year}`;
|
|
7720
|
+
};
|
|
7721
|
+
/**
|
|
7722
|
+
* `MM/YY` as the user types or pastes: `4` → `04`, `13` → `01/3`,
|
|
7723
|
+
* `12/2030` → `12/30`. The slash only appears once a year digit exists so
|
|
7724
|
+
* backspace never gets stuck.
|
|
7725
|
+
*/
|
|
7726
|
+
const formatExpiry = (value) => {
|
|
7727
|
+
const raw = value.trim();
|
|
7728
|
+
const separated = SEPARATED_EXPIRY.exec(raw);
|
|
7729
|
+
if (separated) {
|
|
7730
|
+
const [, monthPart = "", yearPart = ""] = separated;
|
|
7731
|
+
const month = monthPart.length === 1 && monthPart !== "0" ? `0${monthPart}` : monthPart;
|
|
7732
|
+
return joinExpiry(month, shortYear(yearPart));
|
|
7733
|
+
}
|
|
7734
|
+
const all = cardDigits(raw);
|
|
7735
|
+
const digits = all.length === 6 ? `${all.slice(0, 2)}${all.slice(4)}` : all;
|
|
7736
|
+
const first = digits[0];
|
|
7737
|
+
if (!first) return "";
|
|
7738
|
+
if (Number(first) > 1) return joinExpiry(`0${first}`, digits.slice(1, 3));
|
|
7739
|
+
if (digits.length === 1) return digits;
|
|
7740
|
+
const month = digits.slice(0, 2);
|
|
7741
|
+
if (Number(month) > 12) return joinExpiry(`0${first}`, digits.slice(1, 3));
|
|
7742
|
+
return joinExpiry(month, digits.slice(2, 4));
|
|
7743
|
+
};
|
|
7744
|
+
const parseExpiry = (value) => {
|
|
7745
|
+
const match = /^(\d{2})\/(\d{2})$/.exec(value.trim());
|
|
7746
|
+
if (!match) return null;
|
|
7747
|
+
return {
|
|
7748
|
+
month: Number(match[1]),
|
|
7749
|
+
year: 2e3 + Number(match[2])
|
|
7750
|
+
};
|
|
7751
|
+
};
|
|
7752
|
+
const isExpiryComplete = (value) => {
|
|
7753
|
+
return /^\d{2}\/\d{2}$/.test(value.trim());
|
|
7754
|
+
};
|
|
7755
|
+
const MAX_EXPIRY_YEARS = 20;
|
|
7756
|
+
const validateExpiry = (value, now = /* @__PURE__ */ new Date()) => {
|
|
7757
|
+
if (!value.trim()) return "required";
|
|
7758
|
+
const parsed = parseExpiry(value);
|
|
7759
|
+
if (!parsed) return "incomplete";
|
|
7760
|
+
const { month, year } = parsed;
|
|
7761
|
+
if (month < 1 || month > 12) return "invalid_month";
|
|
7762
|
+
const currentYear = now.getFullYear();
|
|
7763
|
+
const currentMonth = now.getMonth() + 1;
|
|
7764
|
+
if (year < currentYear) return "expired";
|
|
7765
|
+
if (year === currentYear && month < currentMonth) return "expired";
|
|
7766
|
+
if (year > currentYear + MAX_EXPIRY_YEARS) return "too_far";
|
|
7767
|
+
return null;
|
|
7768
|
+
};
|
|
7769
|
+
const cvcMaxLength = (brand) => {
|
|
7770
|
+
return last(cardBrandSpec(brand).cvcLengths);
|
|
7771
|
+
};
|
|
7772
|
+
const formatCvc = (value, brand) => {
|
|
7773
|
+
return cardDigits(value).slice(0, cvcMaxLength(brand));
|
|
7774
|
+
};
|
|
7775
|
+
const validateCvc = (value, brand) => {
|
|
7776
|
+
const digits = cardDigits(value);
|
|
7777
|
+
if (!digits) return "required";
|
|
7778
|
+
if (!cardBrandSpec(brand).cvcLengths.includes(digits.length)) return "incomplete";
|
|
7779
|
+
return null;
|
|
7780
|
+
};
|
|
7781
|
+
const isCvcComplete = (value, brand) => {
|
|
7782
|
+
return cardDigits(value).length === cvcMaxLength(brand);
|
|
7783
|
+
};
|
|
7784
|
+
const CARDHOLDER_CHARS = /^[\p{L}\p{M}][\p{L}\p{M}'’. -]*$/u;
|
|
7785
|
+
const CARDHOLDER_MAX = 100;
|
|
7786
|
+
const normalizeCardholderName = (value) => {
|
|
7787
|
+
return value.trim().replace(/\s+/g, " ");
|
|
7788
|
+
};
|
|
7789
|
+
const validateCardholderName = (value) => {
|
|
7790
|
+
const name = normalizeCardholderName(value);
|
|
7791
|
+
if (!name) return "required";
|
|
7792
|
+
if (!CARDHOLDER_CHARS.test(name)) return "invalid_chars";
|
|
7793
|
+
if (name.replace(/[^\p{L}]/gu, "").length < 2) return "too_short";
|
|
7794
|
+
if (name.length > CARDHOLDER_MAX) return "too_long";
|
|
7795
|
+
return null;
|
|
7796
|
+
};
|
|
7797
|
+
const previewLength = (brand) => {
|
|
7798
|
+
if (brand === "amex") return 15;
|
|
7799
|
+
if (brand === "diners") return 14;
|
|
7800
|
+
return 16;
|
|
7801
|
+
};
|
|
7802
|
+
/**
|
|
7803
|
+
* Digit slots for a card preview, grouped like the formatted number. Middle
|
|
7804
|
+
* digits are masked as `•`; first and last groups stay readable.
|
|
7805
|
+
*/
|
|
7806
|
+
const cardPreviewGroups = (value) => {
|
|
7807
|
+
const raw = cardDigits(value);
|
|
7808
|
+
const brand = detectCardBrand(raw);
|
|
7809
|
+
const digits = raw.slice(0, cardNumberMaxLength(brand));
|
|
7810
|
+
const total = Math.max(previewLength(brand), digits.length);
|
|
7811
|
+
const gaps = cardNumberGaps(brand, total);
|
|
7812
|
+
const groups = [];
|
|
7813
|
+
let group = [];
|
|
7814
|
+
for (let i = 0; i < total; i++) {
|
|
7815
|
+
if (i > 0 && gaps.includes(i)) {
|
|
7816
|
+
groups.push(group);
|
|
7817
|
+
group = [];
|
|
7818
|
+
}
|
|
7819
|
+
const digit = digits[i];
|
|
7820
|
+
const masked = i >= 4 && i < total - 4;
|
|
7821
|
+
if (digit === void 0) group.push({
|
|
7822
|
+
char: "",
|
|
7823
|
+
filled: false
|
|
7824
|
+
});
|
|
7825
|
+
else group.push({
|
|
7826
|
+
char: masked ? "•" : digit,
|
|
7827
|
+
filled: true
|
|
7828
|
+
});
|
|
7829
|
+
}
|
|
7830
|
+
groups.push(group);
|
|
7831
|
+
return groups;
|
|
7832
|
+
};
|
|
7833
|
+
//#endregion
|
|
7834
|
+
export { CARD_BRANDS, DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, cardBrandSpec, cardDigits, cardNumberGaps, cardNumberMaxLength, cardPreviewGroups, caretAfterDigits, collectOrderLineDiffs, computeStartingPrice, countCartProducts, coveredAmount, createSession, currentNodes, cvcMaxLength, defaultConditionError, detectCardBrand, flattenMenuCategories, flushLogs, formatCardNumber, formatCvc, formatExpiry, fromOrderLines, genId, genOrderId, getLogContext, getScheduleWindow, indexMenu, isCardNumberComplete, isCardNumberDefinitelyInvalid, isCheckoutFieldFilled, isCvcComplete, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isStorefrontNotFound, isVariantBase, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, parseExpiry, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, remainingAmount, removeLine, replaceLine, resolveCheckout, reversePayment, runPaymentAttempt, serializeError, services, setLineQuantity, storefrontJson, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, visitedStates };
|