@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +318 -3088
  2. package/dist/index.js +1733 -14
  3. 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
- return (await response.json()).data;
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: { list: listPayments },
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
- export { DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, collectOrderLineDiffs, computeStartingPrice, countCartProducts, createSession, currentNodes, defaultConditionError, flattenMenuCategories, fromOrderLines, genId, genOrderId, getScheduleWindow, indexMenu, isCheckoutFieldFilled, isDeviceNotLinked, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isStorefrontNotFound, isVariantBase, listMenusStream, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, resolveCheckout, services, storefrontJson, toOrderLines, validateOrderAgainstMenu, visitedStates };
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 };