@fire-spark/core 0.0.3 → 0.1.1
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 +318 -3088
- package/dist/index.js +1733 -14
- package/package.json +4 -2
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";
|
|
@@ -1966,6 +1967,105 @@ const buildCustomerSnapshot = (source) => {
|
|
|
1966
1967
|
} };
|
|
1967
1968
|
};
|
|
1968
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
|
|
1969
2069
|
//#region src/services/http.ts
|
|
1970
2070
|
var StorefrontHttpError = class extends Error {
|
|
1971
2071
|
status;
|
|
@@ -2083,6 +2183,7 @@ const preferencesQuery = (scope) => {
|
|
|
2083
2183
|
return query ? `${path}?${query}` : path;
|
|
2084
2184
|
};
|
|
2085
2185
|
const getPreferences = (scope = {}, options) => {
|
|
2186
|
+
captureLogScope(scope);
|
|
2086
2187
|
return storefrontJson(...[
|
|
2087
2188
|
preferencesQuery(scope),
|
|
2088
2189
|
{},
|
|
@@ -2171,7 +2272,7 @@ const kioskFetch = async (path, init, options) => {
|
|
|
2171
2272
|
};
|
|
2172
2273
|
return fetchImpl(kioskUrl(path, options), req);
|
|
2173
2274
|
};
|
|
2174
|
-
const readError = async (response) => {
|
|
2275
|
+
const readError$1 = async (response) => {
|
|
2175
2276
|
const text = await response.text();
|
|
2176
2277
|
try {
|
|
2177
2278
|
return JSON.parse(text);
|
|
@@ -2199,10 +2300,12 @@ const getKioskConfig = async (input, options) => {
|
|
|
2199
2300
|
};
|
|
2200
2301
|
const response = await kioskFetch(path, init, options);
|
|
2201
2302
|
if (!response.ok) {
|
|
2202
|
-
const body = await readError(response);
|
|
2303
|
+
const body = await readError$1(response);
|
|
2203
2304
|
throwKioskError(body, response.status);
|
|
2204
2305
|
}
|
|
2205
|
-
|
|
2306
|
+
const json = await response.json();
|
|
2307
|
+
captureLogDeviceConfig(json.data);
|
|
2308
|
+
return json.data;
|
|
2206
2309
|
};
|
|
2207
2310
|
const postKioskHeartbeat = async (input, options) => {
|
|
2208
2311
|
const path = "/api/storefront/v1/kiosk/devices/me";
|
|
@@ -2212,9 +2315,10 @@ const postKioskHeartbeat = async (input, options) => {
|
|
|
2212
2315
|
};
|
|
2213
2316
|
const response = await kioskFetch(path, init, options);
|
|
2214
2317
|
if (!response.ok) {
|
|
2215
|
-
const body = await readError(response);
|
|
2318
|
+
const body = await readError$1(response);
|
|
2216
2319
|
throwKioskError(body, response.status);
|
|
2217
2320
|
}
|
|
2321
|
+
captureLogDevice(input.device_uid);
|
|
2218
2322
|
return await response.json();
|
|
2219
2323
|
};
|
|
2220
2324
|
const postKioskHealthSamples = async (input, options) => {
|
|
@@ -2225,9 +2329,10 @@ const postKioskHealthSamples = async (input, options) => {
|
|
|
2225
2329
|
};
|
|
2226
2330
|
const response = await kioskFetch(path, init, options);
|
|
2227
2331
|
if (!response.ok) {
|
|
2228
|
-
const body = await readError(response);
|
|
2332
|
+
const body = await readError$1(response);
|
|
2229
2333
|
throwKioskError(body, response.status);
|
|
2230
2334
|
}
|
|
2335
|
+
captureLogDevice(input.device_uid);
|
|
2231
2336
|
return await response.json();
|
|
2232
2337
|
};
|
|
2233
2338
|
const patchKioskOverrides = async (input, options) => {
|
|
@@ -2238,7 +2343,7 @@ const patchKioskOverrides = async (input, options) => {
|
|
|
2238
2343
|
};
|
|
2239
2344
|
const response = await kioskFetch(path, init, options);
|
|
2240
2345
|
if (!response.ok) {
|
|
2241
|
-
const body = await readError(response);
|
|
2346
|
+
const body = await readError$1(response);
|
|
2242
2347
|
throwKioskError(body, response.status);
|
|
2243
2348
|
}
|
|
2244
2349
|
return (await response.json()).data;
|
|
@@ -2251,9 +2356,10 @@ const unlinkKioskSelf = async (input, options) => {
|
|
|
2251
2356
|
};
|
|
2252
2357
|
const response = await kioskFetch(path, init, options);
|
|
2253
2358
|
if (!response.ok) {
|
|
2254
|
-
const body = await readError(response);
|
|
2359
|
+
const body = await readError$1(response);
|
|
2255
2360
|
throwKioskError(body, response.status);
|
|
2256
2361
|
}
|
|
2362
|
+
clearLogDevice();
|
|
2257
2363
|
return (await response.json()).data;
|
|
2258
2364
|
};
|
|
2259
2365
|
/** 403 wrong or expired code, 429 too many tries for the current code. */
|
|
@@ -2265,7 +2371,7 @@ const verifyKioskAdminCode = async (input, options) => {
|
|
|
2265
2371
|
};
|
|
2266
2372
|
const response = await kioskFetch(path, init, options);
|
|
2267
2373
|
if (!response.ok) {
|
|
2268
|
-
const body = await readError(response);
|
|
2374
|
+
const body = await readError$1(response);
|
|
2269
2375
|
throwKioskError(body, response.status);
|
|
2270
2376
|
}
|
|
2271
2377
|
return (await response.json()).data;
|
|
@@ -2284,9 +2390,10 @@ const fetchKioskRealtimeToken = async (input, options) => {
|
|
|
2284
2390
|
};
|
|
2285
2391
|
const response = await kioskFetch(path, init, options);
|
|
2286
2392
|
if (!response.ok) {
|
|
2287
|
-
const body = await readError(response);
|
|
2393
|
+
const body = await readError$1(response);
|
|
2288
2394
|
throwKioskError(body, response.status);
|
|
2289
2395
|
}
|
|
2396
|
+
captureLogDevice(input.device_uid);
|
|
2290
2397
|
return (await response.json()).data;
|
|
2291
2398
|
};
|
|
2292
2399
|
//#endregion
|
|
@@ -2304,9 +2411,11 @@ const menuQuery = (scope, stream) => {
|
|
|
2304
2411
|
return `/api/storefront/v1/menus${query ? `?${query}` : ""}`;
|
|
2305
2412
|
};
|
|
2306
2413
|
const listMenus = (scope = {}, options) => {
|
|
2414
|
+
captureLogScope(scope);
|
|
2307
2415
|
return storefrontJson(menuQuery(scope), {}, options);
|
|
2308
2416
|
};
|
|
2309
2417
|
const listMenusStream = (scope = {}, options = {}) => {
|
|
2418
|
+
captureLogScope(scope);
|
|
2310
2419
|
const { category_limit, product_limit, ...http } = options;
|
|
2311
2420
|
return storefrontNdjson(menuQuery(scope, {
|
|
2312
2421
|
streaming: true,
|
|
@@ -2372,6 +2481,18 @@ const placeOrder = (id, body = {}, options) => {
|
|
|
2372
2481
|
options
|
|
2373
2482
|
]);
|
|
2374
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
|
+
};
|
|
2375
2496
|
const getOrderFulfillment = (id, options) => {
|
|
2376
2497
|
return storefrontJson(...[
|
|
2377
2498
|
`${orderPath(id)}/fulfillment`,
|
|
@@ -2450,6 +2571,229 @@ const listPayments = (input, options) => {
|
|
|
2450
2571
|
for (const [key, value] of Object.entries(input)) if (value) params.set(key, value);
|
|
2451
2572
|
return storefrontJson(`/api/storefront/v1/payments?${params.toString()}`, {}, options);
|
|
2452
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
|
+
};
|
|
2453
2797
|
//#endregion
|
|
2454
2798
|
//#region src/services/stores.ts
|
|
2455
2799
|
const storesPath = (suffix) => {
|
|
@@ -2512,10 +2856,15 @@ const services = {
|
|
|
2512
2856
|
reorder: reorderOrder,
|
|
2513
2857
|
validate: validateOrder,
|
|
2514
2858
|
place: placeOrder,
|
|
2859
|
+
recordPayment: recordOrderPayment,
|
|
2515
2860
|
getFulfillment: getOrderFulfillment,
|
|
2516
2861
|
listFulfillments: listOrderFulfillments
|
|
2517
2862
|
},
|
|
2518
|
-
payments: {
|
|
2863
|
+
payments: {
|
|
2864
|
+
list: listPayments,
|
|
2865
|
+
operation: callPaymentOperation,
|
|
2866
|
+
terminalFailure: reportTerminalFailure
|
|
2867
|
+
},
|
|
2519
2868
|
stores: {
|
|
2520
2869
|
list: listStores,
|
|
2521
2870
|
nearby: listNearbyStores,
|
|
@@ -2533,6 +2882,32 @@ const services = {
|
|
|
2533
2882
|
isAdminCodeLocked: isKioskAdminCodeLocked,
|
|
2534
2883
|
isDeviceNotLinked,
|
|
2535
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
|
|
2536
2911
|
}
|
|
2537
2912
|
};
|
|
2538
2913
|
const DISALLOWED = /[^A-Za-z0-9]/g;
|
|
@@ -2614,7 +2989,7 @@ const genOrderId = (storeId) => {
|
|
|
2614
2989
|
};
|
|
2615
2990
|
//#endregion
|
|
2616
2991
|
//#region src/order/retry.ts
|
|
2617
|
-
const defaultSleep = (ms) => {
|
|
2992
|
+
const defaultSleep$1 = (ms) => {
|
|
2618
2993
|
return new Promise((resolve) => {
|
|
2619
2994
|
setTimeout(resolve, ms);
|
|
2620
2995
|
});
|
|
@@ -2623,7 +2998,7 @@ const defaultSleep = (ms) => {
|
|
|
2623
2998
|
const withExponentialRetry = async (run, options = {}) => {
|
|
2624
2999
|
const extraAttempts = options.extraAttempts ?? 3;
|
|
2625
3000
|
const baseDelayMs = options.baseDelayMs ?? 100;
|
|
2626
|
-
const sleep = options.sleep ?? defaultSleep;
|
|
3001
|
+
const sleep = options.sleep ?? defaultSleep$1;
|
|
2627
3002
|
let lastError;
|
|
2628
3003
|
for (let attempt = 0; attempt <= extraAttempts; attempt += 1) try {
|
|
2629
3004
|
return await run();
|
|
@@ -2966,6 +3341,14 @@ const subtractLine = (lines, lineUid, quantity = 1) => {
|
|
|
2966
3341
|
return scaleLineQuantity(line, nextQty);
|
|
2967
3342
|
});
|
|
2968
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
|
+
};
|
|
2969
3352
|
/** Drop a root subtree, then add the replacement tree (may merge by fingerprint). */
|
|
2970
3353
|
const replaceLine = (lines, lineUid, next) => {
|
|
2971
3354
|
return addLines(removeSubtree(lines, lineUid), next);
|
|
@@ -2974,6 +3357,767 @@ const emptyLines = () => {
|
|
|
2974
3357
|
return [];
|
|
2975
3358
|
};
|
|
2976
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);
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
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
|
+
};
|
|
3995
|
+
}
|
|
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
|
+
};
|
|
4043
|
+
});
|
|
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);
|
|
4051
|
+
}
|
|
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);
|
|
4118
|
+
}
|
|
4119
|
+
};
|
|
4120
|
+
//#endregion
|
|
2977
4121
|
//#region src/schedule/schedule.ts
|
|
2978
4122
|
const DAY_OF_WEEK = [
|
|
2979
4123
|
"sunday",
|
|
@@ -3643,9 +4787,11 @@ var Order = class Order {
|
|
|
3643
4787
|
externalNotify;
|
|
3644
4788
|
http;
|
|
3645
4789
|
flush;
|
|
4790
|
+
payments;
|
|
3646
4791
|
constructor(config, notify) {
|
|
3647
4792
|
this.draft = cloneOrder(config.order);
|
|
3648
4793
|
this.synced = cloneOrder(config.order);
|
|
4794
|
+
this.rememberLogScope();
|
|
3649
4795
|
this.persisted = config.persisted;
|
|
3650
4796
|
this.http = config.http;
|
|
3651
4797
|
this.externalNotify = notify;
|
|
@@ -3669,6 +4815,16 @@ var Order = class Order {
|
|
|
3669
4815
|
fresh: config.fresh ?? false,
|
|
3670
4816
|
sleep: config.sleep
|
|
3671
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
|
+
});
|
|
3672
4828
|
}
|
|
3673
4829
|
/**
|
|
3674
4830
|
* Opens a cart session: reuses an existing OPEN order for the scope, or a
|
|
@@ -3881,6 +5037,12 @@ var Order = class Order {
|
|
|
3881
5037
|
});
|
|
3882
5038
|
this.commitLines({ lines });
|
|
3883
5039
|
}
|
|
5040
|
+
rememberLogScope() {
|
|
5041
|
+
captureLogScope({
|
|
5042
|
+
...this.draft,
|
|
5043
|
+
brand_id: this.draft.brand_id ?? null
|
|
5044
|
+
});
|
|
5045
|
+
}
|
|
3884
5046
|
setScope(scope) {
|
|
3885
5047
|
if (this.deleted) return;
|
|
3886
5048
|
this.draft = {
|
|
@@ -3890,6 +5052,7 @@ var Order = class Order {
|
|
|
3890
5052
|
fulfillment_id: scope.fulfillment_id ?? this.draft.fulfillment_id,
|
|
3891
5053
|
brand_id: scope.brand_id === void 0 ? this.draft.brand_id : optionalBrandId(scope.brand_id)
|
|
3892
5054
|
};
|
|
5055
|
+
this.rememberLogScope();
|
|
3893
5056
|
this.scheduleAfterMutate();
|
|
3894
5057
|
}
|
|
3895
5058
|
/** Shallow-merges into order metadata; `null` values are kept as-is. */
|
|
@@ -4004,6 +5167,50 @@ var Order = class Order {
|
|
|
4004
5167
|
this.synced = cloneOrder(next);
|
|
4005
5168
|
this.notify();
|
|
4006
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
|
+
}
|
|
4007
5214
|
/** Applies lines/redeem, replaying redemption steps and totals. */
|
|
4008
5215
|
commitLines(patch) {
|
|
4009
5216
|
const lines = patch.lines ?? this.draft.lines;
|
|
@@ -4822,6 +6029,201 @@ const collectOrderLineDiffs = (input) => {
|
|
|
4822
6029
|
return diffs;
|
|
4823
6030
|
};
|
|
4824
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
|
|
4825
6227
|
//#region src/rules/assert.ts
|
|
4826
6228
|
const assert = (value, message) => {
|
|
4827
6229
|
if (value) return;
|
|
@@ -4935,7 +6337,8 @@ const MULTI_CUSTOM_TYPES = [
|
|
|
4935
6337
|
"Fulfillments",
|
|
4936
6338
|
"Stores",
|
|
4937
6339
|
"Channels",
|
|
4938
|
-
"Brands"
|
|
6340
|
+
"Brands",
|
|
6341
|
+
"Payments"
|
|
4939
6342
|
];
|
|
4940
6343
|
const isMultiCustomType = (type) => {
|
|
4941
6344
|
return MULTI_CUSTOM_TYPES.includes(type);
|
|
@@ -5821,6 +7224,10 @@ var RulesEngine = class {
|
|
|
5821
7224
|
throw new Error(msg);
|
|
5822
7225
|
};
|
|
5823
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
|
+
}
|
|
5824
7231
|
const order = this.getDependency("order", condition.type);
|
|
5825
7232
|
return assertCustom(condition, order);
|
|
5826
7233
|
};
|
|
@@ -6112,4 +7519,316 @@ const paymentMatchesClientConditions = (conditions, order, options) => {
|
|
|
6112
7519
|
return Boolean(engine.matchOrConditions(groups).match);
|
|
6113
7520
|
};
|
|
6114
7521
|
//#endregion
|
|
6115
|
-
|
|
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 };
|