@fire-spark/core 0.3.2 → 0.3.3

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 +13 -1
  2. package/dist/index.js +1186 -1164
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DIGITAL_COLLECT_AT, PARTIAL_APPROVAL_ERROR, activeIntent, amountTolerance, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, groupPaymentRedeems, isDeliveryFee, isPaymentError, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, 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, discountRedeems, groupPaymentRedeems, isDeliveryFee, isPaymentError, isPaymentRedeem, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, paymentRedeemsCoverTotal, 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";
@@ -3451,1027 +3451,598 @@ const emptyLines = () => {
3451
3451
  return [];
3452
3452
  };
3453
3453
  //#endregion
3454
- //#region src/order/totals.ts
3455
- /** Sum root line group totals into order totals (Big.js). */
3456
- const recomputeTotals = (lines, currency = "USD") => {
3457
- let subtotal = new Big(0);
3458
- let discountTotal = new Big(0);
3459
- let taxTotal = new Big(0);
3460
- let total = new Big(0);
3461
- for (const line of lines) {
3462
- if (line.parent_uid != null) continue;
3463
- subtotal = subtotal.plus(line.group_subtotal);
3464
- discountTotal = discountTotal.plus(line.group_discount);
3465
- taxTotal = taxTotal.plus(line.group_tax_total);
3466
- total = total.plus(line.group_total);
3467
- }
3468
- let taxRate = 0;
3469
- let discountRate = 0;
3470
- if (subtotal.gt(0)) {
3471
- taxRate = taxTotal.div(subtotal).toNumber();
3472
- discountRate = discountTotal.div(subtotal).toNumber();
3473
- }
3474
- const discount_total = discountTotal.toNumber();
3475
- const tax_total = taxTotal.toNumber();
3476
- const b1 = {
3477
- currency,
3478
- subtotal: subtotal.toNumber()
3479
- };
3480
- const b2 = {
3481
- discount_rate: discountRate,
3482
- discount_total
3483
- };
3484
- const b3 = {
3485
- tax_rate: taxRate,
3486
- tax_total,
3487
- total: total.toNumber()
3488
- };
3489
- return {
3490
- ...b1,
3491
- ...b2,
3492
- ...b3
3493
- };
3454
+ //#region src/order/tenders.ts
3455
+ /** Catalog provider of `REDEMPTION` payments (§3.3). */
3456
+ const DEFAULT_REDEMPTION_PROVIDER = "firespark";
3457
+ /**
3458
+ * Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
3459
+ * `DOOR` and `OTHER` are in person: the POS, an operator or the courier
3460
+ * collects them (§12).
3461
+ */
3462
+ const isDeviceCharged = (tender) => {
3463
+ return DIGITAL_COLLECT_AT.includes(tender.collect_at);
3494
3464
  };
3495
- const emptyTotals = (currency = "USD") => {
3496
- const b1 = {
3497
- currency,
3498
- subtotal: 0,
3499
- discount_rate: 0,
3500
- discount_total: 0
3501
- };
3502
- const b2 = {
3503
- tax_rate: 0,
3504
- tax_total: 0,
3505
- total: 0
3506
- };
3507
- return {
3508
- ...b1,
3509
- ...b2
3510
- };
3465
+ /** Most a REDEMPTION can cover: order total − tip − delivery (D18). */
3466
+ const redemptionCap = (order) => {
3467
+ const primaryOnly = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
3468
+ return isTipFee(line) || isDeliveryFee(line) ? sum + line.group_total : sum;
3469
+ }, 0);
3470
+ const decimals = currencyDecimals(order.totals.currency);
3471
+ return Math.max(roundTo(order.totals.total - primaryOnly, decimals), 0);
3511
3472
  };
3512
- //#endregion
3513
- //#region src/order/redemptions.ts
3514
- const ORDER_KEY = "ORDER";
3515
- /** Points / balance paid with a REDEMPTION tender, not a discount (D16). */
3516
- const isPaymentRedeem = (redeem) => {
3517
- return Boolean(redeem.payment_id);
3473
+ /**
3474
+ * Trims payment redeems in application order so their sum fits the cap;
3475
+ * points shrink in proportion. Discount redeems are left untouched.
3476
+ */
3477
+ const clampPaymentRedeems = (redeem, cap, currency) => {
3478
+ const decimals = currencyDecimals(currency);
3479
+ let left = cap;
3480
+ return redeem.flatMap((item) => {
3481
+ if (!isPaymentRedeem(item)) return [item];
3482
+ const amount = item.amount ?? 0;
3483
+ const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
3484
+ left = roundTo(left - fits, decimals);
3485
+ if (fits <= 0) return [];
3486
+ if (fits + amountTolerance(currency) >= amount) return [item];
3487
+ const ratio = amount > 0 ? fits / amount : 0;
3488
+ const points = roundTo((item.points ?? 0) * ratio, 0);
3489
+ return [{
3490
+ ...item,
3491
+ amount: fits,
3492
+ points
3493
+ }];
3494
+ });
3518
3495
  };
3519
- const buildPaymentRedeem = (input) => {
3520
- const { reward_id, quantity, payment_id, amount, points } = input;
3521
- if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
3522
- if (points === void 0 || !(points >= 0)) throw new Error("Redeem with payment_id requires points ≥ 0");
3523
- const redeem = {
3524
- reward_id,
3525
- quantity,
3526
- payment_id,
3527
- amount
3528
- };
3529
- redeem.points = points;
3530
- if (input.program !== void 0) redeem.program = input.program;
3531
- if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
3532
- return redeem;
3496
+ /**
3497
+ * One REDEMPTION tender per `payment_id` of the payment redeems (D15,
3498
+ * D19); its `details.rewards[]` lists every grouped redeem.
3499
+ */
3500
+ const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
3501
+ return groupPaymentRedeems(redeem).map(({ payment_id, details }) => {
3502
+ const fallback = DEFAULT_REDEMPTION_PROVIDER;
3503
+ const t1 = {
3504
+ method: "REDEMPTION",
3505
+ provider: providers.get(payment_id) ?? fallback,
3506
+ payment_id
3507
+ };
3508
+ const t2 = {
3509
+ collect_at: "OTHER",
3510
+ allocation: null,
3511
+ details
3512
+ };
3513
+ return {
3514
+ ...t1,
3515
+ ...t2
3516
+ };
3517
+ });
3533
3518
  };
3534
- /** Same rules as `vOrders.redeem()`; throws on invalid input. */
3535
- const buildRedeem = (input) => {
3536
- const { reward_id, quantity, discount_rate, discount_amount } = input;
3537
- const { before_taxes, expires_at } = input;
3538
- if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
3539
- if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
3540
- if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
3541
- if (input.payment_id) return buildPaymentRedeem(input);
3542
- if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
3543
- const rate = discount_rate ?? 0;
3544
- if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
3545
- if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
3546
- const redeem = {
3547
- reward_id,
3548
- quantity
3519
+ /** The one real tender of the simple flow (`allocation: null`). */
3520
+ const simpleTenderInput = (payment) => {
3521
+ if (!payment.type || !payment.provider) return null;
3522
+ if (!isRealPaymentMethod(payment.type)) return null;
3523
+ const collect_at = payment.collect_at ?? "DEVICE";
3524
+ const t1 = {
3525
+ method: payment.type,
3526
+ provider: payment.provider
3549
3527
  };
3550
- if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
3551
- if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
3552
- if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
3553
- if (expires_at !== void 0) redeem.expires_at = expires_at;
3554
- return redeem;
3555
- };
3556
- /** Payment redeems hold totals, so the kept units keep their share. */
3557
- const keepUnits = (item, rest) => {
3558
- if (!isPaymentRedeem(item) || rest === item.quantity) return {
3559
- ...item,
3560
- quantity: rest
3528
+ const t2 = {
3529
+ payment_id: payment.id,
3530
+ collect_at,
3531
+ allocation: null
3561
3532
  };
3562
- const ratio = new Big(rest).div(item.quantity);
3563
- const amount = new Big(item.amount ?? 0).times(ratio).round(6).toNumber();
3564
- const points = new Big(item.points ?? 0).times(ratio).round(0).toNumber();
3565
3533
  return {
3566
- ...item,
3567
- quantity: rest,
3568
- amount,
3569
- points
3534
+ ...t1,
3535
+ ...t2
3570
3536
  };
3571
3537
  };
3572
3538
  /**
3573
- * Takes `quantity` units of `reward_id` off the latest entries first, so the
3574
- * earlier redeems keep their step position. Entries that reach 0 are removed.
3539
+ * Charge order of §6.1: deferred capture (authorize only), immediate
3540
+ * capture reversible with cancel/refund, manual reversal only, then cash /
3541
+ * IDLE last.
3575
3542
  */
3576
- const subtractRedeem = (items, reward_id, quantity) => {
3577
- if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
3578
- let pending = quantity;
3579
- const kept = [];
3580
- for (let i = items.length - 1; i >= 0; i--) {
3581
- const item = items[i];
3582
- if (pending === 0 || item.reward_id !== reward_id) {
3583
- kept.unshift(item);
3584
- continue;
3585
- }
3586
- const taken = Math.min(item.quantity, pending);
3587
- pending -= taken;
3588
- const rest = item.quantity - taken;
3589
- if (rest > 0) kept.unshift(keepUnits(item, rest));
3590
- }
3591
- return kept;
3543
+ const chargeRank = (tender, adapter) => {
3544
+ if (tender.method === "CASH") return 3;
3545
+ if (adapter.capabilities?.deferredCapture) return 0;
3546
+ if (adapter.cancel || adapter.refund) return 1;
3547
+ return 2;
3592
3548
  };
3593
- const redemptionTargetKey = (target) => {
3594
- if (target.target === "ORDER") return ORDER_KEY;
3595
- return `LINE:${target.target_id ?? ""}`;
3549
+ //#endregion
3550
+ //#region src/order/payments/orchestrator.ts
3551
+ const AMBIGUOUS_QUERIES = 3;
3552
+ const defaultSleep = (ms, signal) => {
3553
+ return new Promise((resolve) => {
3554
+ const timer = setTimeout(resolve, ms);
3555
+ signal?.addEventListener("abort", () => {
3556
+ clearTimeout(timer);
3557
+ resolve();
3558
+ });
3559
+ });
3596
3560
  };
3597
- /** Only root lines own redeems; child-line redeems are never applied. */
3598
- const redeemPools = (lines, redeem) => {
3599
- const pools = /* @__PURE__ */ new Map();
3600
- for (const line of lines) {
3601
- const items = line.redeem ?? [];
3602
- if (line.parent_uid != null || items.length === 0) continue;
3603
- const target = {
3604
- target: "LINE",
3605
- target_id: line.uid
3606
- };
3607
- const pool = items.map((item) => ({
3608
- ...target,
3609
- redeem: item
3610
- }));
3611
- pools.set(redemptionTargetKey(target), pool);
3612
- }
3613
- const order = { target: "ORDER" };
3614
- const pool = redeem.filter((item) => !isPaymentRedeem(item)).map((item) => ({
3615
- ...order,
3616
- redeem: item
3617
- }));
3618
- pools.set(ORDER_KEY, pool);
3619
- return pools;
3561
+ const defaultClock = {
3562
+ now: () => Date.now(),
3563
+ sleep: defaultSleep
3620
3564
  };
3565
+ var TimeoutSignal = class extends Error {};
3621
3566
  /**
3622
- * Pairs the k-th step of a target with the k-th redeem of that target, in
3623
- * step order. Steps without a matching redeem are dropped; redeems without a
3624
- * step are appended (lines first, then order).
3567
+ * Provider deadline that pauses while the customer is answering `ctx.ui`;
3568
+ * user waits run on `interaction_timeout_ms` instead.
3625
3569
  */
3626
- const sequenceRedeems = (lines, redeem, steps) => {
3627
- const pools = redeemPools(lines, redeem);
3628
- const cursors = /* @__PURE__ */ new Map();
3629
- const sequence = [];
3630
- for (const step of steps) {
3631
- const key = redemptionTargetKey(step);
3632
- const at = cursors.get(key) ?? 0;
3633
- const next = pools.get(key)?.[at];
3634
- if (!next || next.redeem.reward_id !== step.reward_id) continue;
3635
- cursors.set(key, at + 1);
3636
- sequence.push(next);
3637
- }
3638
- for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
3639
- return sequence;
3570
+ const createDeadline = (timeoutMs, onExpire) => {
3571
+ let remaining = timeoutMs;
3572
+ let startedAt = 0;
3573
+ let timer = null;
3574
+ let paused = 0;
3575
+ const run = () => {
3576
+ startedAt = Date.now();
3577
+ timer = setTimeout(onExpire, Math.max(remaining, 0));
3578
+ };
3579
+ const stop = () => {
3580
+ if (!timer) return;
3581
+ clearTimeout(timer);
3582
+ timer = null;
3583
+ remaining -= Date.now() - startedAt;
3584
+ };
3585
+ return {
3586
+ start: run,
3587
+ pause: () => {
3588
+ if (paused === 0) stop();
3589
+ paused += 1;
3590
+ },
3591
+ resume: () => {
3592
+ paused -= 1;
3593
+ if (paused === 0) run();
3594
+ },
3595
+ clear: stop
3596
+ };
3640
3597
  };
3641
- const minBig = (a, b) => {
3642
- return a.lt(b) ? a : b;
3598
+ const withUserTimeout = (promise, ms) => {
3599
+ return new Promise((resolve, reject) => {
3600
+ const timer = setTimeout(() => {
3601
+ reject(new PaymentUserTimeout());
3602
+ }, ms);
3603
+ promise.then((value) => {
3604
+ clearTimeout(timer);
3605
+ resolve(value);
3606
+ }, (error) => {
3607
+ clearTimeout(timer);
3608
+ reject(error);
3609
+ });
3610
+ });
3643
3611
  };
3644
- const rateOf = (discount, base) => {
3645
- if (base.lte(0)) return 0;
3646
- return minBig(discount.div(base), new Big(1)).toNumber();
3612
+ var PaymentUserTimeout = class extends Error {};
3613
+ const timedUI = (ui, deadline, interactionMs) => {
3614
+ const wrap = (call) => {
3615
+ deadline.pause();
3616
+ return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
3617
+ };
3618
+ return {
3619
+ message: (input) => ui.message(input),
3620
+ action: (input) => ui.action(input),
3621
+ select: (input) => {
3622
+ return wrap(() => ui.select(input));
3623
+ },
3624
+ input: (input) => wrap(() => ui.input(input)),
3625
+ confirm: (input) => wrap(() => ui.confirm(input))
3626
+ };
3647
3627
  };
3648
- const snapshot = (roots, quantity) => {
3649
- let subtotal = new Big(0);
3650
- let total = new Big(0);
3651
- let discount = new Big(0);
3652
- for (const root of roots) {
3653
- subtotal = subtotal.plus(root.subtotal);
3654
- total = total.plus(root.total);
3655
- discount = discount.plus(root.discount);
3628
+ /** Runs one adapter call with the provider timeout and the user clock. */
3629
+ const runTimed = async (ctx, call) => {
3630
+ const controller = new AbortController();
3631
+ const parent = ctx.signal;
3632
+ const abort = () => controller.abort();
3633
+ parent.addEventListener("abort", abort);
3634
+ let expire = () => void 0;
3635
+ const expired = new Promise((_, reject) => {
3636
+ expire = () => {
3637
+ controller.abort();
3638
+ reject(new TimeoutSignal());
3639
+ };
3640
+ });
3641
+ const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
3642
+ const interaction = ctx.policy.interaction_timeout_ms;
3643
+ const ui = timedUI(ctx.ui, deadline, interaction);
3644
+ const scoped = {
3645
+ ...ctx,
3646
+ ui,
3647
+ signal: controller.signal
3648
+ };
3649
+ deadline.start();
3650
+ try {
3651
+ return await Promise.race([call(scoped), expired]);
3652
+ } finally {
3653
+ deadline.clear();
3654
+ parent.removeEventListener("abort", abort);
3656
3655
  }
3657
- const b1 = {
3658
- value: total.toNumber(),
3659
- quantity
3656
+ };
3657
+ const failed = (ctx, error, extra = {}) => {
3658
+ const metadata = {
3659
+ attempt_reference: ctx.attemptReference,
3660
+ error,
3661
+ ...extra
3660
3662
  };
3661
- const b2 = { discount_amount: discount.toNumber() };
3662
- const b3 = { discount_rate: rateOf(discount, subtotal) };
3663
3663
  return {
3664
- ...b1,
3665
- ...b2,
3666
- ...b3
3664
+ status: "FAILED",
3665
+ amount: ctx.amount.value,
3666
+ metadata,
3667
+ error
3667
3668
  };
3668
3669
  };
3669
- const netOf = (root) => {
3670
- const net = root.total.minus(root.tax);
3671
- return net.gt(0) ? net : new Big(0);
3670
+ const errorOf = (ctx, error, startedAt, clock) => {
3671
+ const elapsed_ms = clock.now() - startedAt;
3672
+ const attempt = ctx.attempt;
3673
+ if (isPaymentError(error)) return {
3674
+ ...error.error,
3675
+ attempt,
3676
+ elapsed_ms
3677
+ };
3678
+ let code = "UNKNOWN";
3679
+ if (error instanceof TimeoutSignal) code = "TIMEOUT";
3680
+ if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
3681
+ const extra = {
3682
+ attempt,
3683
+ elapsed_ms,
3684
+ provider_message: error instanceof Error ? error.message : null
3685
+ };
3686
+ return createPaymentError(code, extra);
3672
3687
  };
3673
- /** What a redeem can still discount on a root: pre-tax or with taxes. */
3674
- const baseOf = (root, beforeTaxes) => {
3675
- return beforeTaxes ? netOf(root) : root.total;
3688
+ /** `start` that timed out after leaving the device is ambiguous. */
3689
+ const startError = (error) => {
3690
+ if (error.code !== "TIMEOUT") return error;
3691
+ return createPaymentError("AMBIGUOUS", {
3692
+ attempt: error.attempt,
3693
+ elapsed_ms: error.elapsed_ms
3694
+ });
3676
3695
  };
3677
- /** Before taxes, taxes shrink with the pre-tax value they were charged on. */
3678
- const discountRoot = (root, amount, beforeTaxes) => {
3679
- if (amount.lte(0)) return;
3680
- root.discount = root.discount.plus(amount);
3681
- if (!beforeTaxes) {
3682
- root.total = root.total.minus(amount);
3683
- return;
3696
+ const isFinal = (result) => result.status !== "PENDING";
3697
+ const callOperation = async (ctx, operation, result, clock) => {
3698
+ const startedAt = clock.now();
3699
+ try {
3700
+ return await runTimed(ctx, (scoped) => operation(scoped, result));
3701
+ } catch (error) {
3702
+ return failed(ctx, errorOf(ctx, error, startedAt, clock));
3684
3703
  }
3685
- const net = netOf(root);
3686
- const nextNet = net.minus(amount);
3687
- root.tax = root.tax.times(nextNet).div(net);
3688
- root.total = nextNet.plus(root.tax);
3689
3704
  };
3690
- /** Splits an order discount across roots by what each can still discount. */
3691
- const discountOrder = (roots, amount, beforeTaxes) => {
3692
- const bases = roots.map((root) => baseOf(root, beforeTaxes));
3693
- const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
3694
- if (sum.lte(0) || amount.lte(0)) return;
3695
- let last = -1;
3696
- bases.forEach((base, index) => {
3697
- if (base.gt(0)) last = index;
3698
- });
3699
- let rest = amount;
3700
- roots.forEach((root, index) => {
3701
- const base = bases[index];
3702
- if (base.lte(0)) return;
3703
- const share = amount.times(base).div(sum);
3704
- const part = minBig(index === last ? rest : share, base);
3705
- rest = rest.minus(part);
3706
- discountRoot(root, part, beforeTaxes);
3707
- });
3705
+ /** Query up to 3 times, cancel once, else FAILED + needs_review. */
3706
+ const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
3707
+ if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
3708
+ await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
3709
+ const next = await callOperation(ctx, adapter.query, pending, clock);
3710
+ if (next.status === "PAID") return next;
3711
+ const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
3712
+ if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
3713
+ }
3714
+ if (adapter.cancel) {
3715
+ const cancelled = await callOperation(ctx, adapter.cancel, pending, clock);
3716
+ if (cancelled.status === "CANCELLED") return cancelled;
3717
+ }
3718
+ const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
3719
+ return failed(ctx, error, { needs_review: true });
3708
3720
  };
3709
- /**
3710
- * Rate compounds once per reward unit over the remaining value; amount is
3711
- * per unit. With both, the rate discount is capped at `amount × quantity`.
3712
- */
3713
- const redeemDiscount = (redeem, remaining) => {
3714
- const { quantity, discount_rate, discount_amount } = redeem;
3715
- let cap = null;
3716
- if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
3717
- if (discount_rate === void 0) return cap ?? new Big(0);
3718
- const kept = new Big(1).minus(discount_rate).pow(quantity);
3719
- const byRate = remaining.times(new Big(1).minus(kept));
3720
- return cap ? minBig(byRate, cap) : byRate;
3721
+ /** PENDING with an action: poll until paid, rejected or the QR expires. */
3722
+ const pollPending = async (adapter, ctx, pending, clock, options) => {
3723
+ if (pending.metadata.confirm_after_inject) return pending;
3724
+ if (!adapter.query) return pending;
3725
+ const expiresAt = clock.now() + ctx.policy.qr_ttl_ms;
3726
+ let current = pending;
3727
+ while (clock.now() < expiresAt) {
3728
+ if (ctx.signal.aborted) break;
3729
+ await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
3730
+ if (ctx.signal.aborted) break;
3731
+ const next = await callOperation(ctx, adapter.query, current, clock);
3732
+ if (next.status === "FAILED" && next.error?.retryable) continue;
3733
+ if (isFinal(next)) return next;
3734
+ current = next;
3735
+ await options.onProgress?.(current);
3736
+ }
3737
+ if (adapter.cancel) await callOperation(ctx, adapter.cancel, current, clock);
3738
+ const code = ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED";
3739
+ return failed(ctx, createPaymentError(code, { attempt: ctx.attempt }));
3721
3740
  };
3722
- /**
3723
- * Root group money rebuilt from the line itself plus its children, i.e. the
3724
- * menu price before any redemption. Child lines are never discounted.
3725
- */
3726
- const stripRedemptionLines = (lines) => {
3727
- const children = /* @__PURE__ */ new Map();
3728
- for (const line of lines) {
3729
- if (line.parent_uid == null) continue;
3730
- const list = children.get(line.parent_uid) ?? [];
3731
- list.push(line);
3732
- children.set(line.parent_uid, list);
3741
+ const runStart = async (adapter, ctx, clock) => {
3742
+ const startedAt = clock.now();
3743
+ try {
3744
+ return await runTimed(ctx, (scoped) => adapter.start(scoped));
3745
+ } catch (error) {
3746
+ const raw = errorOf(ctx, error, startedAt, clock);
3747
+ return failed(ctx, startError(raw));
3733
3748
  }
3734
- return lines.map((line) => {
3735
- if (line.parent_uid != null) return line;
3736
- const qty = new Big(line.quantity);
3737
- let sub = new Big(0);
3738
- let disc = new Big(0);
3739
- let tax = new Big(0);
3740
- let tot = new Big(0);
3741
- for (const child of children.get(line.uid) ?? []) {
3742
- sub = sub.plus(child.group_subtotal);
3743
- disc = disc.plus(child.group_discount);
3744
- tax = tax.plus(child.group_tax_total);
3745
- tot = tot.plus(child.group_total);
3746
- }
3747
- const gSub = new Big(line.subtotal).plus(sub.times(qty));
3748
- const gDisc = new Big(line.discount_total).plus(disc.times(qty));
3749
- const gTax = new Big(line.tax_total).plus(tax.times(qty));
3750
- const gTot = new Big(line.total).plus(tot.times(qty));
3751
- const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
3752
- const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
3753
- const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
3754
- const u4 = { unit_group_total: gTot.div(qty).toNumber() };
3755
- const g1 = { group_subtotal: gSub.toNumber() };
3756
- const g2 = { group_discount: gDisc.toNumber() };
3757
- const g3 = { group_tax_total: gTax.toNumber() };
3758
- const g4 = { group_total: gTot.toNumber() };
3759
- const p1 = {
3760
- ...u1,
3761
- ...u2,
3762
- ...u3,
3763
- ...u4
3764
- };
3765
- return {
3766
- ...line,
3767
- ...p1,
3768
- ...g1,
3769
- ...g2,
3770
- ...g3,
3771
- ...g4
3772
- };
3773
- });
3774
3749
  };
3775
- const withRootMoney = (line, money) => {
3776
- const qty = new Big(line.quantity);
3777
- const discount = new Big(line.group_discount).plus(money.discount);
3778
- const u1 = { unit_group_discount: discount.div(qty).toNumber() };
3779
- const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
3780
- const u3 = { unit_group_total: money.total.div(qty).toNumber() };
3781
- const g1 = { group_discount: discount.toNumber() };
3782
- const g2 = { group_tax_total: money.tax.toNumber() };
3783
- const g3 = { group_total: money.total.toNumber() };
3750
+ const toTerminalContext = (ctx) => {
3751
+ const b1 = {
3752
+ paymentId: ctx.paymentId,
3753
+ attempt: ctx.attempt
3754
+ };
3755
+ const b2 = { attemptReference: ctx.attemptReference };
3756
+ const b3 = {
3757
+ environment: ctx.environment,
3758
+ config: ctx.config
3759
+ };
3760
+ const b4 = {
3761
+ deviceSettings: ctx.deviceSettings,
3762
+ policy: ctx.policy
3763
+ };
3764
+ const b5 = {
3765
+ ui: ctx.ui,
3766
+ storage: ctx.storage,
3767
+ server: ctx.server
3768
+ };
3784
3769
  return {
3785
- ...line,
3786
- ...u1,
3787
- ...u2,
3788
- ...u3,
3789
- ...g1,
3790
- ...g2,
3791
- ...g3
3770
+ ...b1,
3771
+ ...b2,
3772
+ ...b3,
3773
+ ...b4,
3774
+ ...b5,
3775
+ signal: ctx.signal
3792
3776
  };
3793
3777
  };
3778
+ const closeBatch = async (adapter, ctx) => {
3779
+ const close = adapter.terminal?.closeBatch;
3780
+ if (!close) return false;
3781
+ const done = await close(toTerminalContext(ctx)).catch(() => null);
3782
+ return Boolean(done?.ok);
3783
+ };
3794
3784
  /**
3795
- * Replays every redeem in persisted step order over the root lines' group
3796
- * money. Line redeems discount their root; order redeems are prorated over
3797
- * every root. A discount never exceeds what is left on its target.
3785
+ * One payment attempt with the policy of section 7: timeout, retries on
3786
+ * retryable errors (same `attempt_reference`), batch close and retry,
3787
+ * polling for actions and resolution of ambiguous results.
3798
3788
  */
3799
- const replayRedemptions = (lines, redeem, steps) => {
3800
- const base = stripRedemptionLines(lines);
3801
- const sequence = sequenceRedeems(base, redeem, steps);
3802
- const moneyByUid = /* @__PURE__ */ new Map();
3803
- for (const line of base) {
3804
- if (line.parent_uid != null) continue;
3805
- const b1 = { quantity: line.quantity };
3806
- const b2 = { subtotal: new Big(line.group_subtotal) };
3807
- const b3 = { tax: new Big(line.group_tax_total) };
3808
- const b4 = {
3809
- total: new Big(line.group_total),
3810
- discount: new Big(0)
3811
- };
3812
- moneyByUid.set(line.uid, {
3813
- ...b1,
3814
- ...b2,
3815
- ...b3,
3816
- ...b4
3817
- });
3818
- }
3819
- const roots = [...moneyByUid.values()];
3820
- const redemption_steps = sequence.map((item) => {
3821
- const id = item.target_id;
3822
- const root = id ? moneyByUid.get(id) : void 0;
3823
- const beforeTaxes = item.redeem.before_taxes ?? true;
3824
- const before = snapshot(roots, 1);
3825
- const targetBefore = root ? snapshot([root], root.quantity) : void 0;
3826
- let left = new Big(0);
3827
- if (root) left = baseOf(root, beforeTaxes);
3828
- else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
3829
- const raw = redeemDiscount(item.redeem, left);
3830
- const discount = minBig(raw, left);
3831
- if (root) discountRoot(root, discount, beforeTaxes);
3832
- else discountOrder(roots, discount, beforeTaxes);
3833
- const action = discount.gt(0) ? "UPDATE" : "READ";
3834
- const { reward_id, quantity } = item.redeem;
3835
- const b1 = {
3836
- reward_id,
3837
- reward_quantity: quantity,
3838
- target: item.target
3839
- };
3840
- const b2 = id ? { target_id: id } : {};
3841
- const b3 = {
3842
- action,
3843
- discount_amount: discount.toNumber()
3844
- };
3845
- const b4 = {
3846
- before,
3847
- after: snapshot(roots, 1)
3848
- };
3849
- let b5 = {};
3850
- if (root) b5 = {
3851
- target_before: targetBefore,
3852
- target_after: snapshot([root], root.quantity)
3789
+ const runPaymentAttempt = async (adapter, ctx, options = {}) => {
3790
+ const clock = options.clock ?? defaultClock;
3791
+ return withTerminalLock(ctx.paymentId, async () => {
3792
+ let batchClosed = false;
3793
+ let attempt = 1;
3794
+ while (true) {
3795
+ const current = {
3796
+ ...ctx,
3797
+ attempt
3798
+ };
3799
+ const result = await runStart(adapter, current, clock);
3800
+ const code = result.error?.code;
3801
+ if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
3802
+ batchClosed = true;
3803
+ if (await closeBatch(adapter, current)) continue;
3804
+ }
3805
+ if (code === "AMBIGUOUS") {
3806
+ await options.onProgress?.({
3807
+ ...result,
3808
+ status: "PENDING"
3809
+ });
3810
+ return resolveAmbiguous(adapter, current, result, clock);
3811
+ }
3812
+ const canRetry = attempt < ctx.policy.max_attempts;
3813
+ if (result.status === "FAILED" && result.error?.retryable && canRetry) {
3814
+ attempt += 1;
3815
+ continue;
3816
+ }
3817
+ if (result.status !== "PENDING") return result;
3818
+ await options.onProgress?.(result);
3819
+ return pollPending(adapter, current, result, clock, options);
3820
+ }
3821
+ });
3822
+ };
3823
+ /** Undo a charge after a failed injection or an incomplete split payment. */
3824
+ const reversePayment = async (adapter, ctx, result, options = {}) => {
3825
+ const clock = options.clock ?? defaultClock;
3826
+ const now = options.now ?? new Date(clock.now());
3827
+ let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
3828
+ const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
3829
+ if (kind !== "manual" && !operation) kind = "manual";
3830
+ if (kind === "manual" || !operation) {
3831
+ const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
3832
+ const m1 = {
3833
+ ...result.metadata,
3834
+ error,
3835
+ needs_review: true
3853
3836
  };
3854
3837
  return {
3855
- ...b1,
3856
- ...b2,
3857
- ...b3,
3858
- ...b4,
3859
- ...b5
3838
+ kind: "manual",
3839
+ result: {
3840
+ ...result,
3841
+ metadata: m1,
3842
+ error
3843
+ }
3860
3844
  };
3861
- });
3862
- return {
3863
- lines: base.map((line) => {
3864
- const money = moneyByUid.get(line.uid);
3865
- if (!money || money.discount.eq(0)) return line;
3866
- return withRootMoney(line, money);
3867
- }),
3868
- redemption_steps
3869
- };
3870
- };
3871
- /** Discounted lines, steps and totals, keeping step order. */
3872
- const recomputeRedemptions = (input) => {
3873
- const { redeem, currency } = input;
3874
- const steps = input.redemption_steps;
3875
- const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
3845
+ }
3846
+ const run = () => callOperation(ctx, operation, result, clock);
3847
+ const next = await withTerminalLock(ctx.paymentId, run);
3876
3848
  return {
3877
- lines,
3878
- redemption_steps,
3879
- totals: recomputeTotals(lines, currency)
3849
+ kind,
3850
+ result: next
3880
3851
  };
3881
3852
  };
3882
3853
  //#endregion
3883
- //#region src/order/tenders.ts
3884
- /** Catalog provider of `REDEMPTION` payments (§3.3). */
3885
- const DEFAULT_REDEMPTION_PROVIDER = "firespark";
3886
- /**
3887
- * Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
3888
- * `DOOR` and `OTHER` are in person: the POS, an operator or the courier
3889
- * collects them (§12).
3890
- */
3891
- const isDeviceCharged = (tender) => {
3892
- return DIGITAL_COLLECT_AT.includes(tender.collect_at);
3854
+ //#region src/order/payments/session.ts
3855
+ const noopUI = {
3856
+ message: () => void 0,
3857
+ action: () => void 0,
3858
+ select: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
3859
+ input: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
3860
+ confirm: () => Promise.resolve(false)
3893
3861
  };
3894
- /** Most a REDEMPTION can cover: order total − tip − delivery (D18). */
3895
- const redemptionCap = (order) => {
3896
- const primaryOnly = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
3897
- return isTipFee(line) || isDeliveryFee(line) ? sum + line.group_total : sum;
3898
- }, 0);
3899
- const decimals = currencyDecimals(order.totals.currency);
3900
- return Math.max(roundTo(order.totals.total - primaryOnly, decimals), 0);
3862
+ const adapterKey = (provider, method) => {
3863
+ return `${provider}:${method}`;
3901
3864
  };
3902
- /**
3903
- * Trims payment redeems in application order so their sum fits the cap;
3904
- * points shrink in proportion. Discount redeems are left untouched.
3905
- */
3906
- const clampPaymentRedeems = (redeem, cap, currency) => {
3907
- const decimals = currencyDecimals(currency);
3908
- let left = cap;
3909
- return redeem.flatMap((item) => {
3910
- if (!isPaymentRedeem(item)) return [item];
3911
- const amount = item.amount ?? 0;
3912
- const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
3913
- left = roundTo(left - fits, decimals);
3914
- if (fits <= 0) return [];
3915
- if (fits + amountTolerance(currency) >= amount) return [item];
3916
- const ratio = amount > 0 ? fits / amount : 0;
3917
- const points = roundTo((item.points ?? 0) * ratio, 0);
3918
- return [{
3919
- ...item,
3920
- amount: fits,
3921
- points
3922
- }];
3923
- });
3924
- };
3925
- /**
3926
- * One REDEMPTION tender per `payment_id` of the payment redeems (D15,
3927
- * D19); its `details.rewards[]` lists every grouped redeem.
3928
- */
3929
- const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
3930
- return groupPaymentRedeems(redeem).map(({ payment_id, details }) => {
3931
- const fallback = DEFAULT_REDEMPTION_PROVIDER;
3932
- const t1 = {
3933
- method: "REDEMPTION",
3934
- provider: providers.get(payment_id) ?? fallback,
3935
- payment_id
3936
- };
3937
- const t2 = {
3938
- collect_at: "OTHER",
3939
- allocation: null,
3940
- details
3941
- };
3942
- return {
3943
- ...t1,
3944
- ...t2
3945
- };
3946
- });
3947
- };
3948
- /** The one real tender of the simple flow (`allocation: null`). */
3949
- const simpleTenderInput = (payment) => {
3950
- if (!payment.type || !payment.provider) return null;
3951
- if (!isRealPaymentMethod(payment.type)) return null;
3952
- const collect_at = payment.collect_at ?? "DEVICE";
3953
- const t1 = {
3954
- method: payment.type,
3955
- provider: payment.provider
3956
- };
3957
- const t2 = {
3958
- payment_id: payment.id,
3959
- collect_at,
3960
- allocation: null
3961
- };
3962
- return {
3963
- ...t1,
3964
- ...t2
3965
- };
3865
+ const record = (value) => {
3866
+ if (!value || typeof value !== "object") return {};
3867
+ return value;
3966
3868
  };
3967
- /**
3968
- * Charge order of §6.1: deferred capture (authorize only), immediate
3969
- * capture reversible with cancel/refund, manual reversal only, then cash /
3970
- * IDLE last.
3971
- */
3972
- const chargeRank = (tender, adapter) => {
3973
- if (tender.method === "CASH") return 3;
3974
- if (adapter.capabilities?.deferredCapture) return 0;
3975
- if (adapter.cancel || adapter.refund) return 1;
3976
- return 2;
3869
+ const isPendingConfirm = (tender) => {
3870
+ const confirm = record(tender.metadata).confirm_after_inject === true;
3871
+ return tender.status === "PENDING" && confirm;
3977
3872
  };
3978
- //#endregion
3979
- //#region src/order/payments/orchestrator.ts
3980
- const AMBIGUOUS_QUERIES = 3;
3981
- const defaultSleep = (ms, signal) => {
3982
- return new Promise((resolve) => {
3983
- const timer = setTimeout(resolve, ms);
3984
- signal?.addEventListener("abort", () => {
3985
- clearTimeout(timer);
3986
- resolve();
3987
- });
3873
+ /** Live tenders of the current intent that hold the customer's money. */
3874
+ const chargedTenders = (intents) => {
3875
+ return liveTenders(currentIntent(intents)).filter((tender) => {
3876
+ return isTenderSettled(tender) || isPendingConfirm(tender);
3988
3877
  });
3989
3878
  };
3990
- const defaultClock = {
3991
- now: () => Date.now(),
3992
- sleep: defaultSleep
3879
+ /** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
3880
+ const coveredAmount = (intents) => {
3881
+ return chargedTenders(intents).reduce((sum, tender) => {
3882
+ return sum + tender.amount.total;
3883
+ }, 0);
3993
3884
  };
3994
- var TimeoutSignal = class extends Error {};
3995
- /**
3996
- * Provider deadline that pauses while the customer is answering `ctx.ui`;
3997
- * user waits run on `interaction_timeout_ms` instead.
3998
- */
3999
- const createDeadline = (timeoutMs, onExpire) => {
4000
- let remaining = timeoutMs;
4001
- let startedAt = 0;
4002
- let timer = null;
4003
- let paused = 0;
4004
- const run = () => {
4005
- startedAt = Date.now();
4006
- timer = setTimeout(onExpire, Math.max(remaining, 0));
4007
- };
4008
- const stop = () => {
4009
- if (!timer) return;
4010
- clearTimeout(timer);
4011
- timer = null;
4012
- remaining -= Date.now() - startedAt;
4013
- };
3885
+ const remainingAmount = (order) => {
3886
+ const decimals = currencyDecimals(order.totals.currency);
3887
+ const left = order.totals.total - coveredAmount(order.payment_intents);
3888
+ return Math.max(roundTo(left, decimals), 0);
3889
+ };
3890
+ const isPlaced = (order) => order.status !== "OPEN";
3891
+ /** Orchestrator failures carry the charge-currency amount, not the order's. */
3892
+ const withOrderAmount = (result, amount) => {
3893
+ if (result.status !== "FAILED") return result;
4014
3894
  return {
4015
- start: run,
4016
- pause: () => {
4017
- if (paused === 0) stop();
4018
- paused += 1;
4019
- },
4020
- resume: () => {
4021
- paused -= 1;
4022
- if (paused === 0) run();
4023
- },
4024
- clear: stop
3895
+ ...result,
3896
+ amount
4025
3897
  };
4026
3898
  };
4027
- const withUserTimeout = (promise, ms) => {
4028
- return new Promise((resolve, reject) => {
4029
- const timer = setTimeout(() => {
4030
- reject(new PaymentUserTimeout());
4031
- }, ms);
4032
- promise.then((value) => {
4033
- clearTimeout(timer);
4034
- resolve(value);
4035
- }, (error) => {
4036
- clearTimeout(timer);
4037
- reject(error);
4038
- });
4039
- });
3899
+ const partialOf = (result) => {
3900
+ return record(result.metadata).partial_approval !== void 0;
4040
3901
  };
4041
- var PaymentUserTimeout = class extends Error {};
4042
- const timedUI = (ui, deadline, interactionMs) => {
4043
- const wrap = (call) => {
4044
- deadline.pause();
4045
- return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
4046
- };
3902
+ /** D31: PAID for less than the tender (same currency only). */
3903
+ const isPartialApproval = (result, tender, ctx) => {
3904
+ if (result.status !== "PAID") return false;
3905
+ if (ctx.amount.currency !== tender.amount.currency) return false;
3906
+ const tolerance = amountTolerance(tender.amount.currency);
3907
+ return result.amount + tolerance < tender.amount.total;
3908
+ };
3909
+ const toFailure = (error) => {
3910
+ const message = error instanceof Error ? error.message : "Terminal failed";
4047
3911
  return {
4048
- message: (input) => ui.message(input),
4049
- action: (input) => ui.action(input),
4050
- select: (input) => {
4051
- return wrap(() => ui.select(input));
4052
- },
4053
- input: (input) => wrap(() => ui.input(input)),
4054
- confirm: (input) => wrap(() => ui.confirm(input))
3912
+ ok: false,
3913
+ message,
3914
+ error: createPaymentError("UNKNOWN", { provider_message: message })
4055
3915
  };
4056
3916
  };
4057
- /** Runs one adapter call with the provider timeout and the user clock. */
4058
- const runTimed = async (ctx, call) => {
4059
- const controller = new AbortController();
4060
- const parent = ctx.signal;
4061
- const abort = () => controller.abort();
4062
- parent.addEventListener("abort", abort);
4063
- let expire = () => void 0;
4064
- const expired = new Promise((_, reject) => {
4065
- expire = () => {
4066
- controller.abort();
4067
- reject(new TimeoutSignal());
3917
+ /**
3918
+ * Payment state of one order: adapters, device settings and the attempt
3919
+ * orchestration. The app only provides adapters, UI and storage.
3920
+ */
3921
+ var PaymentSession = class {
3922
+ deps;
3923
+ adapters = /* @__PURE__ */ new Map();
3924
+ payments = /* @__PURE__ */ new Map();
3925
+ ui = noopUI;
3926
+ storage = memoryPaymentStorage();
3927
+ options = {};
3928
+ constructor(deps) {
3929
+ this.deps = deps;
3930
+ }
3931
+ setAdapters(adapters) {
3932
+ this.adapters.clear();
3933
+ for (const adapter of adapters) this.adapters.set(adapterKey(adapter.provider, adapter.method), adapter);
3934
+ }
3935
+ setUI(ui) {
3936
+ this.ui = ui;
3937
+ }
3938
+ setStorage(storage) {
3939
+ this.storage = storage;
3940
+ }
3941
+ setOptions(options) {
3942
+ this.options = {
3943
+ ...this.options,
3944
+ ...options
4068
3945
  };
4069
- });
4070
- const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
4071
- const interaction = ctx.policy.interaction_timeout_ms;
4072
- const ui = timedUI(ctx.ui, deadline, interaction);
4073
- const scoped = {
4074
- ...ctx,
4075
- ui,
4076
- signal: controller.signal
4077
- };
4078
- deadline.start();
4079
- try {
4080
- return await Promise.race([call(scoped), expired]);
4081
- } finally {
4082
- deadline.clear();
4083
- parent.removeEventListener("abort", abort);
3946
+ captureLogDevice(options.deviceId);
4084
3947
  }
4085
- };
4086
- const failed = (ctx, error, extra = {}) => {
4087
- const metadata = {
4088
- attempt_reference: ctx.attemptReference,
4089
- error,
4090
- ...extra
4091
- };
4092
- return {
4093
- status: "FAILED",
4094
- amount: ctx.amount.value,
4095
- metadata,
4096
- error
4097
- };
4098
- };
4099
- const errorOf = (ctx, error, startedAt, clock) => {
4100
- const elapsed_ms = clock.now() - startedAt;
4101
- const attempt = ctx.attempt;
4102
- if (isPaymentError(error)) return {
4103
- ...error.error,
4104
- attempt,
4105
- elapsed_ms
4106
- };
4107
- let code = "UNKNOWN";
4108
- if (error instanceof TimeoutSignal) code = "TIMEOUT";
4109
- if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
4110
- const extra = {
4111
- attempt,
4112
- elapsed_ms,
4113
- provider_message: error instanceof Error ? error.message : null
4114
- };
4115
- return createPaymentError(code, extra);
4116
- };
4117
- /** `start` that timed out after leaving the device is ambiguous. */
4118
- const startError = (error) => {
4119
- if (error.code !== "TIMEOUT") return error;
4120
- return createPaymentError("AMBIGUOUS", {
4121
- attempt: error.attempt,
4122
- elapsed_ms: error.elapsed_ms
4123
- });
4124
- };
4125
- const isFinal = (result) => result.status !== "PENDING";
4126
- const callOperation = async (ctx, operation, result, clock) => {
4127
- const startedAt = clock.now();
4128
- try {
4129
- return await runTimed(ctx, (scoped) => operation(scoped, result));
4130
- } catch (error) {
4131
- return failed(ctx, errorOf(ctx, error, startedAt, clock));
3948
+ adapterFor(payment) {
3949
+ const key = adapterKey(payment.provider.provider_id, payment.method.method);
3950
+ return this.adapters.get(key) ?? null;
4132
3951
  }
4133
- };
4134
- /** Query up to 3 times, cancel once, else FAILED + needs_review. */
4135
- const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
4136
- if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
4137
- await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
4138
- const next = await callOperation(ctx, adapter.query, pending, clock);
4139
- if (next.status === "PAID") return next;
4140
- const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
4141
- if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
3952
+ /** Device fields still missing for a payment on this device. */
3953
+ missingFields(payment) {
3954
+ const adapter = this.adapterFor(payment);
3955
+ if (!adapter) return [];
3956
+ const fromCatalog = record(payment.method.config).device_fields;
3957
+ const fields = adapter.deviceFields ?? fromCatalog;
3958
+ const settings = this.options.deviceSettings?.[payment.id];
3959
+ return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
4142
3960
  }
4143
- if (adapter.cancel) {
4144
- const cancelled = await callOperation(ctx, adapter.cancel, pending, clock);
4145
- if (cancelled.status === "CANCELLED") return cancelled;
3961
+ /**
3962
+ * Merchant payments with an installed adapter (provider + method) that
3963
+ * are fully configured on this device.
3964
+ */
3965
+ available(payments) {
3966
+ return payments.filter((payment) => {
3967
+ if (!this.adapterFor(payment)) return false;
3968
+ this.payments.set(payment.id, payment);
3969
+ return this.missingFields(payment).length === 0;
3970
+ });
4146
3971
  }
4147
- const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
4148
- return failed(ctx, error, { needs_review: true });
4149
- };
4150
- /** PENDING with an action: poll until paid, rejected or the QR expires. */
4151
- const pollPending = async (adapter, ctx, pending, clock, options) => {
4152
- if (pending.metadata.confirm_after_inject) return pending;
4153
- if (!adapter.query) return pending;
4154
- const expiresAt = clock.now() + ctx.policy.qr_ttl_ms;
4155
- let current = pending;
4156
- while (clock.now() < expiresAt) {
4157
- if (ctx.signal.aborted) break;
4158
- await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
4159
- if (ctx.signal.aborted) break;
4160
- const next = await callOperation(ctx, adapter.query, current, clock);
4161
- if (next.status === "FAILED" && next.error?.retryable) continue;
4162
- if (isFinal(next)) return next;
4163
- current = next;
4164
- await options.onProgress?.(current);
3972
+ remaining() {
3973
+ return remainingAmount(this.deps.getOrder());
4165
3974
  }
4166
- if (adapter.cancel) await callOperation(ctx, adapter.cancel, current, clock);
4167
- const code = ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED";
4168
- return failed(ctx, createPaymentError(code, { attempt: ctx.attempt }));
4169
- };
4170
- const runStart = async (adapter, ctx, clock) => {
4171
- const startedAt = clock.now();
4172
- try {
4173
- return await runTimed(ctx, (scoped) => adapter.start(scoped));
4174
- } catch (error) {
4175
- const raw = errorOf(ctx, error, startedAt, clock);
4176
- return failed(ctx, startError(raw));
3975
+ server(payment, attempt) {
3976
+ const order_id = this.deps.getOrder().id;
3977
+ const http = this.deps.http;
3978
+ return { call: (operation, input) => {
3979
+ const body = {
3980
+ order_id,
3981
+ attempt_reference: attempt,
3982
+ input
3983
+ };
3984
+ const op = services.payments.operation;
3985
+ return op(payment.id, operation, body, http);
3986
+ } };
4177
3987
  }
4178
- };
4179
- const toTerminalContext = (ctx) => {
4180
- const b1 = {
4181
- paymentId: ctx.paymentId,
4182
- attempt: ctx.attempt
4183
- };
4184
- const b2 = { attemptReference: ctx.attemptReference };
4185
- const b3 = {
4186
- environment: ctx.environment,
4187
- config: ctx.config
4188
- };
4189
- const b4 = {
4190
- deviceSettings: ctx.deviceSettings,
4191
- policy: ctx.policy
4192
- };
4193
- const b5 = {
4194
- ui: ctx.ui,
4195
- storage: ctx.storage,
4196
- server: ctx.server
4197
- };
4198
- return {
4199
- ...b1,
4200
- ...b2,
4201
- ...b3,
4202
- ...b4,
4203
- ...b5,
4204
- signal: ctx.signal
4205
- };
4206
- };
4207
- const closeBatch = async (adapter, ctx) => {
4208
- const close = adapter.terminal?.closeBatch;
4209
- if (!close) return false;
4210
- const done = await close(toTerminalContext(ctx)).catch(() => null);
4211
- return Boolean(done?.ok);
4212
- };
4213
- /**
4214
- * One payment attempt with the policy of section 7: timeout, retries on
4215
- * retryable errors (same `attempt_reference`), batch close and retry,
4216
- * polling for actions and resolution of ambiguous results.
4217
- */
4218
- const runPaymentAttempt = async (adapter, ctx, options = {}) => {
4219
- const clock = options.clock ?? defaultClock;
4220
- return withTerminalLock(ctx.paymentId, async () => {
4221
- let batchClosed = false;
4222
- let attempt = 1;
4223
- while (true) {
4224
- const current = {
4225
- ...ctx,
4226
- attempt
3988
+ async chargeAmount(payment, value, server) {
3989
+ const config = record(payment.config);
3990
+ const orderCurrency = this.deps.getOrder().totals.currency;
3991
+ const chargeCurrency = config.charge_currency;
3992
+ if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
3993
+ value,
3994
+ orderCurrency
3995
+ });
3996
+ const exchange = record(config.exchange_rate);
3997
+ let rate = typeof exchange.rate === "number" ? exchange.rate : null;
3998
+ if (exchange.source === "provider") {
3999
+ const input = {
4000
+ from: orderCurrency,
4001
+ to: chargeCurrency
4227
4002
  };
4228
- const result = await runStart(adapter, current, clock);
4229
- const code = result.error?.code;
4230
- if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
4231
- batchClosed = true;
4232
- if (await closeBatch(adapter, current)) continue;
4233
- }
4234
- if (code === "AMBIGUOUS") {
4235
- await options.onProgress?.({
4236
- ...result,
4237
- status: "PENDING"
4238
- });
4239
- return resolveAmbiguous(adapter, current, result, clock);
4240
- }
4241
- const canRetry = attempt < ctx.policy.max_attempts;
4242
- if (result.status === "FAILED" && result.error?.retryable && canRetry) {
4243
- attempt += 1;
4244
- continue;
4245
- }
4246
- if (result.status !== "PENDING") return result;
4247
- await options.onProgress?.(result);
4248
- return pollPending(adapter, current, result, clock, options);
4003
+ rate = (await server.call("exchange_rate", input)).rate;
4249
4004
  }
4250
- });
4251
- };
4252
- /** Undo a charge after a failed injection or an incomplete split payment. */
4253
- const reversePayment = async (adapter, ctx, result, options = {}) => {
4254
- const clock = options.clock ?? defaultClock;
4255
- const now = options.now ?? new Date(clock.now());
4256
- let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
4257
- const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
4258
- if (kind !== "manual" && !operation) kind = "manual";
4259
- if (kind === "manual" || !operation) {
4260
- const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
4261
- const m1 = {
4262
- ...result.metadata,
4263
- error,
4264
- needs_review: true
4005
+ return toChargeAmount({
4006
+ value,
4007
+ orderCurrency,
4008
+ chargeCurrency,
4009
+ rate
4010
+ });
4011
+ }
4012
+ async buildContext(payment, amount, attemptReference, signal) {
4013
+ const order = this.deps.getOrder();
4014
+ const server = this.server(payment, attemptReference);
4015
+ const conversion = await this.chargeAmount(payment, amount, server);
4016
+ const config = record(payment.config);
4017
+ const methodConfig = record(payment.method.config);
4018
+ const share = order.totals.total > 0 ? amount / order.totals.total : 1;
4019
+ const decimals = currencyDecimals(order.totals.currency);
4020
+ const taxes = taxBreakdown(order.lines, {
4021
+ share,
4022
+ decimals
4023
+ });
4024
+ const info = order.customer.customer_info;
4025
+ const customer = {
4026
+ document: info.document_number ?? null,
4027
+ documentType: info.document_type ?? null,
4028
+ phone: info.phone ?? null
4265
4029
  };
4266
- return {
4267
- kind: "manual",
4268
- result: {
4269
- ...result,
4270
- metadata: m1,
4271
- error
4272
- }
4273
- };
4274
- }
4275
- const run = () => callOperation(ctx, operation, result, clock);
4276
- const next = await withTerminalLock(ctx.paymentId, run);
4277
- return {
4278
- kind,
4279
- result: next
4280
- };
4281
- };
4282
- //#endregion
4283
- //#region src/order/payments/session.ts
4284
- const noopUI = {
4285
- message: () => void 0,
4286
- action: () => void 0,
4287
- select: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
4288
- input: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
4289
- confirm: () => Promise.resolve(false)
4290
- };
4291
- const adapterKey = (provider, method) => {
4292
- return `${provider}:${method}`;
4293
- };
4294
- const record = (value) => {
4295
- if (!value || typeof value !== "object") return {};
4296
- return value;
4297
- };
4298
- const isPendingConfirm = (tender) => {
4299
- const confirm = record(tender.metadata).confirm_after_inject === true;
4300
- return tender.status === "PENDING" && confirm;
4301
- };
4302
- /** Live tenders of the current intent that hold the customer's money. */
4303
- const chargedTenders = (intents) => {
4304
- return liveTenders(currentIntent(intents)).filter((tender) => {
4305
- return isTenderSettled(tender) || isPendingConfirm(tender);
4306
- });
4307
- };
4308
- /** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
4309
- const coveredAmount = (intents) => {
4310
- return chargedTenders(intents).reduce((sum, tender) => {
4311
- return sum + tender.amount.total;
4312
- }, 0);
4313
- };
4314
- const remainingAmount = (order) => {
4315
- const decimals = currencyDecimals(order.totals.currency);
4316
- const left = order.totals.total - coveredAmount(order.payment_intents);
4317
- return Math.max(roundTo(left, decimals), 0);
4318
- };
4319
- const isPlaced = (order) => order.status !== "OPEN";
4320
- /** Orchestrator failures carry the charge-currency amount, not the order's. */
4321
- const withOrderAmount = (result, amount) => {
4322
- if (result.status !== "FAILED") return result;
4323
- return {
4324
- ...result,
4325
- amount
4326
- };
4327
- };
4328
- const partialOf = (result) => {
4329
- return record(result.metadata).partial_approval !== void 0;
4330
- };
4331
- /** D31: PAID for less than the tender (same currency only). */
4332
- const isPartialApproval = (result, tender, ctx) => {
4333
- if (result.status !== "PAID") return false;
4334
- if (ctx.amount.currency !== tender.amount.currency) return false;
4335
- const tolerance = amountTolerance(tender.amount.currency);
4336
- return result.amount + tolerance < tender.amount.total;
4337
- };
4338
- const toFailure = (error) => {
4339
- const message = error instanceof Error ? error.message : "Terminal failed";
4340
- return {
4341
- ok: false,
4342
- message,
4343
- error: createPaymentError("UNKNOWN", { provider_message: message })
4344
- };
4345
- };
4346
- /**
4347
- * Payment state of one order: adapters, device settings and the attempt
4348
- * orchestration. The app only provides adapters, UI and storage.
4349
- */
4350
- var PaymentSession = class {
4351
- deps;
4352
- adapters = /* @__PURE__ */ new Map();
4353
- payments = /* @__PURE__ */ new Map();
4354
- ui = noopUI;
4355
- storage = memoryPaymentStorage();
4356
- options = {};
4357
- constructor(deps) {
4358
- this.deps = deps;
4359
- }
4360
- setAdapters(adapters) {
4361
- this.adapters.clear();
4362
- for (const adapter of adapters) this.adapters.set(adapterKey(adapter.provider, adapter.method), adapter);
4363
- }
4364
- setUI(ui) {
4365
- this.ui = ui;
4366
- }
4367
- setStorage(storage) {
4368
- this.storage = storage;
4369
- }
4370
- setOptions(options) {
4371
- this.options = {
4372
- ...this.options,
4373
- ...options
4374
- };
4375
- captureLogDevice(options.deviceId);
4376
- }
4377
- adapterFor(payment) {
4378
- const key = adapterKey(payment.provider.provider_id, payment.method.method);
4379
- return this.adapters.get(key) ?? null;
4380
- }
4381
- /** Device fields still missing for a payment on this device. */
4382
- missingFields(payment) {
4383
- const adapter = this.adapterFor(payment);
4384
- if (!adapter) return [];
4385
- const fromCatalog = record(payment.method.config).device_fields;
4386
- const fields = adapter.deviceFields ?? fromCatalog;
4387
- const settings = this.options.deviceSettings?.[payment.id];
4388
- return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
4389
- }
4390
- /**
4391
- * Merchant payments with an installed adapter (provider + method) that
4392
- * are fully configured on this device.
4393
- */
4394
- available(payments) {
4395
- return payments.filter((payment) => {
4396
- if (!this.adapterFor(payment)) return false;
4397
- this.payments.set(payment.id, payment);
4398
- return this.missingFields(payment).length === 0;
4399
- });
4400
- }
4401
- remaining() {
4402
- return remainingAmount(this.deps.getOrder());
4403
- }
4404
- server(payment, attempt) {
4405
- const order_id = this.deps.getOrder().id;
4406
- const http = this.deps.http;
4407
- return { call: (operation, input) => {
4408
- const body = {
4409
- order_id,
4410
- attempt_reference: attempt,
4411
- input
4412
- };
4413
- const op = services.payments.operation;
4414
- return op(payment.id, operation, body, http);
4415
- } };
4416
- }
4417
- async chargeAmount(payment, value, server) {
4418
- const config = record(payment.config);
4419
- const orderCurrency = this.deps.getOrder().totals.currency;
4420
- const chargeCurrency = config.charge_currency;
4421
- if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
4422
- value,
4423
- orderCurrency
4424
- });
4425
- const exchange = record(config.exchange_rate);
4426
- let rate = typeof exchange.rate === "number" ? exchange.rate : null;
4427
- if (exchange.source === "provider") {
4428
- const input = {
4429
- from: orderCurrency,
4430
- to: chargeCurrency
4431
- };
4432
- rate = (await server.call("exchange_rate", input)).rate;
4433
- }
4434
- return toChargeAmount({
4435
- value,
4436
- orderCurrency,
4437
- chargeCurrency,
4438
- rate
4439
- });
4440
- }
4441
- async buildContext(payment, amount, attemptReference, signal) {
4442
- const order = this.deps.getOrder();
4443
- const server = this.server(payment, attemptReference);
4444
- const conversion = await this.chargeAmount(payment, amount, server);
4445
- const config = record(payment.config);
4446
- const methodConfig = record(payment.method.config);
4447
- const share = order.totals.total > 0 ? amount / order.totals.total : 1;
4448
- const decimals = currencyDecimals(order.totals.currency);
4449
- const taxes = taxBreakdown(order.lines, {
4450
- share,
4451
- decimals
4452
- });
4453
- const info = order.customer.customer_info;
4454
- const customer = {
4455
- document: info.document_number ?? null,
4456
- documentType: info.document_type ?? null,
4457
- phone: info.phone ?? null
4458
- };
4459
- const environment = resolveProviderEnvironment({
4460
- isProduction: this.options.isProduction ?? false,
4461
- paymentConfig: config
4462
- });
4463
- const policy = resolvePaymentPolicy({
4464
- methodConfig,
4465
- paymentConfig: config
4466
- });
4467
- const prefix = `payments:${payment.id}`;
4468
- const storage = scopedPaymentStorage(this.storage, prefix);
4469
- const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
4470
- const totals = order.totals;
4471
- const o1 = {
4472
- id: order.id,
4473
- currency: totals.currency,
4474
- totals
4030
+ const environment = resolveProviderEnvironment({
4031
+ isProduction: this.options.isProduction ?? false,
4032
+ paymentConfig: config
4033
+ });
4034
+ const policy = resolvePaymentPolicy({
4035
+ methodConfig,
4036
+ paymentConfig: config
4037
+ });
4038
+ const prefix = `payments:${payment.id}`;
4039
+ const storage = scopedPaymentStorage(this.storage, prefix);
4040
+ const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
4041
+ const totals = order.totals;
4042
+ const o1 = {
4043
+ id: order.id,
4044
+ currency: totals.currency,
4045
+ totals
4475
4046
  };
4476
4047
  const b1 = {
4477
4048
  paymentId: payment.id,
@@ -4875,215 +4446,648 @@ var PaymentSession = class {
4875
4446
  closeBatch: terminal.closeBatch,
4876
4447
  status: terminal.status
4877
4448
  };
4878
- const named = {
4879
- ...base,
4880
- ...more
4449
+ const named = {
4450
+ ...base,
4451
+ ...more
4452
+ };
4453
+ const extra = terminal.extra?.find((item) => item.key === key)?.run;
4454
+ const operation = named[key] ?? extra;
4455
+ if (!operation) return {
4456
+ ok: false,
4457
+ message: `Unknown operation ${key}`
4458
+ };
4459
+ const signal = new AbortController().signal;
4460
+ const reference = createAttemptReference();
4461
+ const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
4462
+ if (!result.ok) await this.reportTerminal(payment, key, result);
4463
+ return result;
4464
+ }
4465
+ async reportTerminal(payment, operation, result) {
4466
+ const fallback = createPaymentError("UNKNOWN");
4467
+ const error = result.error ?? {
4468
+ ...fallback,
4469
+ message: result.message ?? fallback.message
4470
+ };
4471
+ const body = {
4472
+ operation,
4473
+ device_id: this.options.deviceId ?? null,
4474
+ error
4475
+ };
4476
+ const report = services.payments.terminalFailure;
4477
+ await report(payment.id, body, this.deps.http).catch(() => void 0);
4478
+ }
4479
+ };
4480
+ //#endregion
4481
+ //#region src/schedule/schedule.ts
4482
+ const DAY_OF_WEEK = [
4483
+ "sunday",
4484
+ "monday",
4485
+ "tuesday",
4486
+ "wednesday",
4487
+ "thursday",
4488
+ "friday",
4489
+ "saturday"
4490
+ ];
4491
+ const timeToMinutes = (time) => {
4492
+ const [hours = "0", minutes = "0"] = time.split(":");
4493
+ return Number(hours) * 60 + Number(minutes);
4494
+ };
4495
+ const hasSpecificDates = (period) => {
4496
+ return period.start_date != null && period.end_date != null;
4497
+ };
4498
+ const periodCoversDate = (period, now) => {
4499
+ if (!hasSpecificDates(period)) return false;
4500
+ const nowMs = now.getTime();
4501
+ const startMs = Date.parse(period.start_date);
4502
+ const endMs = Date.parse(period.end_date);
4503
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4504
+ return startMs <= nowMs && nowMs <= endMs;
4505
+ };
4506
+ const timeMatches = (period, now) => {
4507
+ if (!period.start_time || !period.end_time) return false;
4508
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
4509
+ const start = timeToMinutes(period.start_time);
4510
+ const end = timeToMinutes(period.end_time);
4511
+ return nowMinutes >= start && nowMinutes < end;
4512
+ };
4513
+ const isClosedAt = (period, now) => {
4514
+ if (!period.closed) return false;
4515
+ if (!period.start_time || !period.end_time) return true;
4516
+ return timeMatches(period, now);
4517
+ };
4518
+ const isOpenAt = (period, now) => {
4519
+ if (period.closed) return false;
4520
+ return timeMatches(period, now);
4521
+ };
4522
+ const isPeriodsActiveAt = (periods, now) => {
4523
+ return getOpenPeriodAt(periods, now) != null;
4524
+ };
4525
+ const getOpenPeriodAt = (periods, now) => {
4526
+ if (periods.length === 0) return null;
4527
+ const coveringSpecific = periods.filter((period) => {
4528
+ return periodCoversDate(period, now);
4529
+ });
4530
+ const fallback = periods.filter((period) => !hasSpecificDates(period));
4531
+ const specificOpens = coveringSpecific.filter((period) => !period.closed);
4532
+ if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
4533
+ if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
4534
+ if (fallback.some((period) => isClosedAt(period, now))) return null;
4535
+ return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
4536
+ };
4537
+ const atTimeOnDate = (day, time) => {
4538
+ const [hours = "0", minutes = "0"] = time.split(":");
4539
+ const next = new Date(day);
4540
+ next.setHours(Number(hours), Number(minutes), 0, 0);
4541
+ return next;
4542
+ };
4543
+ const isWithinPeriodDates = (period, instantMs) => {
4544
+ if (!hasSpecificDates(period)) return true;
4545
+ const startMs = Date.parse(period.start_date);
4546
+ const endMs = Date.parse(period.end_date);
4547
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4548
+ return startMs <= instantMs && instantMs <= endMs;
4549
+ };
4550
+ const dayPeriods = (schedule, instant) => {
4551
+ const day = DAY_OF_WEEK[instant.getDay()];
4552
+ if (!day) return [];
4553
+ return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
4554
+ };
4555
+ const getActiveUntil = (periods, now) => {
4556
+ const open = getOpenPeriodAt(periods, now);
4557
+ if (!open?.end_time) return null;
4558
+ let untilMs = atTimeOnDate(now, open.end_time).getTime();
4559
+ const openIsSpecific = hasSpecificDates(open);
4560
+ for (const period of periods) {
4561
+ if (!period.closed || !period.start_time) continue;
4562
+ const closeMs = atTimeOnDate(now, period.start_time).getTime();
4563
+ if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
4564
+ if (!isWithinPeriodDates(period, closeMs)) continue;
4565
+ if (!hasSpecificDates(period) && openIsSpecific) continue;
4566
+ untilMs = closeMs;
4567
+ }
4568
+ return new Date(untilMs);
4569
+ };
4570
+ const getNextStart = (schedule, now) => {
4571
+ const nowMs = now.getTime();
4572
+ let nextStartMs = null;
4573
+ const consider = (candidate, periods) => {
4574
+ const startMs = candidate.getTime();
4575
+ if (startMs <= nowMs) return;
4576
+ if (!isPeriodsActiveAt(periods, candidate)) return;
4577
+ if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
4578
+ };
4579
+ for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
4580
+ const candidateDate = new Date(now);
4581
+ candidateDate.setDate(candidateDate.getDate() + dayOffset);
4582
+ candidateDate.setSeconds(0, 0);
4583
+ const periods = dayPeriods(schedule, candidateDate);
4584
+ if (periods.length === 0) continue;
4585
+ for (const period of periods) {
4586
+ if (period.closed || !period.start_time) continue;
4587
+ const start = atTimeOnDate(candidateDate, period.start_time);
4588
+ if (!isWithinPeriodDates(period, start.getTime())) continue;
4589
+ consider(start, periods);
4590
+ }
4591
+ for (const period of periods) {
4592
+ if (!period.closed || !period.end_time) continue;
4593
+ const reopen = atTimeOnDate(candidateDate, period.end_time);
4594
+ if (!isWithinPeriodDates(period, reopen.getTime())) continue;
4595
+ consider(reopen, periods);
4596
+ }
4597
+ }
4598
+ return nextStartMs === null ? null : new Date(nextStartMs);
4599
+ };
4600
+ /** Whether `schedule` is in hours now, and until / next open. */
4601
+ const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
4602
+ if (schedule == null) return {
4603
+ active: true,
4604
+ until: null
4605
+ };
4606
+ const periods = dayPeriods(schedule, now);
4607
+ if (isPeriodsActiveAt(periods, now)) return {
4608
+ active: true,
4609
+ until: getActiveUntil(periods, now)
4610
+ };
4611
+ return {
4612
+ active: false,
4613
+ next: getNextStart(schedule, now)
4614
+ };
4615
+ };
4616
+ const isOnSchedule = (schedule, now) => {
4617
+ return getScheduleWindow(schedule, now).active;
4618
+ };
4619
+ //#endregion
4620
+ //#region src/order/recommendations.ts
4621
+ const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
4622
+ const linkedRecommendationIds = (pricing) => {
4623
+ return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
4624
+ };
4625
+ const collectRecommendations = (config) => {
4626
+ const { lines, lookup, quantity, types } = config;
4627
+ if (quantity <= 0) return [];
4628
+ const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
4629
+ const roots = lines.filter((line) => line.parent_uid == null);
4630
+ const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
4631
+ const cartKeys = new Set(fingerprints);
4632
+ const seenTargetIds = /* @__PURE__ */ new Set();
4633
+ const products = [];
4634
+ for (const root of roots) {
4635
+ const source = lookup.productsById[root.id];
4636
+ if (!source) continue;
4637
+ for (const recommendationId of linkedRecommendationIds(source.pricing)) {
4638
+ const recommendation = lookup.recommendationsById[recommendationId];
4639
+ if (!recommendation || !typeSet.has(recommendation.type)) continue;
4640
+ const targetId = recommendation.target_product_id;
4641
+ if (seenTargetIds.has(targetId)) continue;
4642
+ if (cartKeys.has(bareProductFingerprint(targetId))) continue;
4643
+ const target = lookup.productsById[targetId];
4644
+ if (!target || target.status !== "ACTIVE") continue;
4645
+ if (!isOnSchedule(target.availability.schedules)) continue;
4646
+ seenTargetIds.add(targetId);
4647
+ products.push(target);
4648
+ if (products.length >= quantity) return products;
4649
+ }
4650
+ }
4651
+ return products;
4652
+ };
4653
+ //#endregion
4654
+ //#region src/order/totals.ts
4655
+ /** Sum root line group totals into order totals (Big.js). */
4656
+ const recomputeTotals = (lines, currency = "USD") => {
4657
+ let subtotal = new Big(0);
4658
+ let discountTotal = new Big(0);
4659
+ let taxTotal = new Big(0);
4660
+ let total = new Big(0);
4661
+ for (const line of lines) {
4662
+ if (line.parent_uid != null) continue;
4663
+ subtotal = subtotal.plus(line.group_subtotal);
4664
+ discountTotal = discountTotal.plus(line.group_discount);
4665
+ taxTotal = taxTotal.plus(line.group_tax_total);
4666
+ total = total.plus(line.group_total);
4667
+ }
4668
+ let taxRate = 0;
4669
+ let discountRate = 0;
4670
+ if (subtotal.gt(0)) {
4671
+ taxRate = taxTotal.div(subtotal).toNumber();
4672
+ discountRate = discountTotal.div(subtotal).toNumber();
4673
+ }
4674
+ const discount_total = discountTotal.toNumber();
4675
+ const tax_total = taxTotal.toNumber();
4676
+ const b1 = {
4677
+ currency,
4678
+ subtotal: subtotal.toNumber()
4679
+ };
4680
+ const b2 = {
4681
+ discount_rate: discountRate,
4682
+ discount_total
4683
+ };
4684
+ const b3 = {
4685
+ tax_rate: taxRate,
4686
+ tax_total,
4687
+ total: total.toNumber()
4688
+ };
4689
+ return {
4690
+ ...b1,
4691
+ ...b2,
4692
+ ...b3
4693
+ };
4694
+ };
4695
+ const emptyTotals = (currency = "USD") => {
4696
+ const b1 = {
4697
+ currency,
4698
+ subtotal: 0,
4699
+ discount_rate: 0,
4700
+ discount_total: 0
4701
+ };
4702
+ const b2 = {
4703
+ tax_rate: 0,
4704
+ tax_total: 0,
4705
+ total: 0
4706
+ };
4707
+ return {
4708
+ ...b1,
4709
+ ...b2
4710
+ };
4711
+ };
4712
+ //#endregion
4713
+ //#region src/order/redemptions.ts
4714
+ const ORDER_KEY = "ORDER";
4715
+ const buildPaymentRedeem = (input) => {
4716
+ const { reward_id, quantity, payment_id, amount, points } = input;
4717
+ if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
4718
+ if (points === void 0 || !(points >= 0)) throw new Error("Redeem with payment_id requires points ≥ 0");
4719
+ const redeem = {
4720
+ reward_id,
4721
+ quantity,
4722
+ payment_id,
4723
+ amount
4724
+ };
4725
+ redeem.points = points;
4726
+ if (input.program !== void 0) redeem.program = input.program;
4727
+ if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
4728
+ return redeem;
4729
+ };
4730
+ /** Same rules as `vOrders.redeem()`; throws on invalid input. */
4731
+ const buildRedeem = (input) => {
4732
+ const { reward_id, quantity, discount_rate, discount_amount } = input;
4733
+ const { before_taxes, expires_at } = input;
4734
+ if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
4735
+ if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
4736
+ if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
4737
+ if (input.payment_id) return buildPaymentRedeem(input);
4738
+ if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
4739
+ const rate = discount_rate ?? 0;
4740
+ if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
4741
+ if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
4742
+ const redeem = {
4743
+ reward_id,
4744
+ quantity
4745
+ };
4746
+ if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
4747
+ if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
4748
+ if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
4749
+ if (expires_at !== void 0) redeem.expires_at = expires_at;
4750
+ return redeem;
4751
+ };
4752
+ /** Payment redeems hold totals, so the kept units keep their share. */
4753
+ const keepUnits = (item, rest) => {
4754
+ if (!isPaymentRedeem(item) || rest === item.quantity) return {
4755
+ ...item,
4756
+ quantity: rest
4757
+ };
4758
+ const ratio = new Big(rest).div(item.quantity);
4759
+ const amount = new Big(item.amount ?? 0).times(ratio).round(6).toNumber();
4760
+ const points = new Big(item.points ?? 0).times(ratio).round(0).toNumber();
4761
+ return {
4762
+ ...item,
4763
+ quantity: rest,
4764
+ amount,
4765
+ points
4766
+ };
4767
+ };
4768
+ /**
4769
+ * Takes `quantity` units of `reward_id` off the latest entries first, so the
4770
+ * earlier redeems keep their step position. Entries that reach 0 are removed.
4771
+ */
4772
+ const subtractRedeem = (items, reward_id, quantity) => {
4773
+ if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
4774
+ let pending = quantity;
4775
+ const kept = [];
4776
+ for (let i = items.length - 1; i >= 0; i--) {
4777
+ const item = items[i];
4778
+ if (pending === 0 || item.reward_id !== reward_id) {
4779
+ kept.unshift(item);
4780
+ continue;
4781
+ }
4782
+ const taken = Math.min(item.quantity, pending);
4783
+ pending -= taken;
4784
+ const rest = item.quantity - taken;
4785
+ if (rest > 0) kept.unshift(keepUnits(item, rest));
4786
+ }
4787
+ return kept;
4788
+ };
4789
+ const redemptionTargetKey = (target) => {
4790
+ if (target.target === "ORDER") return ORDER_KEY;
4791
+ return `LINE:${target.target_id ?? ""}`;
4792
+ };
4793
+ /**
4794
+ * Only root lines own redeems; child-line redeems are never applied.
4795
+ * Payment redeems are skipped on lines and on the order alike.
4796
+ */
4797
+ const redeemPools = (lines, redeem) => {
4798
+ const pools = /* @__PURE__ */ new Map();
4799
+ for (const line of lines) {
4800
+ const items = discountRedeems(line.redeem ?? []);
4801
+ if (line.parent_uid != null || items.length === 0) continue;
4802
+ const target = {
4803
+ target: "LINE",
4804
+ target_id: line.uid
4805
+ };
4806
+ const pool = items.map((item) => ({
4807
+ ...target,
4808
+ redeem: item
4809
+ }));
4810
+ pools.set(redemptionTargetKey(target), pool);
4811
+ }
4812
+ const order = { target: "ORDER" };
4813
+ const pool = discountRedeems(redeem).map((item) => ({
4814
+ ...order,
4815
+ redeem: item
4816
+ }));
4817
+ pools.set(ORDER_KEY, pool);
4818
+ return pools;
4819
+ };
4820
+ /**
4821
+ * Pairs the k-th step of a target with the k-th redeem of that target, in
4822
+ * step order. Steps without a matching redeem are dropped; redeems without a
4823
+ * step are appended (lines first, then order).
4824
+ */
4825
+ const sequenceRedeems = (lines, redeem, steps) => {
4826
+ const pools = redeemPools(lines, redeem);
4827
+ const cursors = /* @__PURE__ */ new Map();
4828
+ const sequence = [];
4829
+ for (const step of steps) {
4830
+ const key = redemptionTargetKey(step);
4831
+ const at = cursors.get(key) ?? 0;
4832
+ const next = pools.get(key)?.[at];
4833
+ if (!next || next.redeem.reward_id !== step.reward_id) continue;
4834
+ cursors.set(key, at + 1);
4835
+ sequence.push(next);
4836
+ }
4837
+ for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
4838
+ return sequence;
4839
+ };
4840
+ const minBig = (a, b) => {
4841
+ return a.lt(b) ? a : b;
4842
+ };
4843
+ const rateOf = (discount, base) => {
4844
+ if (base.lte(0)) return 0;
4845
+ return minBig(discount.div(base), new Big(1)).toNumber();
4846
+ };
4847
+ const snapshot = (roots, quantity) => {
4848
+ let subtotal = new Big(0);
4849
+ let total = new Big(0);
4850
+ let discount = new Big(0);
4851
+ for (const root of roots) {
4852
+ subtotal = subtotal.plus(root.subtotal);
4853
+ total = total.plus(root.total);
4854
+ discount = discount.plus(root.discount);
4855
+ }
4856
+ const b1 = {
4857
+ value: total.toNumber(),
4858
+ quantity
4859
+ };
4860
+ const b2 = { discount_amount: discount.toNumber() };
4861
+ const b3 = { discount_rate: rateOf(discount, subtotal) };
4862
+ return {
4863
+ ...b1,
4864
+ ...b2,
4865
+ ...b3
4866
+ };
4867
+ };
4868
+ const netOf = (root) => {
4869
+ const net = root.total.minus(root.tax);
4870
+ return net.gt(0) ? net : new Big(0);
4871
+ };
4872
+ /** What a redeem can still discount on a root: pre-tax or with taxes. */
4873
+ const baseOf = (root, beforeTaxes) => {
4874
+ return beforeTaxes ? netOf(root) : root.total;
4875
+ };
4876
+ /** Before taxes, taxes shrink with the pre-tax value they were charged on. */
4877
+ const discountRoot = (root, amount, beforeTaxes) => {
4878
+ if (amount.lte(0)) return;
4879
+ root.discount = root.discount.plus(amount);
4880
+ if (!beforeTaxes) {
4881
+ root.total = root.total.minus(amount);
4882
+ return;
4883
+ }
4884
+ const net = netOf(root);
4885
+ const nextNet = net.minus(amount);
4886
+ root.tax = root.tax.times(nextNet).div(net);
4887
+ root.total = nextNet.plus(root.tax);
4888
+ };
4889
+ /** Splits an order discount across roots by what each can still discount. */
4890
+ const discountOrder = (roots, amount, beforeTaxes) => {
4891
+ const bases = roots.map((root) => baseOf(root, beforeTaxes));
4892
+ const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
4893
+ if (sum.lte(0) || amount.lte(0)) return;
4894
+ let last = -1;
4895
+ bases.forEach((base, index) => {
4896
+ if (base.gt(0)) last = index;
4897
+ });
4898
+ let rest = amount;
4899
+ roots.forEach((root, index) => {
4900
+ const base = bases[index];
4901
+ if (base.lte(0)) return;
4902
+ const share = amount.times(base).div(sum);
4903
+ const part = minBig(index === last ? rest : share, base);
4904
+ rest = rest.minus(part);
4905
+ discountRoot(root, part, beforeTaxes);
4906
+ });
4907
+ };
4908
+ /**
4909
+ * Rate compounds once per reward unit over the remaining value; amount is
4910
+ * per unit. With both, the rate discount is capped at `amount × quantity`.
4911
+ */
4912
+ const redeemDiscount = (redeem, remaining) => {
4913
+ const { quantity, discount_rate, discount_amount } = redeem;
4914
+ let cap = null;
4915
+ if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
4916
+ if (discount_rate === void 0) return cap ?? new Big(0);
4917
+ const kept = new Big(1).minus(discount_rate).pow(quantity);
4918
+ const byRate = remaining.times(new Big(1).minus(kept));
4919
+ return cap ? minBig(byRate, cap) : byRate;
4920
+ };
4921
+ /**
4922
+ * Root group money rebuilt from the line itself plus its children, i.e. the
4923
+ * menu price before any redemption. Child lines are never discounted.
4924
+ */
4925
+ const stripRedemptionLines = (lines) => {
4926
+ const children = /* @__PURE__ */ new Map();
4927
+ for (const line of lines) {
4928
+ if (line.parent_uid == null) continue;
4929
+ const list = children.get(line.parent_uid) ?? [];
4930
+ list.push(line);
4931
+ children.set(line.parent_uid, list);
4932
+ }
4933
+ return lines.map((line) => {
4934
+ if (line.parent_uid != null) return line;
4935
+ const qty = new Big(line.quantity);
4936
+ let sub = new Big(0);
4937
+ let disc = new Big(0);
4938
+ let tax = new Big(0);
4939
+ let tot = new Big(0);
4940
+ for (const child of children.get(line.uid) ?? []) {
4941
+ sub = sub.plus(child.group_subtotal);
4942
+ disc = disc.plus(child.group_discount);
4943
+ tax = tax.plus(child.group_tax_total);
4944
+ tot = tot.plus(child.group_total);
4945
+ }
4946
+ const gSub = new Big(line.subtotal).plus(sub.times(qty));
4947
+ const gDisc = new Big(line.discount_total).plus(disc.times(qty));
4948
+ const gTax = new Big(line.tax_total).plus(tax.times(qty));
4949
+ const gTot = new Big(line.total).plus(tot.times(qty));
4950
+ const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
4951
+ const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
4952
+ const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
4953
+ const u4 = { unit_group_total: gTot.div(qty).toNumber() };
4954
+ const g1 = { group_subtotal: gSub.toNumber() };
4955
+ const g2 = { group_discount: gDisc.toNumber() };
4956
+ const g3 = { group_tax_total: gTax.toNumber() };
4957
+ const g4 = { group_total: gTot.toNumber() };
4958
+ const p1 = {
4959
+ ...u1,
4960
+ ...u2,
4961
+ ...u3,
4962
+ ...u4
4963
+ };
4964
+ return {
4965
+ ...line,
4966
+ ...p1,
4967
+ ...g1,
4968
+ ...g2,
4969
+ ...g3,
4970
+ ...g4
4971
+ };
4972
+ });
4973
+ };
4974
+ const withRootMoney = (line, money) => {
4975
+ const qty = new Big(line.quantity);
4976
+ const discount = new Big(line.group_discount).plus(money.discount);
4977
+ const u1 = { unit_group_discount: discount.div(qty).toNumber() };
4978
+ const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
4979
+ const u3 = { unit_group_total: money.total.div(qty).toNumber() };
4980
+ const g1 = { group_discount: discount.toNumber() };
4981
+ const g2 = { group_tax_total: money.tax.toNumber() };
4982
+ const g3 = { group_total: money.total.toNumber() };
4983
+ return {
4984
+ ...line,
4985
+ ...u1,
4986
+ ...u2,
4987
+ ...u3,
4988
+ ...g1,
4989
+ ...g2,
4990
+ ...g3
4991
+ };
4992
+ };
4993
+ /**
4994
+ * Replays every redeem in persisted step order over the root lines' group
4995
+ * money. Line redeems discount their root; order redeems are prorated over
4996
+ * every root. A discount never exceeds what is left on its target.
4997
+ */
4998
+ const replayRedemptions = (lines, redeem, steps) => {
4999
+ const base = stripRedemptionLines(lines);
5000
+ const sequence = sequenceRedeems(base, redeem, steps);
5001
+ const moneyByUid = /* @__PURE__ */ new Map();
5002
+ for (const line of base) {
5003
+ if (line.parent_uid != null) continue;
5004
+ const b1 = { quantity: line.quantity };
5005
+ const b2 = { subtotal: new Big(line.group_subtotal) };
5006
+ const b3 = { tax: new Big(line.group_tax_total) };
5007
+ const b4 = {
5008
+ total: new Big(line.group_total),
5009
+ discount: new Big(0)
5010
+ };
5011
+ moneyByUid.set(line.uid, {
5012
+ ...b1,
5013
+ ...b2,
5014
+ ...b3,
5015
+ ...b4
5016
+ });
5017
+ }
5018
+ const roots = [...moneyByUid.values()];
5019
+ const redemption_steps = sequence.map((item) => {
5020
+ const id = item.target_id;
5021
+ const root = id ? moneyByUid.get(id) : void 0;
5022
+ const beforeTaxes = item.redeem.before_taxes ?? true;
5023
+ const before = snapshot(roots, 1);
5024
+ const targetBefore = root ? snapshot([root], root.quantity) : void 0;
5025
+ let left = new Big(0);
5026
+ if (root) left = baseOf(root, beforeTaxes);
5027
+ else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
5028
+ const raw = redeemDiscount(item.redeem, left);
5029
+ const discount = minBig(raw, left);
5030
+ if (root) discountRoot(root, discount, beforeTaxes);
5031
+ else discountOrder(roots, discount, beforeTaxes);
5032
+ const action = discount.gt(0) ? "UPDATE" : "READ";
5033
+ const { reward_id, quantity } = item.redeem;
5034
+ const b1 = {
5035
+ reward_id,
5036
+ reward_quantity: quantity,
5037
+ target: item.target
5038
+ };
5039
+ const b2 = id ? { target_id: id } : {};
5040
+ const b3 = {
5041
+ action,
5042
+ discount_amount: discount.toNumber()
4881
5043
  };
4882
- const extra = terminal.extra?.find((item) => item.key === key)?.run;
4883
- const operation = named[key] ?? extra;
4884
- if (!operation) return {
4885
- ok: false,
4886
- message: `Unknown operation ${key}`
5044
+ const b4 = {
5045
+ before,
5046
+ after: snapshot(roots, 1)
4887
5047
  };
4888
- const signal = new AbortController().signal;
4889
- const reference = createAttemptReference();
4890
- const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
4891
- if (!result.ok) await this.reportTerminal(payment, key, result);
4892
- return result;
4893
- }
4894
- async reportTerminal(payment, operation, result) {
4895
- const fallback = createPaymentError("UNKNOWN");
4896
- const error = result.error ?? {
4897
- ...fallback,
4898
- message: result.message ?? fallback.message
5048
+ let b5 = {};
5049
+ if (root) b5 = {
5050
+ target_before: targetBefore,
5051
+ target_after: snapshot([root], root.quantity)
4899
5052
  };
4900
- const body = {
4901
- operation,
4902
- device_id: this.options.deviceId ?? null,
4903
- error
5053
+ return {
5054
+ ...b1,
5055
+ ...b2,
5056
+ ...b3,
5057
+ ...b4,
5058
+ ...b5
4904
5059
  };
4905
- const report = services.payments.terminalFailure;
4906
- await report(payment.id, body, this.deps.http).catch(() => void 0);
4907
- }
4908
- };
4909
- //#endregion
4910
- //#region src/schedule/schedule.ts
4911
- const DAY_OF_WEEK = [
4912
- "sunday",
4913
- "monday",
4914
- "tuesday",
4915
- "wednesday",
4916
- "thursday",
4917
- "friday",
4918
- "saturday"
4919
- ];
4920
- const timeToMinutes = (time) => {
4921
- const [hours = "0", minutes = "0"] = time.split(":");
4922
- return Number(hours) * 60 + Number(minutes);
4923
- };
4924
- const hasSpecificDates = (period) => {
4925
- return period.start_date != null && period.end_date != null;
4926
- };
4927
- const periodCoversDate = (period, now) => {
4928
- if (!hasSpecificDates(period)) return false;
4929
- const nowMs = now.getTime();
4930
- const startMs = Date.parse(period.start_date);
4931
- const endMs = Date.parse(period.end_date);
4932
- if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4933
- return startMs <= nowMs && nowMs <= endMs;
4934
- };
4935
- const timeMatches = (period, now) => {
4936
- if (!period.start_time || !period.end_time) return false;
4937
- const nowMinutes = now.getHours() * 60 + now.getMinutes();
4938
- const start = timeToMinutes(period.start_time);
4939
- const end = timeToMinutes(period.end_time);
4940
- return nowMinutes >= start && nowMinutes < end;
4941
- };
4942
- const isClosedAt = (period, now) => {
4943
- if (!period.closed) return false;
4944
- if (!period.start_time || !period.end_time) return true;
4945
- return timeMatches(period, now);
4946
- };
4947
- const isOpenAt = (period, now) => {
4948
- if (period.closed) return false;
4949
- return timeMatches(period, now);
4950
- };
4951
- const isPeriodsActiveAt = (periods, now) => {
4952
- return getOpenPeriodAt(periods, now) != null;
4953
- };
4954
- const getOpenPeriodAt = (periods, now) => {
4955
- if (periods.length === 0) return null;
4956
- const coveringSpecific = periods.filter((period) => {
4957
- return periodCoversDate(period, now);
4958
5060
  });
4959
- const fallback = periods.filter((period) => !hasSpecificDates(period));
4960
- const specificOpens = coveringSpecific.filter((period) => !period.closed);
4961
- if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
4962
- if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
4963
- if (fallback.some((period) => isClosedAt(period, now))) return null;
4964
- return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
4965
- };
4966
- const atTimeOnDate = (day, time) => {
4967
- const [hours = "0", minutes = "0"] = time.split(":");
4968
- const next = new Date(day);
4969
- next.setHours(Number(hours), Number(minutes), 0, 0);
4970
- return next;
4971
- };
4972
- const isWithinPeriodDates = (period, instantMs) => {
4973
- if (!hasSpecificDates(period)) return true;
4974
- const startMs = Date.parse(period.start_date);
4975
- const endMs = Date.parse(period.end_date);
4976
- if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
4977
- return startMs <= instantMs && instantMs <= endMs;
4978
- };
4979
- const dayPeriods = (schedule, instant) => {
4980
- const day = DAY_OF_WEEK[instant.getDay()];
4981
- if (!day) return [];
4982
- return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
4983
- };
4984
- const getActiveUntil = (periods, now) => {
4985
- const open = getOpenPeriodAt(periods, now);
4986
- if (!open?.end_time) return null;
4987
- let untilMs = atTimeOnDate(now, open.end_time).getTime();
4988
- const openIsSpecific = hasSpecificDates(open);
4989
- for (const period of periods) {
4990
- if (!period.closed || !period.start_time) continue;
4991
- const closeMs = atTimeOnDate(now, period.start_time).getTime();
4992
- if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
4993
- if (!isWithinPeriodDates(period, closeMs)) continue;
4994
- if (!hasSpecificDates(period) && openIsSpecific) continue;
4995
- untilMs = closeMs;
4996
- }
4997
- return new Date(untilMs);
4998
- };
4999
- const getNextStart = (schedule, now) => {
5000
- const nowMs = now.getTime();
5001
- let nextStartMs = null;
5002
- const consider = (candidate, periods) => {
5003
- const startMs = candidate.getTime();
5004
- if (startMs <= nowMs) return;
5005
- if (!isPeriodsActiveAt(periods, candidate)) return;
5006
- if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
5061
+ return {
5062
+ lines: base.map((line) => {
5063
+ const money = moneyByUid.get(line.uid);
5064
+ if (!money || money.discount.eq(0)) return line;
5065
+ return withRootMoney(line, money);
5066
+ }),
5067
+ redemption_steps
5007
5068
  };
5008
- for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
5009
- const candidateDate = new Date(now);
5010
- candidateDate.setDate(candidateDate.getDate() + dayOffset);
5011
- candidateDate.setSeconds(0, 0);
5012
- const periods = dayPeriods(schedule, candidateDate);
5013
- if (periods.length === 0) continue;
5014
- for (const period of periods) {
5015
- if (period.closed || !period.start_time) continue;
5016
- const start = atTimeOnDate(candidateDate, period.start_time);
5017
- if (!isWithinPeriodDates(period, start.getTime())) continue;
5018
- consider(start, periods);
5019
- }
5020
- for (const period of periods) {
5021
- if (!period.closed || !period.end_time) continue;
5022
- const reopen = atTimeOnDate(candidateDate, period.end_time);
5023
- if (!isWithinPeriodDates(period, reopen.getTime())) continue;
5024
- consider(reopen, periods);
5025
- }
5026
- }
5027
- return nextStartMs === null ? null : new Date(nextStartMs);
5028
5069
  };
5029
- /** Whether `schedule` is in hours now, and until / next open. */
5030
- const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
5031
- if (schedule == null) return {
5032
- active: true,
5033
- until: null
5034
- };
5035
- const periods = dayPeriods(schedule, now);
5036
- if (isPeriodsActiveAt(periods, now)) return {
5037
- active: true,
5038
- until: getActiveUntil(periods, now)
5039
- };
5070
+ /** Discounted lines, steps and totals, keeping step order. */
5071
+ const recomputeRedemptions = (input) => {
5072
+ const { redeem, currency } = input;
5073
+ const steps = input.redemption_steps;
5074
+ const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
5040
5075
  return {
5041
- active: false,
5042
- next: getNextStart(schedule, now)
5076
+ lines,
5077
+ redemption_steps,
5078
+ totals: recomputeTotals(lines, currency)
5043
5079
  };
5044
5080
  };
5045
- const isOnSchedule = (schedule, now) => {
5046
- return getScheduleWindow(schedule, now).active;
5047
- };
5048
- //#endregion
5049
- //#region src/order/recommendations.ts
5050
- const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
5051
- const linkedRecommendationIds = (pricing) => {
5052
- return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
5053
- };
5054
- const collectRecommendations = (config) => {
5055
- const { lines, lookup, quantity, types } = config;
5056
- if (quantity <= 0) return [];
5057
- const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
5058
- const roots = lines.filter((line) => line.parent_uid == null);
5059
- const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
5060
- const cartKeys = new Set(fingerprints);
5061
- const seenTargetIds = /* @__PURE__ */ new Set();
5062
- const products = [];
5063
- for (const root of roots) {
5064
- const source = lookup.productsById[root.id];
5065
- if (!source) continue;
5066
- for (const recommendationId of linkedRecommendationIds(source.pricing)) {
5067
- const recommendation = lookup.recommendationsById[recommendationId];
5068
- if (!recommendation || !typeSet.has(recommendation.type)) continue;
5069
- const targetId = recommendation.target_product_id;
5070
- if (seenTargetIds.has(targetId)) continue;
5071
- if (cartKeys.has(bareProductFingerprint(targetId))) continue;
5072
- const target = lookup.productsById[targetId];
5073
- if (!target || target.status !== "ACTIVE") continue;
5074
- if (!isOnSchedule(target.availability.schedules)) continue;
5075
- seenTargetIds.add(targetId);
5076
- products.push(target);
5077
- if (products.length >= quantity) return products;
5078
- }
5079
- }
5080
- return products;
5081
- };
5082
5081
  //#endregion
5083
5082
  //#region src/order/Order.ts
5084
5083
  const cloneOrder = (order) => {
5085
5084
  return structuredClone(order);
5086
5085
  };
5086
+ const redeemSignature$1 = (items) => {
5087
+ return items.map((item) => {
5088
+ return `${`${item.reward_id}:${item.quantity}:${item.payment_id ?? ""}`}:${`${item.amount ?? ""}:${item.points ?? ""}`}:${`${item.discount_rate ?? ""}:${item.discount_amount ?? ""}`}`;
5089
+ }).join("|");
5090
+ };
5087
5091
  const buildHttpOptions = (options) => {
5088
5092
  return {
5089
5093
  baseUrl: options.baseUrl,
@@ -5295,6 +5299,12 @@ var Order = class Order {
5295
5299
  /** Real tenders chosen with `setTenders`; REDEMPTION ones come from redeem. */
5296
5300
  realTenders = null;
5297
5301
  redemptionProviders = /* @__PURE__ */ new Map();
5302
+ /**
5303
+ * Redeems as applied, before the D18 cap trims payment amounts. The cap
5304
+ * is re-derived from these on every edit, so a redeem trimmed while the
5305
+ * total was low recovers its amount once the total grows back.
5306
+ */
5307
+ requestedRedeem = null;
5298
5308
  constructor(config, notify) {
5299
5309
  this.draft = cloneOrder(config.order);
5300
5310
  this.synced = cloneOrder(config.order);
@@ -5306,7 +5316,7 @@ var Order = class Order {
5306
5316
  this.flush = createFlushController({
5307
5317
  getDraft: () => this.draft,
5308
5318
  setDraft: (next) => {
5309
- this.draft = next;
5319
+ this.replaceDraft(next);
5310
5320
  },
5311
5321
  getSynced: () => this.synced,
5312
5322
  setSynced: (next) => {
@@ -5326,11 +5336,7 @@ var Order = class Order {
5326
5336
  this.payments = new PaymentSession({
5327
5337
  getOrder: () => this.draft,
5328
5338
  placeOrder: (input) => this.placeOrder(input),
5329
- applyOrder: (next) => {
5330
- this.draft = cloneOrder(next);
5331
- this.synced = cloneOrder(next);
5332
- this.notify();
5333
- },
5339
+ applyOrder: (next) => this.applyServerOrder(next),
5334
5340
  http: this.http
5335
5341
  });
5336
5342
  }
@@ -5508,7 +5514,7 @@ var Order = class Order {
5508
5514
  if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Points / balance redeems apply to the whole order");
5509
5515
  if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
5510
5516
  if (line_uid === void 0) {
5511
- this.commitLines({ redeem: [...this.draft.redeem ?? [], redeem] });
5517
+ this.commitLines({ redeem: [...this.appliedRedeem(), redeem] });
5512
5518
  return;
5513
5519
  }
5514
5520
  const line = this.draft.lines.find((item) => item.uid === line_uid);
@@ -5534,7 +5540,7 @@ var Order = class Order {
5534
5540
  const { reward_id, quantity, line_uid } = input;
5535
5541
  if (line_uid === void 0) {
5536
5542
  const p = [
5537
- this.draft.redeem ?? [],
5543
+ this.appliedRedeem(),
5538
5544
  reward_id,
5539
5545
  quantity
5540
5546
  ];
@@ -5697,13 +5703,17 @@ var Order = class Order {
5697
5703
  };
5698
5704
  await services.orders.validate(body, this.http);
5699
5705
  }
5700
- /** Tenders of the next intent: real ones plus one per payment redeem. */
5706
+ /**
5707
+ * Tenders of the next intent: real ones plus one per payment redeem.
5708
+ * When the payment redeems cover the total, only REDEMPTION tenders.
5709
+ */
5701
5710
  getTenders(payment) {
5702
- const simple = payment ? simpleTenderInput(payment) : null;
5703
- const real = simple ? [simple] : this.realTenders ?? [];
5704
5711
  const redeem = this.draft.redeem ?? [];
5705
5712
  const providers = this.redemptionProviders;
5706
- return [...real, ...redemptionTenderInputs(redeem, providers)];
5713
+ const redemption = redemptionTenderInputs(redeem, providers);
5714
+ if (paymentRedeemsCoverTotal(redeem, this.draft.totals)) return redemption;
5715
+ const simple = payment ? simpleTenderInput(payment) : null;
5716
+ return [...simple ? [simple] : this.realTenders ?? [], ...redemption];
5707
5717
  }
5708
5718
  /**
5709
5719
  * Chooses the real tenders (with allocation for mixed payments) and saves
@@ -5734,10 +5744,18 @@ var Order = class Order {
5734
5744
  return this.getOrder();
5735
5745
  }
5736
5746
  applyServerOrder(next) {
5737
- this.draft = cloneOrder(next);
5747
+ this.replaceDraft(cloneOrder(next));
5738
5748
  this.synced = cloneOrder(next);
5739
5749
  this.notify();
5740
5750
  }
5751
+ /** Keeps the applied redeems while the server holds the same capped ones. */
5752
+ replaceDraft(next) {
5753
+ if (redeemSignature$1(this.draft.redeem ?? []) !== redeemSignature$1(next.redeem ?? [])) this.requestedRedeem = null;
5754
+ this.draft = next;
5755
+ }
5756
+ appliedRedeem() {
5757
+ return this.requestedRedeem ?? this.draft.redeem ?? [];
5758
+ }
5741
5759
  /**
5742
5760
  * Places the order with a DRAFT intent: `intent_id` as is, else the
5743
5761
  * tenders of `getTenders(input.payment)` saved first (simple flow: one
@@ -5863,7 +5881,7 @@ var Order = class Order {
5863
5881
  /** Applies lines/redeem, replaying redemption steps and totals. */
5864
5882
  commitLines(patch) {
5865
5883
  const patched = patch.lines ?? this.draft.lines;
5866
- const applied = patch.redeem ?? this.draft.redeem ?? [];
5884
+ const applied = patch.redeem ?? this.appliedRedeem();
5867
5885
  const steps = this.draft.redemption_steps ?? [];
5868
5886
  const currency = this.draft.totals.currency;
5869
5887
  const { lines, redemption_steps, totals } = recomputeRedemptions({
@@ -5876,6 +5894,7 @@ var Order = class Order {
5876
5894
  lines,
5877
5895
  totals
5878
5896
  }), currency);
5897
+ this.requestedRedeem = applied;
5879
5898
  this.draft = {
5880
5899
  ...this.draft,
5881
5900
  lines,
@@ -6439,7 +6458,10 @@ const redeemSignature = (items) => {
6439
6458
  const stepSignature = (steps) => {
6440
6459
  return steps.map((step) => `${step.reward_id}:${step.reward_quantity}`).join(",");
6441
6460
  };
6442
- /** Every target's redeems must have one step each, in the same order. */
6461
+ /**
6462
+ * Every target's discount redeems must have one step each, in the same
6463
+ * order. Payment redeems are a REDEMPTION tender, never a step (D16).
6464
+ */
6443
6465
  const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
6444
6466
  const byTarget = /* @__PURE__ */ new Map();
6445
6467
  for (const step of steps) {
@@ -6449,7 +6471,7 @@ const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
6449
6471
  const check = (key, items) => {
6450
6472
  const targetSteps = byTarget.get(key) ?? [];
6451
6473
  byTarget.delete(key);
6452
- const expected = redeemSignature(items);
6474
+ const expected = redeemSignature(discountRedeems(items));
6453
6475
  const actual = stepSignature(targetSteps);
6454
6476
  if (expected === actual) return null;
6455
6477
  return {