@fire-spark/core 0.2.0 → 0.3.0

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 +171 -20
  2. package/dist/index.js +1789 -1262
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createAttemptReference, createPaymentError, currencyDecimals, defaultReversalFor, isPaymentError, memoryPaymentStorage, missingDeviceFields, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, withTerminalLock } from "@fire-spark/payments";
1
+ import { DIGITAL_COLLECT_AT, PARTIAL_APPROVAL_ERROR, activeIntent, amountTolerance, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, isDeliveryFee, isPaymentError, isRealPaymentMethod, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, withTerminalLock } from "@fire-spark/payments";
2
2
  import advancedFormat from "dayjs/plugin/advancedFormat.js";
3
3
  import isBetween from "dayjs/plugin/isBetween.js";
4
4
  import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
@@ -2069,14 +2069,36 @@ const getPosLogDevice = () => {
2069
2069
  //#region src/services/http.ts
2070
2070
  var StorefrontHttpError = class extends Error {
2071
2071
  status;
2072
+ /** `details.code` when present, else the top-level `code`. */
2072
2073
  code;
2073
- constructor(message, status, code) {
2074
+ /** API `details` object (e.g. `{ code, payment_id, reason }`). */
2075
+ details;
2076
+ constructor(message, status, code, details = null) {
2074
2077
  super(message);
2075
2078
  this.name = "StorefrontHttpError";
2076
2079
  this.status = status;
2077
2080
  this.code = code;
2081
+ this.details = details;
2078
2082
  }
2079
2083
  };
2084
+ const parseErrorBody = (text) => {
2085
+ try {
2086
+ const json = JSON.parse(text);
2087
+ if (!json || typeof json !== "object") return null;
2088
+ return json;
2089
+ } catch {
2090
+ return null;
2091
+ }
2092
+ };
2093
+ /** Error from a failed response body; message stays the raw body text. */
2094
+ const toStorefrontHttpError = (text, status) => {
2095
+ const body = parseErrorBody(text);
2096
+ const raw = body?.details;
2097
+ const details = raw !== null && typeof raw === "object" ? raw : null;
2098
+ const detailsCode = typeof details?.code === "string" ? details.code : null;
2099
+ const bodyCode = typeof body?.code === "string" ? body.code : void 0;
2100
+ return new StorefrontHttpError(text, status, detailsCode ?? bodyCode, details);
2101
+ };
2080
2102
  const isStorefrontNotFound = (error) => {
2081
2103
  return error instanceof StorefrontHttpError && error.status === 404;
2082
2104
  };
@@ -2117,14 +2139,20 @@ const storefrontRequest = async (path, init = {}, options = {}) => {
2117
2139
  };
2118
2140
  const storefrontJson = async (path, init = {}, options = {}) => {
2119
2141
  const response = await storefrontRequest(path, init, options);
2120
- if (!response.ok) throw new StorefrontHttpError(await response.text() || response.statusText, response.status);
2142
+ if (!response.ok) {
2143
+ const body = await response.text();
2144
+ throw toStorefrontHttpError(body || response.statusText, response.status);
2145
+ }
2121
2146
  const json = await response.json();
2122
2147
  if (json.data === void 0) throw new Error("Storefront response missing data");
2123
2148
  return json.data;
2124
2149
  };
2125
2150
  const storefrontVoid = async (path, init = {}, options = {}) => {
2126
2151
  const response = await storefrontRequest(path, init, options);
2127
- if (!response.ok) throw new StorefrontHttpError(await response.text() || response.statusText, response.status);
2152
+ if (!response.ok) {
2153
+ const body = await response.text();
2154
+ throw toStorefrontHttpError(body || response.statusText, response.status);
2155
+ }
2128
2156
  };
2129
2157
  const parseNdjsonDataLine = (line) => {
2130
2158
  const trimmed = line.trim();
@@ -2156,7 +2184,10 @@ const readNdjsonLines = async function* (body) {
2156
2184
  /** Parse an NDJSON storefront body and yield each `{ data }` payload. */
2157
2185
  const storefrontNdjson = async function* (path, init = {}, options = {}) {
2158
2186
  const response = await storefrontRequest(path, init, options);
2159
- if (!response.ok) throw new StorefrontHttpError(await response.text() || response.statusText, response.status);
2187
+ if (!response.ok) {
2188
+ const body = await response.text();
2189
+ throw toStorefrontHttpError(body || response.statusText, response.status);
2190
+ }
2160
2191
  if (response.body) {
2161
2192
  for await (const line of readNdjsonLines(response.body)) {
2162
2193
  const data = parseNdjsonDataLine(line);
@@ -2481,7 +2512,7 @@ const placeOrder = (id, body = {}, options) => {
2481
2512
  options
2482
2513
  ]);
2483
2514
  };
2484
- /** Records (or updates by `metadata.attempt_reference`) one payment intent. */
2515
+ /** Records one tender attempt result (idempotent by `attempt_reference`). */
2485
2516
  const recordOrderPayment = (id, body, options) => {
2486
2517
  const init = {
2487
2518
  method: "POST",
@@ -2493,6 +2524,50 @@ const recordOrderPayment = (id, body, options) => {
2493
2524
  options
2494
2525
  ]);
2495
2526
  };
2527
+ const postJson$1 = (path, body, options) => {
2528
+ return storefrontJson(path, {
2529
+ method: "POST",
2530
+ body: JSON.stringify(body)
2531
+ }, options);
2532
+ };
2533
+ const tenderPath = (id, tenderId, action) => {
2534
+ const tender = encodeURIComponent(tenderId);
2535
+ return `${orderPath(id)}/tenders/${tender}/${action}`;
2536
+ };
2537
+ /** Creates or replaces the DRAFT intent; the server computes amounts. */
2538
+ const putPaymentIntent = (id, body, options) => {
2539
+ const init = {
2540
+ method: "PUT",
2541
+ body: JSON.stringify(body)
2542
+ };
2543
+ return storefrontJson(...[
2544
+ `${orderPath(id)}/payment-intent`,
2545
+ init,
2546
+ options
2547
+ ]);
2548
+ };
2549
+ /** Discards the DRAFT intent (`409 PAYMENT_INTENT_LOCKED` once placed). */
2550
+ const deletePaymentIntent = (id, options) => {
2551
+ return storefrontJson(...[
2552
+ `${orderPath(id)}/payment-intent`,
2553
+ { method: "DELETE" },
2554
+ options
2555
+ ]);
2556
+ };
2557
+ /** New tender with `replaces_tender_id`; only over a `FAILED` tender (D25). */
2558
+ const replaceTender = (id, tenderId, body, options) => {
2559
+ const path = tenderPath(id, tenderId, "replace");
2560
+ return postJson$1(path, body, options);
2561
+ };
2562
+ const voidTender = (id, tenderId, body, options) => {
2563
+ const path = tenderPath(id, tenderId, "void");
2564
+ return postJson$1(path, body, options);
2565
+ };
2566
+ /** Full refund without `amount` / `items`; partial by amount or units. */
2567
+ const refundTender = (id, tenderId, body, options) => {
2568
+ const path = tenderPath(id, tenderId, "refund");
2569
+ return postJson$1(path, body, options);
2570
+ };
2496
2571
  const getOrderFulfillment = (id, options) => {
2497
2572
  return storefrontJson(...[
2498
2573
  `${orderPath(id)}/fulfillment`,
@@ -2562,7 +2637,7 @@ const validateOrder = async (body, options) => {
2562
2637
  const details = parseValidationDetails(parsed);
2563
2638
  if (details) throw new OrderValidationError(details.issues, details.lines);
2564
2639
  }
2565
- throw new StorefrontHttpError(text || response.statusText, response.status);
2640
+ throw toStorefrontHttpError(text || response.statusText, response.status);
2566
2641
  };
2567
2642
  //#endregion
2568
2643
  //#region src/services/payments.ts
@@ -2857,6 +2932,11 @@ const services = {
2857
2932
  validate: validateOrder,
2858
2933
  place: placeOrder,
2859
2934
  recordPayment: recordOrderPayment,
2935
+ putPaymentIntent,
2936
+ deletePaymentIntent,
2937
+ replaceTender,
2938
+ voidTender,
2939
+ refundTender,
2860
2940
  getFulfillment: getOrderFulfillment,
2861
2941
  listFulfillments: listOrderFulfillments
2862
2942
  },
@@ -3043,21 +3123,18 @@ const createFlushController = (runtime) => {
3043
3123
  totals: order.totals,
3044
3124
  metadata: order.metadata
3045
3125
  };
3046
- const b5 = { customer: order.customer };
3047
- const b6 = { payment_intents: order.payment_intents };
3048
- const b7 = { payment_status: order.payment_status };
3049
- const b8 = { redeem: order.redeem };
3050
- const b9 = { redemption_steps: order.redemption_steps };
3126
+ const b5 = {
3127
+ customer: order.customer,
3128
+ redeem: order.redeem
3129
+ };
3130
+ const b6 = { redemption_steps: order.redemption_steps };
3051
3131
  return {
3052
3132
  ...b1,
3053
3133
  ...b2,
3054
3134
  ...b3,
3055
3135
  ...b4,
3056
3136
  ...b5,
3057
- ...b6,
3058
- ...b7,
3059
- ...b8,
3060
- ...b9
3137
+ ...b6
3061
3138
  };
3062
3139
  };
3063
3140
  const insertBody = (order) => {
@@ -3079,10 +3156,8 @@ const createFlushController = (runtime) => {
3079
3156
  totals: order.totals,
3080
3157
  metadata: order.metadata
3081
3158
  };
3082
- const b7 = { payment_intents: order.payment_intents };
3083
- const b8 = { payment_status: order.payment_status };
3084
- const b9 = { redeem: order.redeem };
3085
- const b10 = { redemption_steps: order.redemption_steps };
3159
+ const b7 = { redeem: order.redeem };
3160
+ const b8 = { redemption_steps: order.redemption_steps };
3086
3161
  return {
3087
3162
  ...b1,
3088
3163
  ...b2,
@@ -3091,9 +3166,7 @@ const createFlushController = (runtime) => {
3091
3166
  ...b5,
3092
3167
  ...b6,
3093
3168
  ...b7,
3094
- ...b8,
3095
- ...b9,
3096
- ...b10
3169
+ ...b8
3097
3170
  };
3098
3171
  };
3099
3172
  const flushNow = () => {
@@ -3357,1335 +3430,1634 @@ const emptyLines = () => {
3357
3430
  return [];
3358
3431
  };
3359
3432
  //#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;
3433
+ //#region src/order/totals.ts
3434
+ /** Sum root line group totals into order totals (Big.js). */
3435
+ const recomputeTotals = (lines, currency = "USD") => {
3436
+ let subtotal = new Big(0);
3437
+ let discountTotal = new Big(0);
3438
+ let taxTotal = new Big(0);
3439
+ let total = new Big(0);
3440
+ for (const line of lines) {
3441
+ if (line.parent_uid != null) continue;
3442
+ subtotal = subtotal.plus(line.group_subtotal);
3443
+ discountTotal = discountTotal.plus(line.group_discount);
3444
+ taxTotal = taxTotal.plus(line.group_tax_total);
3445
+ total = total.plus(line.group_total);
3446
+ }
3447
+ let taxRate = 0;
3448
+ let discountRate = 0;
3449
+ if (subtotal.gt(0)) {
3450
+ taxRate = taxTotal.div(subtotal).toNumber();
3451
+ discountRate = discountTotal.div(subtotal).toNumber();
3452
+ }
3453
+ const discount_total = discountTotal.toNumber();
3454
+ const tax_total = taxTotal.toNumber();
3455
+ const b1 = {
3456
+ currency,
3457
+ subtotal: subtotal.toNumber()
3394
3458
  };
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
3459
+ const b2 = {
3460
+ discount_rate: discountRate,
3461
+ discount_total
3406
3462
  };
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());
3463
+ const b3 = {
3464
+ tax_rate: taxRate,
3465
+ tax_total,
3466
+ total: total.toNumber()
3427
3467
  };
3428
3468
  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))
3469
+ ...b1,
3470
+ ...b2,
3471
+ ...b3
3436
3472
  };
3437
3473
  };
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
3474
+ const emptyTotals = (currency = "USD") => {
3475
+ const b1 = {
3476
+ currency,
3477
+ subtotal: 0,
3478
+ discount_rate: 0,
3479
+ discount_total: 0
3458
3480
  };
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
3481
+ const b2 = {
3482
+ tax_rate: 0,
3483
+ tax_total: 0,
3484
+ total: 0
3472
3485
  };
3473
3486
  return {
3474
- status: "FAILED",
3475
- amount: ctx.amount.value,
3476
- metadata,
3477
- error
3487
+ ...b1,
3488
+ ...b2
3478
3489
  };
3479
3490
  };
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
3491
+ //#endregion
3492
+ //#region src/order/redemptions.ts
3493
+ const ORDER_KEY = "ORDER";
3494
+ /** Points / balance paid with a REDEMPTION tender, not a discount (D16). */
3495
+ const isPaymentRedeem = (redeem) => {
3496
+ return Boolean(redeem.payment_id);
3497
+ };
3498
+ const buildPaymentRedeem = (input) => {
3499
+ const { reward_id, quantity, payment_id, amount, points } = input;
3500
+ if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
3501
+ if (points === void 0 || !(points >= 0)) throw new Error("Redeem with payment_id requires points ≥ 0");
3502
+ const redeem = {
3503
+ reward_id,
3504
+ quantity,
3505
+ payment_id,
3506
+ amount
3495
3507
  };
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
- });
3508
+ redeem.points = points;
3509
+ if (input.program !== void 0) redeem.program = input.program;
3510
+ if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
3511
+ return redeem;
3505
3512
  };
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
- }
3513
+ /** Same rules as `vOrders.redeem()`; throws on invalid input. */
3514
+ const buildRedeem = (input) => {
3515
+ const { reward_id, quantity, discount_rate, discount_amount } = input;
3516
+ const { before_taxes, expires_at } = input;
3517
+ if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
3518
+ if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
3519
+ if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
3520
+ if (input.payment_id) return buildPaymentRedeem(input);
3521
+ if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
3522
+ const rate = discount_rate ?? 0;
3523
+ if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
3524
+ if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
3525
+ const redeem = {
3526
+ reward_id,
3527
+ quantity
3528
+ };
3529
+ if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
3530
+ if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
3531
+ if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
3532
+ if (expires_at !== void 0) redeem.expires_at = expires_at;
3533
+ return redeem;
3514
3534
  };
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;
3535
+ /**
3536
+ * Takes `quantity` units of `reward_id` off the latest entries first, so the
3537
+ * earlier redeems keep their step position. Entries that reach 0 are removed.
3538
+ */
3539
+ const subtractRedeem = (items, reward_id, quantity) => {
3540
+ if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
3541
+ let pending = quantity;
3542
+ const kept = [];
3543
+ for (let i = items.length - 1; i >= 0; i--) {
3544
+ const item = items[i];
3545
+ if (pending === 0 || item.reward_id !== reward_id) {
3546
+ kept.unshift(item);
3547
+ continue;
3548
+ }
3549
+ const taken = Math.min(item.quantity, pending);
3550
+ pending -= taken;
3551
+ const rest = item.quantity - taken;
3552
+ if (rest > 0) kept.unshift({
3553
+ ...item,
3554
+ quantity: rest
3555
+ });
3527
3556
  }
3528
- const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
3529
- return failed(ctx, error, { needs_review: true });
3557
+ return kept;
3530
3558
  };
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);
3559
+ const redemptionTargetKey = (target) => {
3560
+ if (target.target === "ORDER") return ORDER_KEY;
3561
+ return `LINE:${target.target_id ?? ""}`;
3562
+ };
3563
+ /** Only root lines own redeems; child-line redeems are never applied. */
3564
+ const redeemPools = (lines, redeem) => {
3565
+ const pools = /* @__PURE__ */ new Map();
3566
+ for (const line of lines) {
3567
+ const items = line.redeem ?? [];
3568
+ if (line.parent_uid != null || items.length === 0) continue;
3569
+ const target = {
3570
+ target: "LINE",
3571
+ target_id: line.uid
3572
+ };
3573
+ const pool = items.map((item) => ({
3574
+ ...target,
3575
+ redeem: item
3576
+ }));
3577
+ pools.set(redemptionTargetKey(target), pool);
3546
3578
  }
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 }));
3579
+ const order = { target: "ORDER" };
3580
+ const pool = redeem.filter((item) => !isPaymentRedeem(item)).map((item) => ({
3581
+ ...order,
3582
+ redeem: item
3583
+ }));
3584
+ pools.set(ORDER_KEY, pool);
3585
+ return pools;
3550
3586
  };
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));
3587
+ /**
3588
+ * Pairs the k-th step of a target with the k-th redeem of that target, in
3589
+ * step order. Steps without a matching redeem are dropped; redeems without a
3590
+ * step are appended (lines first, then order).
3591
+ */
3592
+ const sequenceRedeems = (lines, redeem, steps) => {
3593
+ const pools = redeemPools(lines, redeem);
3594
+ const cursors = /* @__PURE__ */ new Map();
3595
+ const sequence = [];
3596
+ for (const step of steps) {
3597
+ const key = redemptionTargetKey(step);
3598
+ const at = cursors.get(key) ?? 0;
3599
+ const next = pools.get(key)?.[at];
3600
+ if (!next || next.redeem.reward_id !== step.reward_id) continue;
3601
+ cursors.set(key, at + 1);
3602
+ sequence.push(next);
3558
3603
  }
3604
+ for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
3605
+ return sequence;
3559
3606
  };
3560
- const toTerminalContext = (ctx) => {
3607
+ const minBig = (a, b) => {
3608
+ return a.lt(b) ? a : b;
3609
+ };
3610
+ const rateOf = (discount, base) => {
3611
+ if (base.lte(0)) return 0;
3612
+ return minBig(discount.div(base), new Big(1)).toNumber();
3613
+ };
3614
+ const snapshot = (roots, quantity) => {
3615
+ let subtotal = new Big(0);
3616
+ let total = new Big(0);
3617
+ let discount = new Big(0);
3618
+ for (const root of roots) {
3619
+ subtotal = subtotal.plus(root.subtotal);
3620
+ total = total.plus(root.total);
3621
+ discount = discount.plus(root.discount);
3622
+ }
3561
3623
  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
3624
+ value: total.toNumber(),
3625
+ quantity
3578
3626
  };
3627
+ const b2 = { discount_amount: discount.toNumber() };
3628
+ const b3 = { discount_rate: rateOf(discount, subtotal) };
3579
3629
  return {
3580
3630
  ...b1,
3581
3631
  ...b2,
3582
- ...b3,
3583
- ...b4,
3584
- ...b5,
3585
- signal: ctx.signal
3632
+ ...b3
3586
3633
  };
3587
3634
  };
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);
3635
+ const netOf = (root) => {
3636
+ const net = root.total.minus(root.tax);
3637
+ return net.gt(0) ? net : new Big(0);
3593
3638
  };
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
- });
3639
+ /** What a redeem can still discount on a root: pre-tax or with taxes. */
3640
+ const baseOf = (root, beforeTaxes) => {
3641
+ return beforeTaxes ? netOf(root) : root.total;
3632
3642
  };
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
- };
3643
+ /** Before taxes, taxes shrink with the pre-tax value they were charged on. */
3644
+ const discountRoot = (root, amount, beforeTaxes) => {
3645
+ if (amount.lte(0)) return;
3646
+ root.discount = root.discount.plus(amount);
3647
+ if (!beforeTaxes) {
3648
+ root.total = root.total.minus(amount);
3649
+ return;
3655
3650
  }
3656
- const run = () => callOperation(ctx, operation, result, clock);
3657
- const next = await withTerminalLock(ctx.paymentId, run);
3658
- return {
3659
- kind,
3660
- result: next
3661
- };
3651
+ const net = netOf(root);
3652
+ const nextNet = net.minus(amount);
3653
+ root.tax = root.tax.times(nextNet).div(net);
3654
+ root.total = nextNet.plus(root.tax);
3662
3655
  };
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)
3656
+ /** Splits an order discount across roots by what each can still discount. */
3657
+ const discountOrder = (roots, amount, beforeTaxes) => {
3658
+ const bases = roots.map((root) => baseOf(root, beforeTaxes));
3659
+ const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
3660
+ if (sum.lte(0) || amount.lte(0)) return;
3661
+ let last = -1;
3662
+ bases.forEach((base, index) => {
3663
+ if (base.gt(0)) last = index;
3664
+ });
3665
+ let rest = amount;
3666
+ roots.forEach((root, index) => {
3667
+ const base = bases[index];
3668
+ if (base.lte(0)) return;
3669
+ const share = amount.times(base).div(sum);
3670
+ const part = minBig(index === last ? rest : share, base);
3671
+ rest = rest.minus(part);
3672
+ discountRoot(root, part, beforeTaxes);
3673
+ });
3671
3674
  };
3672
- const adapterKey = (provider, method) => {
3673
- return `${provider}:${method}`;
3674
- };
3675
- const record = (value) => {
3676
- if (!value || typeof value !== "object") return {};
3677
- return value;
3675
+ /**
3676
+ * Rate compounds once per reward unit over the remaining value; amount is
3677
+ * per unit. With both, the rate discount is capped at `amount × quantity`.
3678
+ */
3679
+ const redeemDiscount = (redeem, remaining) => {
3680
+ const { quantity, discount_rate, discount_amount } = redeem;
3681
+ let cap = null;
3682
+ if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
3683
+ if (discount_rate === void 0) return cap ?? new Big(0);
3684
+ const kept = new Big(1).minus(discount_rate).pow(quantity);
3685
+ const byRate = remaining.times(new Big(1).minus(kept));
3686
+ return cap ? minBig(byRate, cap) : byRate;
3678
3687
  };
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);
3688
+ /**
3689
+ * Root group money rebuilt from the line itself plus its children, i.e. the
3690
+ * menu price before any redemption. Child lines are never discounted.
3691
+ */
3692
+ const stripRedemptionLines = (lines) => {
3693
+ const children = /* @__PURE__ */ new Map();
3694
+ for (const line of lines) {
3695
+ if (line.parent_uid == null) continue;
3696
+ const list = children.get(line.parent_uid) ?? [];
3697
+ list.push(line);
3698
+ children.set(line.parent_uid, list);
3699
+ }
3700
+ return lines.map((line) => {
3701
+ if (line.parent_uid != null) return line;
3702
+ const qty = new Big(line.quantity);
3703
+ let sub = new Big(0);
3704
+ let disc = new Big(0);
3705
+ let tax = new Big(0);
3706
+ let tot = new Big(0);
3707
+ for (const child of children.get(line.uid) ?? []) {
3708
+ sub = sub.plus(child.group_subtotal);
3709
+ disc = disc.plus(child.group_discount);
3710
+ tax = tax.plus(child.group_tax_total);
3711
+ tot = tot.plus(child.group_total);
3712
+ }
3713
+ const gSub = new Big(line.subtotal).plus(sub.times(qty));
3714
+ const gDisc = new Big(line.discount_total).plus(disc.times(qty));
3715
+ const gTax = new Big(line.tax_total).plus(tax.times(qty));
3716
+ const gTot = new Big(line.total).plus(tot.times(qty));
3717
+ const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
3718
+ const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
3719
+ const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
3720
+ const u4 = { unit_group_total: gTot.div(qty).toNumber() };
3721
+ const g1 = { group_subtotal: gSub.toNumber() };
3722
+ const g2 = { group_discount: gDisc.toNumber() };
3723
+ const g3 = { group_tax_total: gTax.toNumber() };
3724
+ const g4 = { group_total: gTot.toNumber() };
3725
+ const p1 = {
3726
+ ...u1,
3727
+ ...u2,
3728
+ ...u3,
3729
+ ...u4
3730
+ };
3731
+ return {
3732
+ ...line,
3733
+ ...p1,
3734
+ ...g1,
3735
+ ...g2,
3736
+ ...g3,
3737
+ ...g4
3738
+ };
3739
+ });
3687
3740
  };
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);
3741
+ const withRootMoney = (line, money) => {
3742
+ const qty = new Big(line.quantity);
3743
+ const discount = new Big(line.group_discount).plus(money.discount);
3744
+ const u1 = { unit_group_discount: discount.div(qty).toNumber() };
3745
+ const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
3746
+ const u3 = { unit_group_total: money.total.div(qty).toNumber() };
3747
+ const g1 = { group_discount: discount.toNumber() };
3748
+ const g2 = { group_tax_total: money.tax.toNumber() };
3749
+ const g3 = { group_total: money.total.toNumber() };
3750
+ return {
3751
+ ...line,
3752
+ ...u1,
3753
+ ...u2,
3754
+ ...u3,
3755
+ ...g1,
3756
+ ...g2,
3757
+ ...g3
3758
+ };
3692
3759
  };
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;
3760
+ /**
3761
+ * Replays every redeem in persisted step order over the root lines' group
3762
+ * money. Line redeems discount their root; order redeems are prorated over
3763
+ * every root. A discount never exceeds what is left on its target.
3764
+ */
3765
+ const replayRedemptions = (lines, redeem, steps) => {
3766
+ const base = stripRedemptionLines(lines);
3767
+ const sequence = sequenceRedeems(base, redeem, steps);
3768
+ const moneyByUid = /* @__PURE__ */ new Map();
3769
+ for (const line of base) {
3770
+ if (line.parent_uid != null) continue;
3771
+ const b1 = { quantity: line.quantity };
3772
+ const b2 = { subtotal: new Big(line.group_subtotal) };
3773
+ const b3 = { tax: new Big(line.group_tax_total) };
3774
+ const b4 = {
3775
+ total: new Big(line.group_total),
3776
+ discount: new Big(0)
3777
+ };
3778
+ moneyByUid.set(line.uid, {
3779
+ ...b1,
3780
+ ...b2,
3781
+ ...b3,
3782
+ ...b4
3783
+ });
3784
+ }
3785
+ const roots = [...moneyByUid.values()];
3786
+ const redemption_steps = sequence.map((item) => {
3787
+ const id = item.target_id;
3788
+ const root = id ? moneyByUid.get(id) : void 0;
3789
+ const beforeTaxes = item.redeem.before_taxes ?? true;
3790
+ const before = snapshot(roots, 1);
3791
+ const targetBefore = root ? snapshot([root], root.quantity) : void 0;
3792
+ let left = new Big(0);
3793
+ if (root) left = baseOf(root, beforeTaxes);
3794
+ else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
3795
+ const raw = redeemDiscount(item.redeem, left);
3796
+ const discount = minBig(raw, left);
3797
+ if (root) discountRoot(root, discount, beforeTaxes);
3798
+ else discountOrder(roots, discount, beforeTaxes);
3799
+ const action = discount.gt(0) ? "UPDATE" : "READ";
3800
+ const { reward_id, quantity } = item.redeem;
3801
+ const b1 = {
3802
+ reward_id,
3803
+ reward_quantity: quantity,
3804
+ target: item.target
3805
+ };
3806
+ const b2 = id ? { target_id: id } : {};
3807
+ const b3 = {
3808
+ action,
3809
+ discount_amount: discount.toNumber()
3810
+ };
3811
+ const b4 = {
3812
+ before,
3813
+ after: snapshot(roots, 1)
3814
+ };
3815
+ let b5 = {};
3816
+ if (root) b5 = {
3817
+ target_before: targetBefore,
3818
+ target_after: snapshot([root], root.quantity)
3819
+ };
3820
+ return {
3821
+ ...b1,
3822
+ ...b2,
3823
+ ...b3,
3824
+ ...b4,
3825
+ ...b5
3826
+ };
3827
+ });
3697
3828
  return {
3698
- ...result,
3699
- amount
3829
+ lines: base.map((line) => {
3830
+ const money = moneyByUid.get(line.uid);
3831
+ if (!money || money.discount.eq(0)) return line;
3832
+ return withRootMoney(line, money);
3833
+ }),
3834
+ redemption_steps
3700
3835
  };
3701
3836
  };
3702
- const toFailure = (error) => {
3703
- const message = error instanceof Error ? error.message : "Terminal failed";
3837
+ /** Discounted lines, steps and totals, keeping step order. */
3838
+ const recomputeRedemptions = (input) => {
3839
+ const { redeem, currency } = input;
3840
+ const steps = input.redemption_steps;
3841
+ const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
3704
3842
  return {
3705
- ok: false,
3706
- message,
3707
- error: createPaymentError("UNKNOWN", { provider_message: message })
3843
+ lines,
3844
+ redemption_steps,
3845
+ totals: recomputeTotals(lines, currency)
3708
3846
  };
3709
3847
  };
3848
+ //#endregion
3849
+ //#region src/order/tenders.ts
3850
+ /** Catalog provider of `REDEMPTION` payments (§3.3). */
3851
+ const DEFAULT_REDEMPTION_PROVIDER = "firespark";
3710
3852
  /**
3711
- * Payment state of one order: adapters, device settings and the attempt
3712
- * orchestration. The app only provides adapters, UI and storage.
3853
+ * Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
3854
+ * `DOOR` and `OTHER` are in person: the POS, an operator or the courier
3855
+ * collects them (§12).
3713
3856
  */
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
3857
+ const isDeviceCharged = (tender) => {
3858
+ return DIGITAL_COLLECT_AT.includes(tender.collect_at);
3859
+ };
3860
+ /** Most a REDEMPTION can cover: order total − tip − delivery (D18). */
3861
+ const redemptionCap = (order) => {
3862
+ const primaryOnly = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
3863
+ return isTipFee(line) || isDeliveryFee(line) ? sum + line.group_total : sum;
3864
+ }, 0);
3865
+ const decimals = currencyDecimals(order.totals.currency);
3866
+ return Math.max(roundTo(order.totals.total - primaryOnly, decimals), 0);
3867
+ };
3868
+ /**
3869
+ * Trims payment redeems in application order so their sum fits the cap;
3870
+ * points shrink in proportion. Discount redeems are left untouched.
3871
+ */
3872
+ const clampPaymentRedeems = (redeem, cap, currency) => {
3873
+ const decimals = currencyDecimals(currency);
3874
+ let left = cap;
3875
+ return redeem.flatMap((item) => {
3876
+ if (!isPaymentRedeem(item)) return [item];
3877
+ const amount = item.amount ?? 0;
3878
+ const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
3879
+ left = roundTo(left - fits, decimals);
3880
+ if (fits <= 0) return [];
3881
+ if (fits + amountTolerance(currency) >= amount) return [item];
3882
+ const ratio = amount > 0 ? fits / amount : 0;
3883
+ const points = roundTo((item.points ?? 0) * ratio, 0);
3884
+ return [{
3885
+ ...item,
3886
+ amount: fits,
3887
+ points
3888
+ }];
3889
+ });
3890
+ };
3891
+ /** One REDEMPTION tender per payment redeem of the order (D15, D19). */
3892
+ const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
3893
+ return redeem.filter(isPaymentRedeem).map((item) => {
3894
+ const payment_id = item.payment_id;
3895
+ const fallback = DEFAULT_REDEMPTION_PROVIDER;
3896
+ const provider = providers.get(payment_id) ?? fallback;
3897
+ const program = item.program ?? item.reward_id;
3898
+ const d1 = {
3899
+ method: "REDEMPTION",
3900
+ reward_id: item.reward_id
3738
3901
  };
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
3902
+ const d2 = {
3903
+ program,
3904
+ points: item.points ?? 0
3844
3905
  };
3845
- const b2 = {
3846
- amount: conversion.amount,
3847
- taxes,
3848
- attemptReference
3849
- };
3850
- const b3 = {
3851
- collectAt: this.collectAt(payment),
3852
- attempt: 1
3906
+ const details = {
3907
+ ...d1,
3908
+ ...d2,
3909
+ points_value: item.amount ?? 0
3853
3910
  };
3854
- const b4 = {
3855
- environment,
3856
- config,
3857
- deviceSettings,
3858
- policy
3911
+ const t1 = {
3912
+ method: "REDEMPTION",
3913
+ provider,
3914
+ payment_id
3859
3915
  };
3860
- const b5 = {
3861
- ui: this.ui,
3862
- storage,
3863
- server,
3864
- signal
3916
+ const t2 = {
3917
+ collect_at: "OTHER",
3918
+ allocation: null,
3919
+ details
3865
3920
  };
3866
3921
  return {
3867
- ...b1,
3868
- ...b2,
3869
- ...b3,
3870
- ...b4,
3871
- ...b5
3922
+ ...t1,
3923
+ ...t2
3872
3924
  };
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
4121
- //#region src/schedule/schedule.ts
4122
- const DAY_OF_WEEK = [
4123
- "sunday",
4124
- "monday",
4125
- "tuesday",
4126
- "wednesday",
4127
- "thursday",
4128
- "friday",
4129
- "saturday"
4130
- ];
4131
- const timeToMinutes = (time) => {
4132
- const [hours = "0", minutes = "0"] = time.split(":");
4133
- return Number(hours) * 60 + Number(minutes);
4134
- };
4135
- const hasSpecificDates = (period) => {
4136
- return period.start_date != null && period.end_date != null;
4137
- };
4138
- const periodCoversDate = (period, now) => {
4139
- if (!hasSpecificDates(period)) return false;
4140
- const nowMs = now.getTime();
4141
- const startMs = Date.parse(period.start_date);
4142
- const endMs = Date.parse(period.end_date);
4143
- if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4144
- return startMs <= nowMs && nowMs <= endMs;
4145
- };
4146
- const timeMatches = (period, now) => {
4147
- if (!period.start_time || !period.end_time) return false;
4148
- const nowMinutes = now.getHours() * 60 + now.getMinutes();
4149
- const start = timeToMinutes(period.start_time);
4150
- const end = timeToMinutes(period.end_time);
4151
- return nowMinutes >= start && nowMinutes < end;
4152
- };
4153
- const isClosedAt = (period, now) => {
4154
- if (!period.closed) return false;
4155
- if (!period.start_time || !period.end_time) return true;
4156
- return timeMatches(period, now);
4157
- };
4158
- const isOpenAt = (period, now) => {
4159
- if (period.closed) return false;
4160
- return timeMatches(period, now);
4161
- };
4162
- const isPeriodsActiveAt = (periods, now) => {
4163
- return getOpenPeriodAt(periods, now) != null;
4164
- };
4165
- const getOpenPeriodAt = (periods, now) => {
4166
- if (periods.length === 0) return null;
4167
- const coveringSpecific = periods.filter((period) => {
4168
- return periodCoversDate(period, now);
4169
- });
4170
- const fallback = periods.filter((period) => !hasSpecificDates(period));
4171
- const specificOpens = coveringSpecific.filter((period) => !period.closed);
4172
- if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
4173
- if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
4174
- if (fallback.some((period) => isClosedAt(period, now))) return null;
4175
- return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
4176
- };
4177
- const atTimeOnDate = (day, time) => {
4178
- const [hours = "0", minutes = "0"] = time.split(":");
4179
- const next = new Date(day);
4180
- next.setHours(Number(hours), Number(minutes), 0, 0);
4181
- return next;
4182
- };
4183
- const isWithinPeriodDates = (period, instantMs) => {
4184
- if (!hasSpecificDates(period)) return true;
4185
- const startMs = Date.parse(period.start_date);
4186
- const endMs = Date.parse(period.end_date);
4187
- if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4188
- return startMs <= instantMs && instantMs <= endMs;
4189
- };
4190
- const dayPeriods = (schedule, instant) => {
4191
- const day = DAY_OF_WEEK[instant.getDay()];
4192
- if (!day) return [];
4193
- return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
4194
- };
4195
- const getActiveUntil = (periods, now) => {
4196
- const open = getOpenPeriodAt(periods, now);
4197
- if (!open?.end_time) return null;
4198
- let untilMs = atTimeOnDate(now, open.end_time).getTime();
4199
- const openIsSpecific = hasSpecificDates(open);
4200
- for (const period of periods) {
4201
- if (!period.closed || !period.start_time) continue;
4202
- const closeMs = atTimeOnDate(now, period.start_time).getTime();
4203
- if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
4204
- if (!isWithinPeriodDates(period, closeMs)) continue;
4205
- if (!hasSpecificDates(period) && openIsSpecific) continue;
4206
- untilMs = closeMs;
4207
- }
4208
- return new Date(untilMs);
4209
- };
4210
- const getNextStart = (schedule, now) => {
4211
- const nowMs = now.getTime();
4212
- let nextStartMs = null;
4213
- const consider = (candidate, periods) => {
4214
- const startMs = candidate.getTime();
4215
- if (startMs <= nowMs) return;
4216
- if (!isPeriodsActiveAt(periods, candidate)) return;
4217
- if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
4218
- };
4219
- for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
4220
- const candidateDate = new Date(now);
4221
- candidateDate.setDate(candidateDate.getDate() + dayOffset);
4222
- candidateDate.setSeconds(0, 0);
4223
- const periods = dayPeriods(schedule, candidateDate);
4224
- if (periods.length === 0) continue;
4225
- for (const period of periods) {
4226
- if (period.closed || !period.start_time) continue;
4227
- const start = atTimeOnDate(candidateDate, period.start_time);
4228
- if (!isWithinPeriodDates(period, start.getTime())) continue;
4229
- consider(start, periods);
4230
- }
4231
- for (const period of periods) {
4232
- if (!period.closed || !period.end_time) continue;
4233
- const reopen = atTimeOnDate(candidateDate, period.end_time);
4234
- if (!isWithinPeriodDates(period, reopen.getTime())) continue;
4235
- consider(reopen, periods);
4236
- }
4237
- }
4238
- return nextStartMs === null ? null : new Date(nextStartMs);
3925
+ });
4239
3926
  };
4240
- /** Whether `schedule` is in hours now, and until / next open. */
4241
- const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
4242
- if (schedule == null) return {
4243
- active: true,
4244
- until: null
3927
+ /** The one real tender of the simple flow (`allocation: null`). */
3928
+ const simpleTenderInput = (payment) => {
3929
+ if (!payment.type || !payment.provider) return null;
3930
+ if (!isRealPaymentMethod(payment.type)) return null;
3931
+ const collect_at = payment.collect_at ?? "DEVICE";
3932
+ const t1 = {
3933
+ method: payment.type,
3934
+ provider: payment.provider
4245
3935
  };
4246
- const periods = dayPeriods(schedule, now);
4247
- if (isPeriodsActiveAt(periods, now)) return {
4248
- active: true,
4249
- until: getActiveUntil(periods, now)
3936
+ const t2 = {
3937
+ payment_id: payment.id,
3938
+ collect_at,
3939
+ allocation: null
4250
3940
  };
4251
3941
  return {
4252
- active: false,
4253
- next: getNextStart(schedule, now)
3942
+ ...t1,
3943
+ ...t2
4254
3944
  };
4255
3945
  };
4256
- const isOnSchedule = (schedule, now) => {
4257
- return getScheduleWindow(schedule, now).active;
3946
+ /**
3947
+ * Charge order of §6.1: deferred capture (authorize only), immediate
3948
+ * capture reversible with cancel/refund, manual reversal only, then cash /
3949
+ * IDLE last.
3950
+ */
3951
+ const chargeRank = (tender, adapter) => {
3952
+ if (tender.method === "CASH") return 3;
3953
+ if (adapter.capabilities?.deferredCapture) return 0;
3954
+ if (adapter.cancel || adapter.refund) return 1;
3955
+ return 2;
4258
3956
  };
4259
3957
  //#endregion
4260
- //#region src/order/recommendations.ts
4261
- const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
4262
- const linkedRecommendationIds = (pricing) => {
4263
- return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
3958
+ //#region src/order/payments/orchestrator.ts
3959
+ const AMBIGUOUS_QUERIES = 3;
3960
+ const defaultSleep = (ms, signal) => {
3961
+ return new Promise((resolve) => {
3962
+ const timer = setTimeout(resolve, ms);
3963
+ signal?.addEventListener("abort", () => {
3964
+ clearTimeout(timer);
3965
+ resolve();
3966
+ });
3967
+ });
4264
3968
  };
4265
- const collectRecommendations = (config) => {
4266
- const { lines, lookup, quantity, types } = config;
4267
- if (quantity <= 0) return [];
4268
- const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
4269
- const roots = lines.filter((line) => line.parent_uid == null);
4270
- const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
4271
- const cartKeys = new Set(fingerprints);
4272
- const seenTargetIds = /* @__PURE__ */ new Set();
4273
- const products = [];
4274
- for (const root of roots) {
4275
- const source = lookup.productsById[root.id];
4276
- if (!source) continue;
4277
- for (const recommendationId of linkedRecommendationIds(source.pricing)) {
4278
- const recommendation = lookup.recommendationsById[recommendationId];
4279
- if (!recommendation || !typeSet.has(recommendation.type)) continue;
4280
- const targetId = recommendation.target_product_id;
4281
- if (seenTargetIds.has(targetId)) continue;
4282
- if (cartKeys.has(bareProductFingerprint(targetId))) continue;
4283
- const target = lookup.productsById[targetId];
4284
- if (!target || target.status !== "ACTIVE") continue;
4285
- if (!isOnSchedule(target.availability.schedules)) continue;
4286
- seenTargetIds.add(targetId);
4287
- products.push(target);
4288
- if (products.length >= quantity) return products;
4289
- }
4290
- }
4291
- return products;
3969
+ const defaultClock = {
3970
+ now: () => Date.now(),
3971
+ sleep: defaultSleep
4292
3972
  };
4293
- //#endregion
4294
- //#region src/order/totals.ts
4295
- /** Sum root line group totals into order totals (Big.js). */
4296
- const recomputeTotals = (lines, currency = "USD") => {
4297
- let subtotal = new Big(0);
4298
- let discountTotal = new Big(0);
4299
- let taxTotal = new Big(0);
4300
- let total = new Big(0);
4301
- for (const line of lines) {
4302
- if (line.parent_uid != null) continue;
4303
- subtotal = subtotal.plus(line.group_subtotal);
4304
- discountTotal = discountTotal.plus(line.group_discount);
4305
- taxTotal = taxTotal.plus(line.group_tax_total);
4306
- total = total.plus(line.group_total);
4307
- }
4308
- let taxRate = 0;
4309
- let discountRate = 0;
4310
- if (subtotal.gt(0)) {
4311
- taxRate = taxTotal.div(subtotal).toNumber();
4312
- discountRate = discountTotal.div(subtotal).toNumber();
4313
- }
4314
- const discount_total = discountTotal.toNumber();
4315
- const tax_total = taxTotal.toNumber();
4316
- const b1 = {
4317
- currency,
4318
- subtotal: subtotal.toNumber()
4319
- };
4320
- const b2 = {
4321
- discount_rate: discountRate,
4322
- discount_total
3973
+ var TimeoutSignal = class extends Error {};
3974
+ /**
3975
+ * Provider deadline that pauses while the customer is answering `ctx.ui`;
3976
+ * user waits run on `interaction_timeout_ms` instead.
3977
+ */
3978
+ const createDeadline = (timeoutMs, onExpire) => {
3979
+ let remaining = timeoutMs;
3980
+ let startedAt = 0;
3981
+ let timer = null;
3982
+ let paused = 0;
3983
+ const run = () => {
3984
+ startedAt = Date.now();
3985
+ timer = setTimeout(onExpire, Math.max(remaining, 0));
4323
3986
  };
4324
- const b3 = {
4325
- tax_rate: taxRate,
4326
- tax_total,
4327
- total: total.toNumber()
3987
+ const stop = () => {
3988
+ if (!timer) return;
3989
+ clearTimeout(timer);
3990
+ timer = null;
3991
+ remaining -= Date.now() - startedAt;
4328
3992
  };
4329
3993
  return {
4330
- ...b1,
4331
- ...b2,
4332
- ...b3
3994
+ start: run,
3995
+ pause: () => {
3996
+ if (paused === 0) stop();
3997
+ paused += 1;
3998
+ },
3999
+ resume: () => {
4000
+ paused -= 1;
4001
+ if (paused === 0) run();
4002
+ },
4003
+ clear: stop
4333
4004
  };
4334
4005
  };
4335
- const emptyTotals = (currency = "USD") => {
4336
- const b1 = {
4337
- currency,
4338
- subtotal: 0,
4339
- discount_rate: 0,
4340
- discount_total: 0
4341
- };
4342
- const b2 = {
4343
- tax_rate: 0,
4344
- tax_total: 0,
4345
- total: 0
4006
+ const withUserTimeout = (promise, ms) => {
4007
+ return new Promise((resolve, reject) => {
4008
+ const timer = setTimeout(() => {
4009
+ reject(new PaymentUserTimeout());
4010
+ }, ms);
4011
+ promise.then((value) => {
4012
+ clearTimeout(timer);
4013
+ resolve(value);
4014
+ }, (error) => {
4015
+ clearTimeout(timer);
4016
+ reject(error);
4017
+ });
4018
+ });
4019
+ };
4020
+ var PaymentUserTimeout = class extends Error {};
4021
+ const timedUI = (ui, deadline, interactionMs) => {
4022
+ const wrap = (call) => {
4023
+ deadline.pause();
4024
+ return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
4346
4025
  };
4347
4026
  return {
4348
- ...b1,
4349
- ...b2
4027
+ message: (input) => ui.message(input),
4028
+ action: (input) => ui.action(input),
4029
+ select: (input) => {
4030
+ return wrap(() => ui.select(input));
4031
+ },
4032
+ input: (input) => wrap(() => ui.input(input)),
4033
+ confirm: (input) => wrap(() => ui.confirm(input))
4350
4034
  };
4351
4035
  };
4352
- //#endregion
4353
- //#region src/order/redemptions.ts
4354
- const ORDER_KEY = "ORDER";
4355
- /** Same rules as `vOrders.redeem()`; throws on invalid input. */
4356
- const buildRedeem = (input) => {
4357
- const { reward_id, quantity, discount_rate, discount_amount } = input;
4358
- const { before_taxes, expires_at } = input;
4359
- if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
4360
- if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
4361
- if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
4362
- const rate = discount_rate ?? 0;
4363
- if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
4364
- if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
4365
- if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
4366
- const redeem = {
4367
- reward_id,
4368
- quantity
4036
+ /** Runs one adapter call with the provider timeout and the user clock. */
4037
+ const runTimed = async (ctx, call) => {
4038
+ const controller = new AbortController();
4039
+ const parent = ctx.signal;
4040
+ const abort = () => controller.abort();
4041
+ parent.addEventListener("abort", abort);
4042
+ let expire = () => void 0;
4043
+ const expired = new Promise((_, reject) => {
4044
+ expire = () => {
4045
+ controller.abort();
4046
+ reject(new TimeoutSignal());
4047
+ };
4048
+ });
4049
+ const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
4050
+ const interaction = ctx.policy.interaction_timeout_ms;
4051
+ const ui = timedUI(ctx.ui, deadline, interaction);
4052
+ const scoped = {
4053
+ ...ctx,
4054
+ ui,
4055
+ signal: controller.signal
4369
4056
  };
4370
- if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
4371
- if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
4372
- if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
4373
- if (expires_at !== void 0) redeem.expires_at = expires_at;
4374
- return redeem;
4375
- };
4376
- /**
4377
- * Takes `quantity` units of `reward_id` off the latest entries first, so the
4378
- * earlier redeems keep their step position. Entries that reach 0 are removed.
4379
- */
4380
- const subtractRedeem = (items, reward_id, quantity) => {
4381
- if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
4382
- let pending = quantity;
4383
- const kept = [];
4384
- for (let i = items.length - 1; i >= 0; i--) {
4385
- const item = items[i];
4386
- if (pending === 0 || item.reward_id !== reward_id) {
4387
- kept.unshift(item);
4388
- continue;
4389
- }
4390
- const taken = Math.min(item.quantity, pending);
4391
- pending -= taken;
4392
- const rest = item.quantity - taken;
4393
- if (rest > 0) kept.unshift({
4394
- ...item,
4395
- quantity: rest
4396
- });
4057
+ deadline.start();
4058
+ try {
4059
+ return await Promise.race([call(scoped), expired]);
4060
+ } finally {
4061
+ deadline.clear();
4062
+ parent.removeEventListener("abort", abort);
4397
4063
  }
4398
- return kept;
4399
4064
  };
4400
- const redemptionTargetKey = (target) => {
4401
- if (target.target === "ORDER") return ORDER_KEY;
4402
- return `LINE:${target.target_id ?? ""}`;
4065
+ const failed = (ctx, error, extra = {}) => {
4066
+ const metadata = {
4067
+ attempt_reference: ctx.attemptReference,
4068
+ error,
4069
+ ...extra
4070
+ };
4071
+ return {
4072
+ status: "FAILED",
4073
+ amount: ctx.amount.value,
4074
+ metadata,
4075
+ error
4076
+ };
4077
+ };
4078
+ const errorOf = (ctx, error, startedAt, clock) => {
4079
+ const elapsed_ms = clock.now() - startedAt;
4080
+ const attempt = ctx.attempt;
4081
+ if (isPaymentError(error)) return {
4082
+ ...error.error,
4083
+ attempt,
4084
+ elapsed_ms
4085
+ };
4086
+ let code = "UNKNOWN";
4087
+ if (error instanceof TimeoutSignal) code = "TIMEOUT";
4088
+ if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
4089
+ const extra = {
4090
+ attempt,
4091
+ elapsed_ms,
4092
+ provider_message: error instanceof Error ? error.message : null
4093
+ };
4094
+ return createPaymentError(code, extra);
4095
+ };
4096
+ /** `start` that timed out after leaving the device is ambiguous. */
4097
+ const startError = (error) => {
4098
+ if (error.code !== "TIMEOUT") return error;
4099
+ return createPaymentError("AMBIGUOUS", {
4100
+ attempt: error.attempt,
4101
+ elapsed_ms: error.elapsed_ms
4102
+ });
4403
4103
  };
4404
- /** Only root lines own redeems; child-line redeems are never applied. */
4405
- const redeemPools = (lines, redeem) => {
4406
- const pools = /* @__PURE__ */ new Map();
4407
- for (const line of lines) {
4408
- const items = line.redeem ?? [];
4409
- if (line.parent_uid != null || items.length === 0) continue;
4410
- const target = {
4411
- target: "LINE",
4412
- target_id: line.uid
4413
- };
4414
- const pool = items.map((item) => ({
4415
- ...target,
4416
- redeem: item
4417
- }));
4418
- pools.set(redemptionTargetKey(target), pool);
4104
+ const isFinal = (result) => result.status !== "PENDING";
4105
+ const callOperation = async (ctx, operation, result, clock) => {
4106
+ const startedAt = clock.now();
4107
+ try {
4108
+ return await runTimed(ctx, (scoped) => operation(scoped, result));
4109
+ } catch (error) {
4110
+ return failed(ctx, errorOf(ctx, error, startedAt, clock));
4419
4111
  }
4420
- const order = { target: "ORDER" };
4421
- const pool = redeem.map((item) => ({
4422
- ...order,
4423
- redeem: item
4424
- }));
4425
- pools.set(ORDER_KEY, pool);
4426
- return pools;
4427
4112
  };
4428
- /**
4429
- * Pairs the k-th step of a target with the k-th redeem of that target, in
4430
- * step order. Steps without a matching redeem are dropped; redeems without a
4431
- * step are appended (lines first, then order).
4432
- */
4433
- const sequenceRedeems = (lines, redeem, steps) => {
4434
- const pools = redeemPools(lines, redeem);
4435
- const cursors = /* @__PURE__ */ new Map();
4436
- const sequence = [];
4437
- for (const step of steps) {
4438
- const key = redemptionTargetKey(step);
4439
- const at = cursors.get(key) ?? 0;
4440
- const next = pools.get(key)?.[at];
4441
- if (!next || next.redeem.reward_id !== step.reward_id) continue;
4442
- cursors.set(key, at + 1);
4443
- sequence.push(next);
4113
+ /** Query up to 3 times, cancel once, else FAILED + needs_review. */
4114
+ const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
4115
+ if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
4116
+ await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
4117
+ const next = await callOperation(ctx, adapter.query, pending, clock);
4118
+ if (next.status === "PAID") return next;
4119
+ const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
4120
+ if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
4444
4121
  }
4445
- for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
4446
- return sequence;
4447
- };
4448
- const minBig = (a, b) => {
4449
- return a.lt(b) ? a : b;
4122
+ if (adapter.cancel) {
4123
+ const cancelled = await callOperation(ctx, adapter.cancel, pending, clock);
4124
+ if (cancelled.status === "CANCELLED") return cancelled;
4125
+ }
4126
+ const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
4127
+ return failed(ctx, error, { needs_review: true });
4450
4128
  };
4451
- const rateOf = (discount, base) => {
4452
- if (base.lte(0)) return 0;
4453
- return minBig(discount.div(base), new Big(1)).toNumber();
4129
+ /** PENDING with an action: poll until paid, rejected or the QR expires. */
4130
+ const pollPending = async (adapter, ctx, pending, clock, options) => {
4131
+ if (pending.metadata.confirm_after_inject) return pending;
4132
+ if (!adapter.query) return pending;
4133
+ const expiresAt = clock.now() + ctx.policy.qr_ttl_ms;
4134
+ let current = pending;
4135
+ while (clock.now() < expiresAt) {
4136
+ if (ctx.signal.aborted) break;
4137
+ await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
4138
+ if (ctx.signal.aborted) break;
4139
+ const next = await callOperation(ctx, adapter.query, current, clock);
4140
+ if (next.status === "FAILED" && next.error?.retryable) continue;
4141
+ if (isFinal(next)) return next;
4142
+ current = next;
4143
+ await options.onProgress?.(current);
4144
+ }
4145
+ if (adapter.cancel) await callOperation(ctx, adapter.cancel, current, clock);
4146
+ const code = ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED";
4147
+ return failed(ctx, createPaymentError(code, { attempt: ctx.attempt }));
4454
4148
  };
4455
- const snapshot = (roots, quantity) => {
4456
- let subtotal = new Big(0);
4457
- let total = new Big(0);
4458
- let discount = new Big(0);
4459
- for (const root of roots) {
4460
- subtotal = subtotal.plus(root.subtotal);
4461
- total = total.plus(root.total);
4462
- discount = discount.plus(root.discount);
4149
+ const runStart = async (adapter, ctx, clock) => {
4150
+ const startedAt = clock.now();
4151
+ try {
4152
+ return await runTimed(ctx, (scoped) => adapter.start(scoped));
4153
+ } catch (error) {
4154
+ const raw = errorOf(ctx, error, startedAt, clock);
4155
+ return failed(ctx, startError(raw));
4463
4156
  }
4157
+ };
4158
+ const toTerminalContext = (ctx) => {
4464
4159
  const b1 = {
4465
- value: total.toNumber(),
4466
- quantity
4160
+ paymentId: ctx.paymentId,
4161
+ attempt: ctx.attempt
4162
+ };
4163
+ const b2 = { attemptReference: ctx.attemptReference };
4164
+ const b3 = {
4165
+ environment: ctx.environment,
4166
+ config: ctx.config
4167
+ };
4168
+ const b4 = {
4169
+ deviceSettings: ctx.deviceSettings,
4170
+ policy: ctx.policy
4171
+ };
4172
+ const b5 = {
4173
+ ui: ctx.ui,
4174
+ storage: ctx.storage,
4175
+ server: ctx.server
4467
4176
  };
4468
- const b2 = { discount_amount: discount.toNumber() };
4469
- const b3 = { discount_rate: rateOf(discount, subtotal) };
4470
4177
  return {
4471
4178
  ...b1,
4472
4179
  ...b2,
4473
- ...b3
4180
+ ...b3,
4181
+ ...b4,
4182
+ ...b5,
4183
+ signal: ctx.signal
4474
4184
  };
4475
4185
  };
4476
- const netOf = (root) => {
4477
- const net = root.total.minus(root.tax);
4478
- return net.gt(0) ? net : new Big(0);
4479
- };
4480
- /** What a redeem can still discount on a root: pre-tax or with taxes. */
4481
- const baseOf = (root, beforeTaxes) => {
4482
- return beforeTaxes ? netOf(root) : root.total;
4483
- };
4484
- /** Before taxes, taxes shrink with the pre-tax value they were charged on. */
4485
- const discountRoot = (root, amount, beforeTaxes) => {
4486
- if (amount.lte(0)) return;
4487
- root.discount = root.discount.plus(amount);
4488
- if (!beforeTaxes) {
4489
- root.total = root.total.minus(amount);
4490
- return;
4491
- }
4492
- const net = netOf(root);
4493
- const nextNet = net.minus(amount);
4494
- root.tax = root.tax.times(nextNet).div(net);
4495
- root.total = nextNet.plus(root.tax);
4496
- };
4497
- /** Splits an order discount across roots by what each can still discount. */
4498
- const discountOrder = (roots, amount, beforeTaxes) => {
4499
- const bases = roots.map((root) => baseOf(root, beforeTaxes));
4500
- const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
4501
- if (sum.lte(0) || amount.lte(0)) return;
4502
- let last = -1;
4503
- bases.forEach((base, index) => {
4504
- if (base.gt(0)) last = index;
4505
- });
4506
- let rest = amount;
4507
- roots.forEach((root, index) => {
4508
- const base = bases[index];
4509
- if (base.lte(0)) return;
4510
- const share = amount.times(base).div(sum);
4511
- const part = minBig(index === last ? rest : share, base);
4512
- rest = rest.minus(part);
4513
- discountRoot(root, part, beforeTaxes);
4514
- });
4515
- };
4516
- /**
4517
- * Rate compounds once per reward unit over the remaining value; amount is
4518
- * per unit. With both, the rate discount is capped at `amount × quantity`.
4519
- */
4520
- const redeemDiscount = (redeem, remaining) => {
4521
- const { quantity, discount_rate, discount_amount } = redeem;
4522
- let cap = null;
4523
- if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
4524
- if (discount_rate === void 0) return cap ?? new Big(0);
4525
- const kept = new Big(1).minus(discount_rate).pow(quantity);
4526
- const byRate = remaining.times(new Big(1).minus(kept));
4527
- return cap ? minBig(byRate, cap) : byRate;
4186
+ const closeBatch = async (adapter, ctx) => {
4187
+ const close = adapter.terminal?.closeBatch;
4188
+ if (!close) return false;
4189
+ const done = await close(toTerminalContext(ctx)).catch(() => null);
4190
+ return Boolean(done?.ok);
4528
4191
  };
4529
4192
  /**
4530
- * Root group money rebuilt from the line itself plus its children, i.e. the
4531
- * menu price before any redemption. Child lines are never discounted.
4193
+ * One payment attempt with the policy of section 7: timeout, retries on
4194
+ * retryable errors (same `attempt_reference`), batch close and retry,
4195
+ * polling for actions and resolution of ambiguous results.
4532
4196
  */
4533
- const stripRedemptionLines = (lines) => {
4534
- const children = /* @__PURE__ */ new Map();
4535
- for (const line of lines) {
4536
- if (line.parent_uid == null) continue;
4537
- const list = children.get(line.parent_uid) ?? [];
4538
- list.push(line);
4539
- children.set(line.parent_uid, list);
4540
- }
4541
- return lines.map((line) => {
4542
- if (line.parent_uid != null) return line;
4543
- const qty = new Big(line.quantity);
4544
- let sub = new Big(0);
4545
- let disc = new Big(0);
4546
- let tax = new Big(0);
4547
- let tot = new Big(0);
4548
- for (const child of children.get(line.uid) ?? []) {
4549
- sub = sub.plus(child.group_subtotal);
4550
- disc = disc.plus(child.group_discount);
4551
- tax = tax.plus(child.group_tax_total);
4552
- tot = tot.plus(child.group_total);
4197
+ const runPaymentAttempt = async (adapter, ctx, options = {}) => {
4198
+ const clock = options.clock ?? defaultClock;
4199
+ return withTerminalLock(ctx.paymentId, async () => {
4200
+ let batchClosed = false;
4201
+ let attempt = 1;
4202
+ while (true) {
4203
+ const current = {
4204
+ ...ctx,
4205
+ attempt
4206
+ };
4207
+ const result = await runStart(adapter, current, clock);
4208
+ const code = result.error?.code;
4209
+ if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
4210
+ batchClosed = true;
4211
+ if (await closeBatch(adapter, current)) continue;
4212
+ }
4213
+ if (code === "AMBIGUOUS") {
4214
+ await options.onProgress?.({
4215
+ ...result,
4216
+ status: "PENDING"
4217
+ });
4218
+ return resolveAmbiguous(adapter, current, result, clock);
4219
+ }
4220
+ const canRetry = attempt < ctx.policy.max_attempts;
4221
+ if (result.status === "FAILED" && result.error?.retryable && canRetry) {
4222
+ attempt += 1;
4223
+ continue;
4224
+ }
4225
+ if (result.status !== "PENDING") return result;
4226
+ await options.onProgress?.(result);
4227
+ return pollPending(adapter, current, result, clock, options);
4553
4228
  }
4554
- const gSub = new Big(line.subtotal).plus(sub.times(qty));
4555
- const gDisc = new Big(line.discount_total).plus(disc.times(qty));
4556
- const gTax = new Big(line.tax_total).plus(tax.times(qty));
4557
- const gTot = new Big(line.total).plus(tot.times(qty));
4558
- const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
4559
- const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
4560
- const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
4561
- const u4 = { unit_group_total: gTot.div(qty).toNumber() };
4562
- const g1 = { group_subtotal: gSub.toNumber() };
4563
- const g2 = { group_discount: gDisc.toNumber() };
4564
- const g3 = { group_tax_total: gTax.toNumber() };
4565
- const g4 = { group_total: gTot.toNumber() };
4566
- const p1 = {
4567
- ...u1,
4568
- ...u2,
4569
- ...u3,
4570
- ...u4
4229
+ });
4230
+ };
4231
+ /** Undo a charge after a failed injection or an incomplete split payment. */
4232
+ const reversePayment = async (adapter, ctx, result, options = {}) => {
4233
+ const clock = options.clock ?? defaultClock;
4234
+ const now = options.now ?? new Date(clock.now());
4235
+ let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
4236
+ const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
4237
+ if (kind !== "manual" && !operation) kind = "manual";
4238
+ if (kind === "manual" || !operation) {
4239
+ const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
4240
+ const m1 = {
4241
+ ...result.metadata,
4242
+ error,
4243
+ needs_review: true
4571
4244
  };
4572
4245
  return {
4573
- ...line,
4574
- ...p1,
4575
- ...g1,
4576
- ...g2,
4577
- ...g3,
4578
- ...g4
4246
+ kind: "manual",
4247
+ result: {
4248
+ ...result,
4249
+ metadata: m1,
4250
+ error
4251
+ }
4579
4252
  };
4253
+ }
4254
+ const run = () => callOperation(ctx, operation, result, clock);
4255
+ const next = await withTerminalLock(ctx.paymentId, run);
4256
+ return {
4257
+ kind,
4258
+ result: next
4259
+ };
4260
+ };
4261
+ //#endregion
4262
+ //#region src/order/payments/session.ts
4263
+ const noopUI = {
4264
+ message: () => void 0,
4265
+ action: () => void 0,
4266
+ select: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
4267
+ input: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
4268
+ confirm: () => Promise.resolve(false)
4269
+ };
4270
+ const adapterKey = (provider, method) => {
4271
+ return `${provider}:${method}`;
4272
+ };
4273
+ const record = (value) => {
4274
+ if (!value || typeof value !== "object") return {};
4275
+ return value;
4276
+ };
4277
+ const isPendingConfirm = (tender) => {
4278
+ const confirm = record(tender.metadata).confirm_after_inject === true;
4279
+ return tender.status === "PENDING" && confirm;
4280
+ };
4281
+ /** Live tenders of the current intent that hold the customer's money. */
4282
+ const chargedTenders = (intents) => {
4283
+ return liveTenders(currentIntent(intents)).filter((tender) => {
4284
+ return isTenderSettled(tender) || isPendingConfirm(tender);
4580
4285
  });
4581
4286
  };
4582
- const withRootMoney = (line, money) => {
4583
- const qty = new Big(line.quantity);
4584
- const discount = new Big(line.group_discount).plus(money.discount);
4585
- const u1 = { unit_group_discount: discount.div(qty).toNumber() };
4586
- const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
4587
- const u3 = { unit_group_total: money.total.div(qty).toNumber() };
4588
- const g1 = { group_discount: discount.toNumber() };
4589
- const g2 = { group_tax_total: money.tax.toNumber() };
4590
- const g3 = { group_total: money.total.toNumber() };
4287
+ /** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
4288
+ const coveredAmount = (intents) => {
4289
+ return chargedTenders(intents).reduce((sum, tender) => {
4290
+ return sum + tender.amount.total;
4291
+ }, 0);
4292
+ };
4293
+ const remainingAmount = (order) => {
4294
+ const decimals = currencyDecimals(order.totals.currency);
4295
+ const left = order.totals.total - coveredAmount(order.payment_intents);
4296
+ return Math.max(roundTo(left, decimals), 0);
4297
+ };
4298
+ const isPlaced = (order) => order.status !== "OPEN";
4299
+ /** Orchestrator failures carry the charge-currency amount, not the order's. */
4300
+ const withOrderAmount = (result, amount) => {
4301
+ if (result.status !== "FAILED") return result;
4591
4302
  return {
4592
- ...line,
4593
- ...u1,
4594
- ...u2,
4595
- ...u3,
4596
- ...g1,
4597
- ...g2,
4598
- ...g3
4303
+ ...result,
4304
+ amount
4305
+ };
4306
+ };
4307
+ const partialOf = (result) => {
4308
+ return record(result.metadata).partial_approval !== void 0;
4309
+ };
4310
+ /** D31: PAID for less than the tender (same currency only). */
4311
+ const isPartialApproval = (result, tender, ctx) => {
4312
+ if (result.status !== "PAID") return false;
4313
+ if (ctx.amount.currency !== tender.amount.currency) return false;
4314
+ const tolerance = amountTolerance(tender.amount.currency);
4315
+ return result.amount + tolerance < tender.amount.total;
4316
+ };
4317
+ const toFailure = (error) => {
4318
+ const message = error instanceof Error ? error.message : "Terminal failed";
4319
+ return {
4320
+ ok: false,
4321
+ message,
4322
+ error: createPaymentError("UNKNOWN", { provider_message: message })
4599
4323
  };
4600
4324
  };
4601
4325
  /**
4602
- * Replays every redeem in persisted step order over the root lines' group
4603
- * money. Line redeems discount their root; order redeems are prorated over
4604
- * every root. A discount never exceeds what is left on its target.
4326
+ * Payment state of one order: adapters, device settings and the attempt
4327
+ * orchestration. The app only provides adapters, UI and storage.
4605
4328
  */
4606
- const replayRedemptions = (lines, redeem, steps) => {
4607
- const base = stripRedemptionLines(lines);
4608
- const sequence = sequenceRedeems(base, redeem, steps);
4609
- const moneyByUid = /* @__PURE__ */ new Map();
4610
- for (const line of base) {
4611
- if (line.parent_uid != null) continue;
4612
- const b1 = { quantity: line.quantity };
4613
- const b2 = { subtotal: new Big(line.group_subtotal) };
4614
- const b3 = { tax: new Big(line.group_tax_total) };
4329
+ var PaymentSession = class {
4330
+ deps;
4331
+ adapters = /* @__PURE__ */ new Map();
4332
+ payments = /* @__PURE__ */ new Map();
4333
+ ui = noopUI;
4334
+ storage = memoryPaymentStorage();
4335
+ options = {};
4336
+ constructor(deps) {
4337
+ this.deps = deps;
4338
+ }
4339
+ setAdapters(adapters) {
4340
+ this.adapters.clear();
4341
+ for (const adapter of adapters) this.adapters.set(adapterKey(adapter.provider, adapter.method), adapter);
4342
+ }
4343
+ setUI(ui) {
4344
+ this.ui = ui;
4345
+ }
4346
+ setStorage(storage) {
4347
+ this.storage = storage;
4348
+ }
4349
+ setOptions(options) {
4350
+ this.options = {
4351
+ ...this.options,
4352
+ ...options
4353
+ };
4354
+ captureLogDevice(options.deviceId);
4355
+ }
4356
+ adapterFor(payment) {
4357
+ const key = adapterKey(payment.provider.provider_id, payment.method.method);
4358
+ return this.adapters.get(key) ?? null;
4359
+ }
4360
+ /** Device fields still missing for a payment on this device. */
4361
+ missingFields(payment) {
4362
+ const adapter = this.adapterFor(payment);
4363
+ if (!adapter) return [];
4364
+ const fromCatalog = record(payment.method.config).device_fields;
4365
+ const fields = adapter.deviceFields ?? fromCatalog;
4366
+ const settings = this.options.deviceSettings?.[payment.id];
4367
+ return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
4368
+ }
4369
+ /**
4370
+ * Merchant payments with an installed adapter (provider + method) that
4371
+ * are fully configured on this device.
4372
+ */
4373
+ available(payments) {
4374
+ return payments.filter((payment) => {
4375
+ if (!this.adapterFor(payment)) return false;
4376
+ this.payments.set(payment.id, payment);
4377
+ return this.missingFields(payment).length === 0;
4378
+ });
4379
+ }
4380
+ remaining() {
4381
+ return remainingAmount(this.deps.getOrder());
4382
+ }
4383
+ server(payment, attempt) {
4384
+ const order_id = this.deps.getOrder().id;
4385
+ const http = this.deps.http;
4386
+ return { call: (operation, input) => {
4387
+ const body = {
4388
+ order_id,
4389
+ attempt_reference: attempt,
4390
+ input
4391
+ };
4392
+ const op = services.payments.operation;
4393
+ return op(payment.id, operation, body, http);
4394
+ } };
4395
+ }
4396
+ async chargeAmount(payment, value, server) {
4397
+ const config = record(payment.config);
4398
+ const orderCurrency = this.deps.getOrder().totals.currency;
4399
+ const chargeCurrency = config.charge_currency;
4400
+ if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
4401
+ value,
4402
+ orderCurrency
4403
+ });
4404
+ const exchange = record(config.exchange_rate);
4405
+ let rate = typeof exchange.rate === "number" ? exchange.rate : null;
4406
+ if (exchange.source === "provider") {
4407
+ const input = {
4408
+ from: orderCurrency,
4409
+ to: chargeCurrency
4410
+ };
4411
+ rate = (await server.call("exchange_rate", input)).rate;
4412
+ }
4413
+ return toChargeAmount({
4414
+ value,
4415
+ orderCurrency,
4416
+ chargeCurrency,
4417
+ rate
4418
+ });
4419
+ }
4420
+ async buildContext(payment, amount, attemptReference, signal) {
4421
+ const order = this.deps.getOrder();
4422
+ const server = this.server(payment, attemptReference);
4423
+ const conversion = await this.chargeAmount(payment, amount, server);
4424
+ const config = record(payment.config);
4425
+ const methodConfig = record(payment.method.config);
4426
+ const share = order.totals.total > 0 ? amount / order.totals.total : 1;
4427
+ const decimals = currencyDecimals(order.totals.currency);
4428
+ const taxes = taxBreakdown(order.lines, {
4429
+ share,
4430
+ decimals
4431
+ });
4432
+ const info = order.customer.customer_info;
4433
+ const customer = {
4434
+ document: info.document_number ?? null,
4435
+ documentType: info.document_type ?? null,
4436
+ phone: info.phone ?? null
4437
+ };
4438
+ const environment = resolveProviderEnvironment({
4439
+ isProduction: this.options.isProduction ?? false,
4440
+ paymentConfig: config
4441
+ });
4442
+ const policy = resolvePaymentPolicy({
4443
+ methodConfig,
4444
+ paymentConfig: config
4445
+ });
4446
+ const prefix = `payments:${payment.id}`;
4447
+ const storage = scopedPaymentStorage(this.storage, prefix);
4448
+ const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
4449
+ const totals = order.totals;
4450
+ const o1 = {
4451
+ id: order.id,
4452
+ currency: totals.currency,
4453
+ totals
4454
+ };
4455
+ const b1 = {
4456
+ paymentId: payment.id,
4457
+ order: o1,
4458
+ customer
4459
+ };
4460
+ const b2 = {
4461
+ amount: conversion.amount,
4462
+ taxes,
4463
+ attemptReference
4464
+ };
4465
+ const b3 = {
4466
+ collectAt: this.collectAt(payment),
4467
+ attempt: 1
4468
+ };
4615
4469
  const b4 = {
4616
- total: new Big(line.group_total),
4617
- discount: new Big(0)
4470
+ environment,
4471
+ config,
4472
+ deviceSettings,
4473
+ policy
4618
4474
  };
4619
- moneyByUid.set(line.uid, {
4475
+ const b5 = {
4476
+ ui: this.ui,
4477
+ storage,
4478
+ server,
4479
+ signal
4480
+ };
4481
+ return {
4620
4482
  ...b1,
4621
4483
  ...b2,
4622
4484
  ...b3,
4623
- ...b4
4485
+ ...b4,
4486
+ ...b5
4487
+ };
4488
+ }
4489
+ collectAt(payment) {
4490
+ if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
4491
+ return this.options.collectAt ?? "DEVICE";
4492
+ }
4493
+ /** `collect_at` of a new tender: cash in person, the rest digital. */
4494
+ tenderCollectAt(payment) {
4495
+ if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
4496
+ return "DEVICE";
4497
+ }
4498
+ toRecord(tender, result) {
4499
+ const metadata = { ...result.metadata };
4500
+ const failure = result.error?.message;
4501
+ const message = partialOf(result) ? PARTIAL_APPROVAL_ERROR : failure;
4502
+ const b1 = {
4503
+ tender_id: tender.id,
4504
+ status: result.status,
4505
+ metadata
4506
+ };
4507
+ const b2 = { attempt_reference: tender.attempt_reference };
4508
+ const b3 = result.details ? { details: result.details } : {};
4509
+ return {
4510
+ ...b1,
4511
+ ...b2,
4512
+ ...b3,
4513
+ error_message: message
4514
+ };
4515
+ }
4516
+ async recordResult(tender, result) {
4517
+ const order = this.deps.getOrder();
4518
+ const input = this.toRecord(tender, result);
4519
+ const http = this.deps.http;
4520
+ const next = await services.orders.recordPayment(order.id, input, http);
4521
+ this.deps.applyOrder(next);
4522
+ }
4523
+ registerPayment(payment) {
4524
+ this.payments.set(payment.id, payment);
4525
+ }
4526
+ /** Merchant payments the intent tenders point to (`payment_id`). */
4527
+ registerPayments(payments) {
4528
+ payments.forEach((payment) => this.registerPayment(payment));
4529
+ }
4530
+ intentTenders() {
4531
+ return liveTenders(currentIntent(this.deps.getOrder().payment_intents));
4532
+ }
4533
+ /** Adapter + payment of a device-charged real tender, or why not. */
4534
+ chargerFor(tender) {
4535
+ const id = tender.payment_id;
4536
+ const payment = id ? this.payments.get(id) : void 0;
4537
+ if (!payment) throw new Error(`Unknown payment ${id ?? "(none)"}`);
4538
+ const adapter = this.adapterFor(payment);
4539
+ if (!adapter) throw new Error(`No payment adapter installed for payment ${id}`);
4540
+ const missing = this.missingFields(payment);
4541
+ if (missing.length > 0) {
4542
+ const keys = missing.map((field) => field.key).join(", ");
4543
+ throw new Error(`Payment ${id} is missing device fields: ${keys}`);
4544
+ }
4545
+ return {
4546
+ payment,
4547
+ adapter
4548
+ };
4549
+ }
4550
+ /** Real tenders this device still has to charge, in §6.1 order. */
4551
+ pendingCharges() {
4552
+ return this.intentTenders().filter((tender) => {
4553
+ if (!isRealPaymentMethod(tender.method)) return false;
4554
+ if (!isDeviceCharged(tender)) return false;
4555
+ return tender.status === "IDLE" || tender.status === "FAILED";
4556
+ }).map((tender) => {
4557
+ const charger = this.chargerFor(tender);
4558
+ return {
4559
+ tender,
4560
+ ...charger,
4561
+ rank: chargeRank(tender, charger.adapter)
4562
+ };
4563
+ }).toSorted((a, b) => {
4564
+ if (a.rank !== b.rank) return a.rank - b.rank;
4565
+ return a.tender.sequence - b.tender.sequence;
4624
4566
  });
4625
4567
  }
4626
- const roots = [...moneyByUid.values()];
4627
- const redemption_steps = sequence.map((item) => {
4628
- const id = item.target_id;
4629
- const root = id ? moneyByUid.get(id) : void 0;
4630
- const beforeTaxes = item.redeem.before_taxes ?? true;
4631
- const before = snapshot(roots, 1);
4632
- const targetBefore = root ? snapshot([root], root.quantity) : void 0;
4633
- let left = new Big(0);
4634
- if (root) left = baseOf(root, beforeTaxes);
4635
- else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
4636
- const raw = redeemDiscount(item.redeem, left);
4637
- const discount = minBig(raw, left);
4638
- if (root) discountRoot(root, discount, beforeTaxes);
4639
- else discountOrder(roots, discount, beforeTaxes);
4640
- const action = discount.gt(0) ? "UPDATE" : "READ";
4641
- const { reward_id, quantity } = item.redeem;
4568
+ /** A FAILED tender is retried with a new `attempt_reference` (D25). */
4569
+ withNewAttempt(tender) {
4570
+ if (tender.status !== "FAILED") return tender;
4642
4571
  const b1 = {
4643
- reward_id,
4644
- reward_quantity: quantity,
4645
- target: item.target
4572
+ attempt_reference: createAttemptReference(),
4573
+ previous_attempt_references: [...tender.previous_attempt_references, tender.attempt_reference]
4646
4574
  };
4647
- const b2 = id ? { target_id: id } : {};
4648
- const b3 = {
4649
- action,
4650
- discount_amount: discount.toNumber()
4575
+ return {
4576
+ ...tender,
4577
+ ...b1
4651
4578
  };
4652
- const b4 = {
4653
- before,
4654
- after: snapshot(roots, 1)
4579
+ }
4580
+ /** D31: the provider approved less than the tender; void it, FAILED. */
4581
+ async rejectPartial(adapter, ctx, result, tender) {
4582
+ const voided = adapter.cancel ? await adapter.cancel(ctx, result).catch(() => null) : null;
4583
+ const error = createPaymentError("INSUFFICIENT_FUNDS", { provider_message: `Approved ${result.amount}` });
4584
+ const needs_review = voided?.status !== "CANCELLED";
4585
+ const partial_approval = { approved_amount: result.amount };
4586
+ const metadata = {
4587
+ ...result.metadata,
4588
+ error,
4589
+ needs_review,
4590
+ partial_approval
4655
4591
  };
4656
- let b5 = {};
4657
- if (root) b5 = {
4658
- target_before: targetBefore,
4659
- target_after: snapshot([root], root.quantity)
4592
+ return {
4593
+ status: "FAILED",
4594
+ amount: tender.amount.total,
4595
+ metadata,
4596
+ error
4597
+ };
4598
+ }
4599
+ async chargeTender(current, charger, options) {
4600
+ const { payment, adapter } = charger;
4601
+ const tender = this.withNewAttempt(current);
4602
+ const emit = options.onProgress ?? (() => void 0);
4603
+ await emit({
4604
+ type: "tender_started",
4605
+ tender
4606
+ });
4607
+ const amount = tender.amount.total;
4608
+ const attemptReference = tender.attempt_reference;
4609
+ const metadata = { attempt_reference: attemptReference };
4610
+ if (!(payment.method.method === "CASH") && tender.status !== "PENDING") {
4611
+ const pending = {
4612
+ status: "PENDING",
4613
+ amount,
4614
+ metadata
4615
+ };
4616
+ await this.recordResult(tender, pending);
4617
+ }
4618
+ const controller = new AbortController();
4619
+ const abort = () => controller.abort();
4620
+ options.signal?.addEventListener("abort", abort);
4621
+ try {
4622
+ const a1 = [
4623
+ payment,
4624
+ amount,
4625
+ attemptReference,
4626
+ controller.signal
4627
+ ];
4628
+ const ctx = await this.buildContext(...a1);
4629
+ const onProgress = async (progress) => {
4630
+ await emit({
4631
+ type: "tender_pending",
4632
+ tender,
4633
+ result: progress
4634
+ });
4635
+ if (progress.status !== "PENDING") return;
4636
+ await this.recordResult(tender, progress);
4637
+ };
4638
+ const attempt = await runPaymentAttempt(adapter, ctx, {
4639
+ ...this.deps.runOptions,
4640
+ onProgress
4641
+ });
4642
+ let result = withOrderAmount(attempt, amount);
4643
+ if (isPartialApproval(result, tender, ctx)) result = await this.rejectPartial(adapter, ctx, result, tender);
4644
+ if (result.status !== "IDLE") await this.recordResult(tender, result);
4645
+ const type = result.status === "FAILED" ? "tender_failed" : "tender_paid";
4646
+ if (result.status !== "IDLE") await emit({
4647
+ type,
4648
+ tender,
4649
+ result
4650
+ });
4651
+ return result;
4652
+ } finally {
4653
+ options.signal?.removeEventListener("abort", abort);
4654
+ }
4655
+ }
4656
+ outcome(results, failed) {
4657
+ const order = this.deps.getOrder();
4658
+ const tenders = liveTenders(currentIntent(order.payment_intents));
4659
+ const s1 = {
4660
+ order,
4661
+ results,
4662
+ needsReview: tenders.some((tender) => {
4663
+ return record(tender.metadata).needs_review === true;
4664
+ })
4665
+ };
4666
+ if (failed) return {
4667
+ status: "FAILED",
4668
+ ...s1,
4669
+ ...failed
4670
+ };
4671
+ return {
4672
+ status: tenders.every((tender) => isTenderSettled(tender)) ? "PAID" : "PENDING",
4673
+ ...s1
4674
+ };
4675
+ }
4676
+ /** Charges every device tender left, stopping at the first failure. */
4677
+ async chargeRemaining(options) {
4678
+ const results = [];
4679
+ for (const next of this.pendingCharges()) {
4680
+ const tender = this.intentTenders().find((t) => t.id === next.tender.id);
4681
+ if (!tender) continue;
4682
+ const result = await this.chargeTender(tender, next, options);
4683
+ results.push(result);
4684
+ if (result.status !== "FAILED") continue;
4685
+ const failed = this.intentTenders().find((t) => t.id === tender.id);
4686
+ return this.outcome(results, {
4687
+ tender: failed ?? tender,
4688
+ result
4689
+ });
4690
+ }
4691
+ return this.outcome(results);
4692
+ }
4693
+ /**
4694
+ * Places the order (simple flow: `payment` becomes the one real tender)
4695
+ * and charges the real device tenders in sequence (§6.1). A failure
4696
+ * stops here with the order frozen: `retryTender`, `replaceTender` or
4697
+ * cancel and refund. In-person tenders are never charged here.
4698
+ */
4699
+ async payIntent(options = {}) {
4700
+ const { payment } = options;
4701
+ if (payment) this.registerPayment(payment);
4702
+ if (!isPlaced(this.deps.getOrder())) {
4703
+ if (payment) this.chargerFor({ payment_id: payment.id });
4704
+ const p1 = payment ? this.placePayment(payment) : void 0;
4705
+ await this.deps.placeOrder(p1 ? { payment: p1 } : {});
4706
+ }
4707
+ return this.chargeRemaining(options);
4708
+ }
4709
+ /** Charges a FAILED tender again with a new `attempt_reference`. */
4710
+ async retryTender(tenderId, options = {}) {
4711
+ const tender = this.intentTenders().find((item) => item.id === tenderId);
4712
+ if (!tender) throw new Error(`Unknown tender ${tenderId}`);
4713
+ if (tender.status !== "FAILED") throw new Error(`Only a FAILED tender can be retried (${tender.status})`);
4714
+ return this.chargeRemaining(options);
4715
+ }
4716
+ /**
4717
+ * Changes the method of a FAILED tender (same allocation and amount);
4718
+ * then `payIntent()` / `retryTender` charges the new tender.
4719
+ */
4720
+ async replaceTender(tenderId, payment, collectAt) {
4721
+ this.registerPayment(payment);
4722
+ const order = this.deps.getOrder();
4723
+ const method = payment.method.method;
4724
+ const provider = payment.provider.provider_id;
4725
+ const collect_at = collectAt ?? this.tenderCollectAt(payment);
4726
+ const body = {
4727
+ method,
4728
+ payment_id: payment.id,
4729
+ provider,
4730
+ collect_at
4731
+ };
4732
+ const http = this.deps.http;
4733
+ const replace = services.orders.replaceTender;
4734
+ const next = await replace(order.id, tenderId, body, http);
4735
+ this.deps.applyOrder(next);
4736
+ return next;
4737
+ }
4738
+ /** `PlaceOrderInput.payment` of the simple flow for `payment`. */
4739
+ placePayment(payment) {
4740
+ const type = payment.method.method;
4741
+ const provider = payment.provider.provider_id;
4742
+ const collect_at = this.tenderCollectAt(payment);
4743
+ return {
4744
+ id: payment.id,
4745
+ type,
4746
+ provider,
4747
+ collect_at
4748
+ };
4749
+ }
4750
+ tenderResult(tender) {
4751
+ const metadata = record(tender.metadata);
4752
+ const error = metadata.error;
4753
+ const attempt_reference = tender.attempt_reference;
4754
+ const m1 = {
4755
+ ...metadata,
4756
+ attempt_reference
4757
+ };
4758
+ return {
4759
+ status: tender.status,
4760
+ amount: tender.amount.total,
4761
+ metadata: m1,
4762
+ ...error ? { error } : {}
4763
+ };
4764
+ }
4765
+ async contextFor(tender) {
4766
+ const payment = tender.payment_id ? this.payments.get(tender.payment_id) : null;
4767
+ if (!payment) return null;
4768
+ const adapter = this.adapterFor(payment);
4769
+ if (!adapter) return null;
4770
+ const result = this.tenderResult(tender);
4771
+ const reference = tender.attempt_reference;
4772
+ const signal = new AbortController().signal;
4773
+ const a1 = [
4774
+ payment,
4775
+ tender.amount.total,
4776
+ reference,
4777
+ signal
4778
+ ];
4779
+ return {
4780
+ adapter,
4781
+ result,
4782
+ ctx: await this.buildContext(...a1)
4783
+ };
4784
+ }
4785
+ /** After the order is injected: confirm PENDING `confirm_after_inject`. */
4786
+ async confirmAfterInject() {
4787
+ const intents = this.deps.getOrder().payment_intents;
4788
+ const tenders = liveTenders(currentIntent(intents));
4789
+ const results = [];
4790
+ for (const tender of tenders.filter(isPendingConfirm)) {
4791
+ const found = await this.contextFor(tender);
4792
+ const confirm = found?.adapter.confirm;
4793
+ if (!found || !confirm) continue;
4794
+ const { result, ctx } = found;
4795
+ const confirmed = await confirm(ctx, result).catch((error) => {
4796
+ const err = createPaymentError("UNKNOWN", { provider_message: error instanceof Error ? error.message : null });
4797
+ const m1 = {
4798
+ ...result.metadata,
4799
+ error: err
4800
+ };
4801
+ return {
4802
+ ...result,
4803
+ status: "FAILED",
4804
+ metadata: m1,
4805
+ error: err
4806
+ };
4807
+ });
4808
+ const next = {
4809
+ ...confirmed,
4810
+ metadata: { ...confirmed.metadata }
4811
+ };
4812
+ delete next.metadata.confirm_after_inject;
4813
+ await this.recordResult(tender, next);
4814
+ results.push(next);
4815
+ }
4816
+ return results;
4817
+ }
4818
+ /**
4819
+ * Undo the device charges of this order (injection failed, or the
4820
+ * customer chose "cancel and refund"): cancel, refund or flag for manual
4821
+ * reversal. REDEMPTION reserves are released by the server on cancel.
4822
+ */
4823
+ async reverseAll(options = {}) {
4824
+ const intents = this.deps.getOrder().payment_intents;
4825
+ const outcomes = [];
4826
+ for (const tender of chargedTenders(intents)) {
4827
+ if (tender.status === "IDLE") continue;
4828
+ const found = await this.contextFor(tender);
4829
+ if (!found) continue;
4830
+ await options.onProgress?.({
4831
+ type: "reversing",
4832
+ tender
4833
+ });
4834
+ const { adapter, result, ctx } = found;
4835
+ const outcome = await reversePayment(adapter, ctx, result);
4836
+ const reversed = withOrderAmount(outcome.result, tender.amount.total);
4837
+ await this.recordResult(tender, reversed);
4838
+ outcomes.push(outcome);
4839
+ }
4840
+ return outcomes;
4841
+ }
4842
+ /** Runs a terminal operation (init, test, closeBatch, status, extra). */
4843
+ async runTerminal(payment, key) {
4844
+ const terminal = this.adapterFor(payment)?.terminal;
4845
+ if (!terminal) return {
4846
+ ok: false,
4847
+ message: "No terminal operations"
4848
+ };
4849
+ const base = {
4850
+ init: terminal.init,
4851
+ test: terminal.test
4852
+ };
4853
+ const more = {
4854
+ closeBatch: terminal.closeBatch,
4855
+ status: terminal.status
4856
+ };
4857
+ const named = {
4858
+ ...base,
4859
+ ...more
4860
+ };
4861
+ const extra = terminal.extra?.find((item) => item.key === key)?.run;
4862
+ const operation = named[key] ?? extra;
4863
+ if (!operation) return {
4864
+ ok: false,
4865
+ message: `Unknown operation ${key}`
4866
+ };
4867
+ const signal = new AbortController().signal;
4868
+ const reference = createAttemptReference();
4869
+ const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
4870
+ if (!result.ok) await this.reportTerminal(payment, key, result);
4871
+ return result;
4872
+ }
4873
+ async reportTerminal(payment, operation, result) {
4874
+ const fallback = createPaymentError("UNKNOWN");
4875
+ const error = result.error ?? {
4876
+ ...fallback,
4877
+ message: result.message ?? fallback.message
4660
4878
  };
4661
- return {
4662
- ...b1,
4663
- ...b2,
4664
- ...b3,
4665
- ...b4,
4666
- ...b5
4879
+ const body = {
4880
+ operation,
4881
+ device_id: this.options.deviceId ?? null,
4882
+ error
4667
4883
  };
4884
+ const report = services.payments.terminalFailure;
4885
+ await report(payment.id, body, this.deps.http).catch(() => void 0);
4886
+ }
4887
+ };
4888
+ //#endregion
4889
+ //#region src/schedule/schedule.ts
4890
+ const DAY_OF_WEEK = [
4891
+ "sunday",
4892
+ "monday",
4893
+ "tuesday",
4894
+ "wednesday",
4895
+ "thursday",
4896
+ "friday",
4897
+ "saturday"
4898
+ ];
4899
+ const timeToMinutes = (time) => {
4900
+ const [hours = "0", minutes = "0"] = time.split(":");
4901
+ return Number(hours) * 60 + Number(minutes);
4902
+ };
4903
+ const hasSpecificDates = (period) => {
4904
+ return period.start_date != null && period.end_date != null;
4905
+ };
4906
+ const periodCoversDate = (period, now) => {
4907
+ if (!hasSpecificDates(period)) return false;
4908
+ const nowMs = now.getTime();
4909
+ const startMs = Date.parse(period.start_date);
4910
+ const endMs = Date.parse(period.end_date);
4911
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4912
+ return startMs <= nowMs && nowMs <= endMs;
4913
+ };
4914
+ const timeMatches = (period, now) => {
4915
+ if (!period.start_time || !period.end_time) return false;
4916
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
4917
+ const start = timeToMinutes(period.start_time);
4918
+ const end = timeToMinutes(period.end_time);
4919
+ return nowMinutes >= start && nowMinutes < end;
4920
+ };
4921
+ const isClosedAt = (period, now) => {
4922
+ if (!period.closed) return false;
4923
+ if (!period.start_time || !period.end_time) return true;
4924
+ return timeMatches(period, now);
4925
+ };
4926
+ const isOpenAt = (period, now) => {
4927
+ if (period.closed) return false;
4928
+ return timeMatches(period, now);
4929
+ };
4930
+ const isPeriodsActiveAt = (periods, now) => {
4931
+ return getOpenPeriodAt(periods, now) != null;
4932
+ };
4933
+ const getOpenPeriodAt = (periods, now) => {
4934
+ if (periods.length === 0) return null;
4935
+ const coveringSpecific = periods.filter((period) => {
4936
+ return periodCoversDate(period, now);
4668
4937
  });
4669
- return {
4670
- lines: base.map((line) => {
4671
- const money = moneyByUid.get(line.uid);
4672
- if (!money || money.discount.eq(0)) return line;
4673
- return withRootMoney(line, money);
4674
- }),
4675
- redemption_steps
4938
+ const fallback = periods.filter((period) => !hasSpecificDates(period));
4939
+ const specificOpens = coveringSpecific.filter((period) => !period.closed);
4940
+ if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
4941
+ if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
4942
+ if (fallback.some((period) => isClosedAt(period, now))) return null;
4943
+ return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
4944
+ };
4945
+ const atTimeOnDate = (day, time) => {
4946
+ const [hours = "0", minutes = "0"] = time.split(":");
4947
+ const next = new Date(day);
4948
+ next.setHours(Number(hours), Number(minutes), 0, 0);
4949
+ return next;
4950
+ };
4951
+ const isWithinPeriodDates = (period, instantMs) => {
4952
+ if (!hasSpecificDates(period)) return true;
4953
+ const startMs = Date.parse(period.start_date);
4954
+ const endMs = Date.parse(period.end_date);
4955
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4956
+ return startMs <= instantMs && instantMs <= endMs;
4957
+ };
4958
+ const dayPeriods = (schedule, instant) => {
4959
+ const day = DAY_OF_WEEK[instant.getDay()];
4960
+ if (!day) return [];
4961
+ return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
4962
+ };
4963
+ const getActiveUntil = (periods, now) => {
4964
+ const open = getOpenPeriodAt(periods, now);
4965
+ if (!open?.end_time) return null;
4966
+ let untilMs = atTimeOnDate(now, open.end_time).getTime();
4967
+ const openIsSpecific = hasSpecificDates(open);
4968
+ for (const period of periods) {
4969
+ if (!period.closed || !period.start_time) continue;
4970
+ const closeMs = atTimeOnDate(now, period.start_time).getTime();
4971
+ if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
4972
+ if (!isWithinPeriodDates(period, closeMs)) continue;
4973
+ if (!hasSpecificDates(period) && openIsSpecific) continue;
4974
+ untilMs = closeMs;
4975
+ }
4976
+ return new Date(untilMs);
4977
+ };
4978
+ const getNextStart = (schedule, now) => {
4979
+ const nowMs = now.getTime();
4980
+ let nextStartMs = null;
4981
+ const consider = (candidate, periods) => {
4982
+ const startMs = candidate.getTime();
4983
+ if (startMs <= nowMs) return;
4984
+ if (!isPeriodsActiveAt(periods, candidate)) return;
4985
+ if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
4676
4986
  };
4987
+ for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
4988
+ const candidateDate = new Date(now);
4989
+ candidateDate.setDate(candidateDate.getDate() + dayOffset);
4990
+ candidateDate.setSeconds(0, 0);
4991
+ const periods = dayPeriods(schedule, candidateDate);
4992
+ if (periods.length === 0) continue;
4993
+ for (const period of periods) {
4994
+ if (period.closed || !period.start_time) continue;
4995
+ const start = atTimeOnDate(candidateDate, period.start_time);
4996
+ if (!isWithinPeriodDates(period, start.getTime())) continue;
4997
+ consider(start, periods);
4998
+ }
4999
+ for (const period of periods) {
5000
+ if (!period.closed || !period.end_time) continue;
5001
+ const reopen = atTimeOnDate(candidateDate, period.end_time);
5002
+ if (!isWithinPeriodDates(period, reopen.getTime())) continue;
5003
+ consider(reopen, periods);
5004
+ }
5005
+ }
5006
+ return nextStartMs === null ? null : new Date(nextStartMs);
4677
5007
  };
4678
- /** Discounted lines, steps and totals, keeping step order. */
4679
- const recomputeRedemptions = (input) => {
4680
- const { redeem, currency } = input;
4681
- const steps = input.redemption_steps;
4682
- const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
5008
+ /** Whether `schedule` is in hours now, and until / next open. */
5009
+ const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
5010
+ if (schedule == null) return {
5011
+ active: true,
5012
+ until: null
5013
+ };
5014
+ const periods = dayPeriods(schedule, now);
5015
+ if (isPeriodsActiveAt(periods, now)) return {
5016
+ active: true,
5017
+ until: getActiveUntil(periods, now)
5018
+ };
4683
5019
  return {
4684
- lines,
4685
- redemption_steps,
4686
- totals: recomputeTotals(lines, currency)
5020
+ active: false,
5021
+ next: getNextStart(schedule, now)
4687
5022
  };
4688
5023
  };
5024
+ const isOnSchedule = (schedule, now) => {
5025
+ return getScheduleWindow(schedule, now).active;
5026
+ };
5027
+ //#endregion
5028
+ //#region src/order/recommendations.ts
5029
+ const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
5030
+ const linkedRecommendationIds = (pricing) => {
5031
+ return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
5032
+ };
5033
+ const collectRecommendations = (config) => {
5034
+ const { lines, lookup, quantity, types } = config;
5035
+ if (quantity <= 0) return [];
5036
+ const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
5037
+ const roots = lines.filter((line) => line.parent_uid == null);
5038
+ const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
5039
+ const cartKeys = new Set(fingerprints);
5040
+ const seenTargetIds = /* @__PURE__ */ new Set();
5041
+ const products = [];
5042
+ for (const root of roots) {
5043
+ const source = lookup.productsById[root.id];
5044
+ if (!source) continue;
5045
+ for (const recommendationId of linkedRecommendationIds(source.pricing)) {
5046
+ const recommendation = lookup.recommendationsById[recommendationId];
5047
+ if (!recommendation || !typeSet.has(recommendation.type)) continue;
5048
+ const targetId = recommendation.target_product_id;
5049
+ if (seenTargetIds.has(targetId)) continue;
5050
+ if (cartKeys.has(bareProductFingerprint(targetId))) continue;
5051
+ const target = lookup.productsById[targetId];
5052
+ if (!target || target.status !== "ACTIVE") continue;
5053
+ if (!isOnSchedule(target.availability.schedules)) continue;
5054
+ seenTargetIds.add(targetId);
5055
+ products.push(target);
5056
+ if (products.length >= quantity) return products;
5057
+ }
5058
+ }
5059
+ return products;
5060
+ };
4689
5061
  //#endregion
4690
5062
  //#region src/order/Order.ts
4691
5063
  const cloneOrder = (order) => {
@@ -4805,14 +5177,8 @@ const buildLocalDraft = (insert) => {
4805
5177
  const is_anonymous = insert.is_anonymous ?? false;
4806
5178
  const totals = insert.totals ?? emptyTotals();
4807
5179
  const metadata = insert.metadata ?? {};
4808
- const payment_intents = (insert.payment_intents ?? []).map((intent) => {
4809
- const metadata = intent.metadata ?? {};
4810
- return {
4811
- ...intent,
4812
- metadata
4813
- };
4814
- });
4815
- const payment_status = insert.payment_status ?? "IDLE";
5180
+ const payment_intents = [];
5181
+ const payment_status = "IDLE";
4816
5182
  const b1 = {
4817
5183
  uid: randomUUID(),
4818
5184
  id: insert.id,
@@ -4904,6 +5270,9 @@ var Order = class Order {
4904
5270
  http;
4905
5271
  flush;
4906
5272
  payments;
5273
+ /** Real tenders chosen with `setTenders`; REDEMPTION ones come from redeem. */
5274
+ realTenders = null;
5275
+ redemptionProviders = /* @__PURE__ */ new Map();
4907
5276
  constructor(config, notify) {
4908
5277
  this.draft = cloneOrder(config.order);
4909
5278
  this.synced = cloneOrder(config.order);
@@ -5102,12 +5471,16 @@ var Order = class Order {
5102
5471
  }
5103
5472
  /**
5104
5473
  * Appends a redeem to `line_uid` (root line) or to the order. Steps keep
5105
- * the order in which redeems were applied.
5474
+ * the order in which redeems were applied. With `payment_id` (points /
5475
+ * balance) it is not a discount: the order gets a REDEMPTION tender for
5476
+ * it, capped at total − tip − delivery (D15, D18, D19).
5106
5477
  */
5107
5478
  applyRedeem(input) {
5108
5479
  if (this.deleted) return;
5109
- const { line_uid, ...rest } = input;
5480
+ const { line_uid, provider, ...rest } = input;
5110
5481
  const redeem = buildRedeem(rest);
5482
+ if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Points / balance redeems apply to the whole order");
5483
+ if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
5111
5484
  if (line_uid === void 0) {
5112
5485
  this.commitLines({ redeem: [...this.draft.redeem ?? [], redeem] });
5113
5486
  return;
@@ -5125,7 +5498,8 @@ var Order = class Order {
5125
5498
  }
5126
5499
  /**
5127
5500
  * Removes `quantity` units of `reward_id` from `line_uid` or the order,
5128
- * latest applied first. Removing more units than applied clears them all.
5501
+ * latest applied first. Removing more units than applied clears them all
5502
+ * (and the REDEMPTION tender of a points / balance redeem).
5129
5503
  */
5130
5504
  removeRedeem(input) {
5131
5505
  if (this.deleted) return;
@@ -5269,19 +5643,72 @@ var Order = class Order {
5269
5643
  };
5270
5644
  await services.orders.validate(body, this.http);
5271
5645
  }
5646
+ /** Tenders of the next intent: real ones plus one per payment redeem. */
5647
+ getTenders(payment) {
5648
+ const simple = payment ? simpleTenderInput(payment) : null;
5649
+ const real = simple ? [simple] : this.realTenders ?? [];
5650
+ const redeem = this.draft.redeem ?? [];
5651
+ const providers = this.redemptionProviders;
5652
+ return [...real, ...redemptionTenderInputs(redeem, providers)];
5653
+ }
5654
+ /**
5655
+ * Chooses the real tenders (with allocation for mixed payments) and saves
5656
+ * the DRAFT intent; the server returns amounts per tender. REDEMPTION
5657
+ * tenders are added from the redeems, never passed here.
5658
+ */
5659
+ async setTenders(tenders) {
5660
+ if (tenders.some((tender) => !isRealPaymentMethod(tender.method))) throw new Error("REDEMPTION tenders come from applyRedeem");
5661
+ this.realTenders = tenders;
5662
+ return this.syncPaymentIntent(this.getTenders());
5663
+ }
5664
+ /** Drops the chosen tenders and discards the DRAFT intent. */
5665
+ async clearTenders() {
5666
+ this.realTenders = null;
5667
+ if (activeIntent(this.draft.payment_intents)?.status !== "DRAFT") return this.getOrder();
5668
+ const id = this.draft.id;
5669
+ const next = await services.orders.deletePaymentIntent(id, this.http);
5670
+ this.applyServerOrder(next);
5671
+ return this.getOrder();
5672
+ }
5673
+ async syncPaymentIntent(tenders) {
5674
+ await this.persist();
5675
+ await this.flush.settle();
5676
+ const id = this.draft.id;
5677
+ const put = services.orders.putPaymentIntent;
5678
+ const next = await put(id, { tenders }, this.http);
5679
+ this.applyServerOrder(next);
5680
+ return this.getOrder();
5681
+ }
5682
+ applyServerOrder(next) {
5683
+ this.draft = cloneOrder(next);
5684
+ this.synced = cloneOrder(next);
5685
+ this.notify();
5686
+ }
5687
+ /**
5688
+ * Places the order with a DRAFT intent: `intent_id` as is, else the
5689
+ * tenders of `getTenders(input.payment)` saved first (simple flow: one
5690
+ * real tender plus REDEMPTION). The intent is `LOCKED` afterwards (D25).
5691
+ */
5272
5692
  async placeOrder(input = {}) {
5273
5693
  await this.persist();
5274
5694
  await this.flush.settle();
5275
5695
  await this.validateOrder();
5696
+ let body = input;
5697
+ if (!input.intent_id) {
5698
+ const tenders = this.getTenders(input.payment);
5699
+ const needsPayment = input.payment && !simpleTenderInput(input.payment);
5700
+ if (tenders.length > 0 && !needsPayment) {
5701
+ const intent = activeIntent((await this.syncPaymentIntent(tenders)).payment_intents);
5702
+ if (intent) body = { intent_id: intent.id };
5703
+ }
5704
+ }
5276
5705
  try {
5277
- await services.orders.place(this.draft.id, input, this.http);
5706
+ await services.orders.place(this.draft.id, body, this.http);
5278
5707
  } catch (error) {
5279
5708
  if (!(error instanceof StorefrontHttpError) || error.status !== 409) throw error;
5280
5709
  }
5281
5710
  const next = await services.orders.get(this.draft.id, this.http);
5282
- this.draft = cloneOrder(next);
5283
- this.synced = cloneOrder(next);
5284
- this.notify();
5711
+ this.applyServerOrder(next);
5285
5712
  }
5286
5713
  /** Installed `@fire-spark/payments-*` adapters (provider + method). */
5287
5714
  setPaymentAdapters(adapters) {
@@ -5307,21 +5734,73 @@ var Order = class Order {
5307
5734
  getMissingPaymentFields(payment) {
5308
5735
  return this.payments.missingFields(payment);
5309
5736
  }
5310
- /** Order total minus PAID (and confirm-after-inject) intents. */
5737
+ /** Order total minus settled (and confirm-after-inject) tenders. */
5311
5738
  getRemainingAmount() {
5312
5739
  return this.payments.remaining();
5313
5740
  }
5314
- /** Places the order if needed and charges `payment` (all or part). */
5741
+ /** Merchant payments the tenders point to (`payment_id`). */
5742
+ setPayments(payments) {
5743
+ this.payments.registerPayments(payments);
5744
+ }
5745
+ /**
5746
+ * Places the order if needed and charges its real device tenders in
5747
+ * sequence (§6.1); stops at the first failure with the order frozen.
5748
+ */
5749
+ payIntent(options) {
5750
+ return this.payments.payIntent(options);
5751
+ }
5752
+ /** Charges a FAILED tender again with a new attempt reference (D25). */
5753
+ retryTender(tenderId, options) {
5754
+ return this.payments.retryTender(tenderId, options);
5755
+ }
5756
+ /** Changes the method of a FAILED tender; then `payIntent()` again. */
5757
+ replaceTender(tenderId, payment, collectAt) {
5758
+ return this.payments.replaceTender(tenderId, payment, collectAt);
5759
+ }
5760
+ /** "Cancel and refund": reverses device charges, then cancels the order. */
5761
+ async cancelAndRefund(options) {
5762
+ const outcomes = await this.payments.reverseAll(options);
5763
+ await this.cancelOrder();
5764
+ return outcomes;
5765
+ }
5766
+ /** Voids one tender (operator flow); idempotent per call. */
5767
+ async voidTender(tenderId, reason) {
5768
+ const idempotency_key = createAttemptReference();
5769
+ const body = reason ? {
5770
+ idempotency_key,
5771
+ reason
5772
+ } : { idempotency_key };
5773
+ const id = this.draft.id;
5774
+ const next = await services.orders.voidTender(id, tenderId, body, this.http);
5775
+ this.applyServerOrder(next);
5776
+ return this.getOrder();
5777
+ }
5778
+ /** Refunds one tender: all of it, an amount, or some units. */
5779
+ async refundTender(tenderId, input = {}) {
5780
+ const body = {
5781
+ ...input,
5782
+ idempotency_key: createAttemptReference()
5783
+ };
5784
+ const id = this.draft.id;
5785
+ const refund = services.orders.refundTender;
5786
+ const next = await refund(id, tenderId, body, this.http);
5787
+ this.applyServerOrder(next);
5788
+ return this.getOrder();
5789
+ }
5790
+ /** @deprecated Use `payIntent({ payment })`. */
5315
5791
  pay(payment, options) {
5316
- return this.payments.pay(payment, options);
5792
+ return this.payments.payIntent({
5793
+ ...options,
5794
+ payment
5795
+ });
5317
5796
  }
5318
5797
  /** Confirms intents that waited for the order injection. */
5319
5798
  confirmPaymentsAfterInject() {
5320
5799
  return this.payments.confirmAfterInject();
5321
5800
  }
5322
5801
  /** Cancels or refunds every charge of this order (or flags it manual). */
5323
- reversePayments() {
5324
- return this.payments.reverseAll();
5802
+ reversePayments(options) {
5803
+ return this.payments.reverseAll(options);
5325
5804
  }
5326
5805
  /** Terminal admin operation (init, test, closeBatch, status, extra). */
5327
5806
  runPaymentTerminal(payment, key) {
@@ -5330,13 +5809,19 @@ var Order = class Order {
5330
5809
  /** Applies lines/redeem, replaying redemption steps and totals. */
5331
5810
  commitLines(patch) {
5332
5811
  const patched = patch.lines ?? this.draft.lines;
5333
- const redeem = patch.redeem ?? this.draft.redeem ?? [];
5812
+ const applied = patch.redeem ?? this.draft.redeem ?? [];
5813
+ const steps = this.draft.redemption_steps ?? [];
5814
+ const currency = this.draft.totals.currency;
5334
5815
  const { lines, redemption_steps, totals } = recomputeRedemptions({
5335
5816
  lines: patched,
5336
- redeem,
5337
- redemption_steps: this.draft.redemption_steps ?? [],
5338
- currency: this.draft.totals.currency
5817
+ redeem: applied,
5818
+ redemption_steps: steps,
5819
+ currency
5339
5820
  });
5821
+ const redeem = clampPaymentRedeems(applied, redemptionCap({
5822
+ lines,
5823
+ totals
5824
+ }), currency);
5340
5825
  this.draft = {
5341
5826
  ...this.draft,
5342
5827
  lines,
@@ -6471,6 +6956,22 @@ const isAnonymousOrder = (condition, isAnonymous) => {
6471
6956
  assert(value != null, "Invalid value");
6472
6957
  return matchBooleanOperator(condition, isAnonymous ?? false);
6473
6958
  };
6959
+ const paymentIsMixed = (condition, isMixed) => {
6960
+ const { type, primitive, value } = condition;
6961
+ const matcherType = "Payment is mixed";
6962
+ assert(primitive === "boolean", primitiveError("boolean"));
6963
+ assert(type === matcherType, typeError(matcherType));
6964
+ assert(value != null, "Invalid value");
6965
+ return matchBooleanOperator(condition, isMixed ?? false);
6966
+ };
6967
+ const paymentCaptured = (condition, captured) => {
6968
+ const { type, primitive, value } = condition;
6969
+ const matcherType = "Payment captured";
6970
+ assert(primitive === "boolean", primitiveError("boolean"));
6971
+ assert(type === matcherType, typeError(matcherType));
6972
+ assert(value != null, "Invalid value");
6973
+ return matchBooleanOperator(condition, captured ?? false);
6974
+ };
6474
6975
  //#endregion
6475
6976
  //#region src/rules/currencyMatcher.ts
6476
6977
  const matchCurrencyOperator = (condition, matcher) => {
@@ -6583,17 +7084,29 @@ const matchEnumOperator = (condition, matcher) => {
6583
7084
  default: throw new Error(`Operator ${operator} not supported in enum matcher`);
6584
7085
  }
6585
7086
  };
7087
+ const NEGATIVE_ENUM_OPERATORS = ["is not", "is none of"];
7088
+ /**
7089
+ * Enum condition over a set (one value per tender): positive operators
7090
+ * match when some value matches, negative ones when none matches.
7091
+ */
7092
+ const matchEnumSet = (condition, matcher) => {
7093
+ if (typeof matcher === "undefined" || typeof matcher === "string") return matchEnumOperator(condition, matcher);
7094
+ if (matcher.length === 0) return false;
7095
+ const matches = (value) => matchEnumOperator(condition, value);
7096
+ if (NEGATIVE_ENUM_OPERATORS.includes(condition.operator)) return matcher.every(matches);
7097
+ return matcher.some(matches);
7098
+ };
6586
7099
  const paymentCollectAt = (condition, collectAt) => {
6587
7100
  const matcherType = "Payment collect at";
6588
7101
  assert(condition.primitive === "enum", primitiveError("enum"));
6589
7102
  assert(condition.type === matcherType, typeError(matcherType));
6590
- return matchEnumOperator(condition, collectAt);
7103
+ return matchEnumSet(condition, collectAt);
6591
7104
  };
6592
7105
  const paymentMethod = (condition, method) => {
6593
7106
  const matcherType = "Payment method";
6594
7107
  assert(condition.primitive === "enum", primitiveError("enum"));
6595
7108
  assert(condition.type === matcherType, typeError(matcherType));
6596
- return matchEnumOperator(condition, method);
7109
+ return matchEnumSet(condition, method);
6597
7110
  };
6598
7111
  const paymentStatus = (condition, status) => {
6599
7112
  const matcherType = "Payment status";
@@ -7351,6 +7864,14 @@ var RulesEngine = class {
7351
7864
  if (typeof value !== "boolean") return false;
7352
7865
  return orderIncludesProductWithTag$3(condition, value);
7353
7866
  });
7867
+ if (type === "Payment is mixed") {
7868
+ const payment = this.getDependency("payment", type);
7869
+ return paymentIsMixed(condition, payment.is_mixed);
7870
+ }
7871
+ if (type === "Payment captured") {
7872
+ const payment = this.getDependency("payment", type);
7873
+ return paymentCaptured(condition, payment.captured);
7874
+ }
7354
7875
  throw new Error(`Condition ${type} not supported in boolean matcher`);
7355
7876
  };
7356
7877
  assertCurrency = (condition) => {
@@ -7371,8 +7892,14 @@ var RulesEngine = class {
7371
7892
  assert(primitive === "enum", primitiveError("enum"));
7372
7893
  const payment = this.getDependency("payment", type);
7373
7894
  switch (type) {
7374
- case "Payment collect at": return paymentCollectAt(condition, payment.collect_at);
7375
- case "Payment method": return paymentMethod(condition, payment.method);
7895
+ case "Payment collect at": {
7896
+ const collectAt = payment.collect_ats ?? payment.collect_at;
7897
+ return paymentCollectAt(condition, collectAt);
7898
+ }
7899
+ case "Payment method": {
7900
+ const method = payment.methods ?? payment.method;
7901
+ return paymentMethod(condition, method);
7902
+ }
7376
7903
  case "Payment status": return paymentStatus(condition, payment.status);
7377
7904
  default: throw new Error(`Condition ${type} not supported in enum matcher`);
7378
7905
  }
@@ -8004,4 +8531,4 @@ const cardPreviewGroups = (value) => {
8004
8531
  return groups;
8005
8532
  };
8006
8533
  //#endregion
8007
- 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, stripRedemptionLines, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };
8534
+ 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, isDeviceCharged, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isRealPaymentMethod, isStorefrontNotFound, isVariantBase, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, parseExpiry, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, redemptionCap, remainingAmount, removeLine, replaceLine, resolveCheckout, reversePayment, runPaymentAttempt, serializeError, services, setLineQuantity, storefrontJson, stripRedemptionLines, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };