@fire-spark/core 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +211 -70
  2. package/dist/index.js +472 -462
  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, currentIntent, defaultReversalFor, discountRedeems, groupPaymentRedeems, isDeliveryFee, isPaymentError, isPaymentRedeem, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, money, moneyNumber, moneyZero, orderPaymentRedeems, orderPaymentRedeems as orderPaymentRedeems$1, paymentRedeemsCoverTotal, resolvePaymentPolicy, resolveProviderEnvironment, scopedPaymentStorage, taxBreakdown, toChargeAmount, withTerminalLock } from "@fire-spark/payments";
1
+ import { DIGITAL_COLLECT_AT, PARTIAL_APPROVAL_ERROR, REDEMPTION_METHOD, activeIntent, createAttemptReference, createPaymentError, currentIntent, defaultReversalFor, groupRedeems, hasRedeemPayment, isDeliveryFee, isPaymentError, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, money, moneyMin, moneyNumber, moneyZero, orderRedeems, orderRedeems as orderRedeems$1, redeemsCoverTotal, resolvePaymentPolicy, resolveProviderEnvironment, 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";
@@ -3089,6 +3089,23 @@ const genOrderId = (storeId) => {
3089
3089
  return genId().literal("ord").separator().literal(storeId, "nostore").separator().hash().build();
3090
3090
  };
3091
3091
  //#endregion
3092
+ //#region src/order/OrderPaymentRequiredError.ts
3093
+ /**
3094
+ * `placeOrder` has no real tender (no `payment`, no `setTenders`, no
3095
+ * `intent_id`) and the redeems do not cover the total. Same code as the
3096
+ * API's 422, so callers map both the same way.
3097
+ */
3098
+ var OrderPaymentRequiredError = class extends Error {
3099
+ code = "PAYMENT_REQUIRED";
3100
+ constructor() {
3101
+ super("Place needs a payment method: the redeems do not cover the total");
3102
+ this.name = "OrderPaymentRequiredError";
3103
+ }
3104
+ };
3105
+ const isOrderPaymentRequiredError = (error) => {
3106
+ return error instanceof OrderPaymentRequiredError;
3107
+ };
3108
+ //#endregion
3092
3109
  //#region src/order/retry.ts
3093
3110
  const defaultSleep$1 = (ms) => {
3094
3111
  return new Promise((resolve) => {
@@ -3118,17 +3135,31 @@ var OrderSyncPendingError = class extends Error {
3118
3135
  this.name = "OrderSyncPendingError";
3119
3136
  }
3120
3137
  };
3138
+ /**
3139
+ * The server rejected an order write (POST/PATCH) and the draft was rolled
3140
+ * back to the last synced order. Keeps the API `status`, `code` and
3141
+ * `details`, so code checks on `StorefrontHttpError` still match.
3142
+ */
3143
+ var OrderSyncRejectedError = class extends StorefrontHttpError {
3144
+ constructor(error) {
3145
+ const { message, status, code, details } = error;
3146
+ super(message, status, code, details);
3147
+ this.name = "OrderSyncRejectedError";
3148
+ }
3149
+ };
3121
3150
  const createFlushController = (runtime) => {
3122
3151
  let timer = null;
3123
3152
  let inFlight = false;
3124
3153
  let dirtyDuringFlight = false;
3125
3154
  let current = null;
3126
3155
  let pending = false;
3156
+ let rejected = null;
3127
3157
  const cancel = () => {
3128
3158
  if (timer) clearTimeout(timer);
3129
3159
  timer = null;
3130
3160
  dirtyDuringFlight = false;
3131
3161
  pending = false;
3162
+ rejected = null;
3132
3163
  };
3133
3164
  const patchBody = (order) => {
3134
3165
  const b1 = {
@@ -3211,11 +3242,20 @@ const createFlushController = (runtime) => {
3211
3242
  await flushNow();
3212
3243
  }
3213
3244
  };
3245
+ const throwRejected = () => {
3246
+ if (!rejected) return;
3247
+ const error = rejected;
3248
+ rejected = null;
3249
+ throw error;
3250
+ };
3214
3251
  const settle = async () => {
3215
3252
  await drain();
3216
- if (!pending || runtime.isDeleted()) return;
3253
+ if (runtime.isDeleted()) return;
3254
+ throwRejected();
3255
+ if (!pending) return;
3217
3256
  await flushNow();
3218
3257
  await drain();
3258
+ throwRejected();
3219
3259
  if (pending) throw new OrderSyncPendingError();
3220
3260
  };
3221
3261
  const resume = async () => {
@@ -3239,6 +3279,7 @@ const createFlushController = (runtime) => {
3239
3279
  return services.orders.patch(...p1);
3240
3280
  }, { sleep: runtime.sleep });
3241
3281
  pending = false;
3282
+ rejected = null;
3242
3283
  runtime.setPersisted(true);
3243
3284
  runtime.setSynced(updated);
3244
3285
  const reusedOpen = !wasPersisted && updated.id !== payload.id;
@@ -3273,6 +3314,7 @@ const createFlushController = (runtime) => {
3273
3314
  return;
3274
3315
  }
3275
3316
  pending = false;
3317
+ rejected = new OrderSyncRejectedError(error);
3276
3318
  runtime.setDraft(batchBase);
3277
3319
  runtime.setSynced(batchBase);
3278
3320
  runtime.setPersisted(wasPersisted);
@@ -3306,7 +3348,8 @@ const createFlushController = (runtime) => {
3306
3348
  settle,
3307
3349
  resume,
3308
3350
  getInFlight: () => inFlight,
3309
- getPending: () => pending
3351
+ getPending: () => pending,
3352
+ getRejected: () => rejected
3310
3353
  };
3311
3354
  };
3312
3355
  //#endregion
@@ -3454,6 +3497,13 @@ const emptyLines = () => {
3454
3497
  //#region src/order/tenders.ts
3455
3498
  /** Catalog provider of `REDEMPTION` payments (§3.3). */
3456
3499
  const DEFAULT_REDEMPTION_PROVIDER = "firespark";
3500
+ const isFixedFee = (line) => isTipFee(line) || isDeliveryFee(line);
3501
+ const positive = (value) => value.gt(0) ? value : moneyZero();
3502
+ /** What a redeem may cover on a root line: pre-tax value or with taxes. */
3503
+ const coverableOf = (line, beforeTaxes) => {
3504
+ const total = money(line.group_total);
3505
+ return positive(beforeTaxes ? total.minus(line.group_tax_total) : total);
3506
+ };
3457
3507
  /**
3458
3508
  * Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
3459
3509
  * `DOOR` and `OTHER` are in person: the POS, an operator or the courier
@@ -3462,42 +3512,53 @@ const DEFAULT_REDEMPTION_PROVIDER = "firespark";
3462
3512
  const isDeviceCharged = (tender) => {
3463
3513
  return DIGITAL_COLLECT_AT.includes(tender.collect_at);
3464
3514
  };
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
- if (!(isTipFee(line) || isDeliveryFee(line))) return sum;
3469
- return sum.plus(line.group_total);
3470
- }, moneyZero());
3471
- const cap = money(order.totals.total).minus(primaryOnly);
3472
- if (cap.lt(0)) return 0;
3473
- return moneyNumber(cap);
3474
- };
3475
3515
  /**
3476
- * Room left for order-level payment redeems: the D18 cap minus what the
3477
- * payment redeems kept on lines already cover.
3516
+ * Most the redeems can cover (D18): order total − tip − delivery. With
3517
+ * `beforeTaxes`, the taxes of the other roots are excluded too, so they
3518
+ * stay for the customer's method.
3478
3519
  */
3479
- const orderRedeemCap = (order) => {
3480
- const used = orderPaymentRedeems({ lines: order.lines }).reduce((sum, item) => {
3481
- return sum.plus(item.amount ?? 0);
3520
+ const redemptionCap = (order, beforeTaxes = false) => {
3521
+ const excluded = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
3522
+ if (isFixedFee(line)) return sum.plus(line.group_total);
3523
+ if (!beforeTaxes) return sum;
3524
+ return sum.plus(line.group_tax_total);
3482
3525
  }, moneyZero());
3483
- const left = money(redemptionCap(order)).minus(used);
3484
- if (left.lt(0)) return 0;
3485
- return moneyNumber(left);
3526
+ return moneyNumber(positive(money(order.totals.total).minus(excluded)));
3527
+ };
3528
+ const fitRedeems = (order) => {
3529
+ const gross = money(redemptionCap(order));
3530
+ const net = money(redemptionCap(order, true));
3531
+ let used = moneyZero();
3532
+ const fit = (item, line, lineUsed) => {
3533
+ if (!hasRedeemPayment(item) || !(item.amount > 0)) return moneyZero();
3534
+ const beforeTaxes = item.before_taxes ?? true;
3535
+ let room = (beforeTaxes ? net : gross).minus(used);
3536
+ if (line) {
3537
+ const left = coverableOf(line, beforeTaxes).minus(lineUsed);
3538
+ room = moneyMin(room, left);
3539
+ }
3540
+ const fits = moneyMin(money(item.amount), positive(room));
3541
+ used = used.plus(fits);
3542
+ return fits;
3543
+ };
3544
+ return {
3545
+ lines: order.lines.map((line) => {
3546
+ if (line.parent_uid != null) return (line.redeem ?? []).map(moneyZero);
3547
+ let lineUsed = moneyZero();
3548
+ return (line.redeem ?? []).map((item) => {
3549
+ const fits = fit(item, line, lineUsed);
3550
+ lineUsed = lineUsed.plus(fits);
3551
+ return fits;
3552
+ });
3553
+ }),
3554
+ redeem: (order.redeem ?? []).map((item) => fit(item))
3555
+ };
3486
3556
  };
3487
- /**
3488
- * Trims payment redeems in application order so their sum fits the cap.
3489
- * Discount redeems are left untouched. The last trimmed amount is whatever
3490
- * room remains, so the kept amounts sum to at most `cap`.
3491
- */
3492
- const clampPaymentRedeems = (redeem, cap, _currency) => {
3493
- let left = money(cap);
3494
- return redeem.flatMap((item) => {
3495
- if (!isPaymentRedeem(item)) return [item];
3496
- const amount = money(item.amount ?? 0);
3497
- const fits = amount.lt(left) ? amount : left;
3498
- left = left.minus(fits);
3557
+ const keepFitted = (items, fitted) => {
3558
+ return items.flatMap((item, index) => {
3559
+ const fits = fitted[index] ?? moneyZero();
3499
3560
  if (fits.lte(0)) return [];
3500
- if (fits.eq(amount)) return [item];
3561
+ if (fits.eq(item.amount)) return [item];
3501
3562
  return [{
3502
3563
  ...item,
3503
3564
  amount: moneyNumber(fits)
@@ -3505,11 +3566,37 @@ const clampPaymentRedeems = (redeem, cap, _currency) => {
3505
3566
  });
3506
3567
  };
3507
3568
  /**
3508
- * One REDEMPTION tender per `payment_id` of the payment redeems (D15,
3509
- * D19); its `details.rewards[]` lists every grouped redeem.
3569
+ * Trims every redeem to what `fitRedeems` lets it cover; redeems with no
3570
+ * room left are dropped. Line and order money is never changed: the
3571
+ * REDEMPTION tender pays the kept `amount`.
3572
+ */
3573
+ const capRedeems = (order) => {
3574
+ const fitted = fitRedeems(order);
3575
+ const lines = order.lines.map((line, index) => {
3576
+ if (!line.redeem?.length) return line;
3577
+ const redeem = keepFitted(line.redeem, fitted.lines[index] ?? []);
3578
+ return {
3579
+ ...line,
3580
+ redeem
3581
+ };
3582
+ });
3583
+ const redeem = keepFitted(order.redeem ?? [], fitted.redeem);
3584
+ return {
3585
+ ...order,
3586
+ lines,
3587
+ redeem
3588
+ };
3589
+ };
3590
+ /** Every redeem of the order after `capRedeems`, ready for tenders. */
3591
+ const cappedRedeems = (order) => {
3592
+ return orderRedeems(capRedeems(order));
3593
+ };
3594
+ /**
3595
+ * One REDEMPTION tender per `payment_id` (D15, D19); its
3596
+ * `details.rewards[]` lists every grouped redeem.
3510
3597
  */
3511
3598
  const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
3512
- return groupPaymentRedeems(redeem).map(({ payment_id, details }) => {
3599
+ return groupRedeems(redeem).map(({ payment_id, details }) => {
3513
3600
  const fallback = DEFAULT_REDEMPTION_PROVIDER;
3514
3601
  const t1 = {
3515
3602
  method: "REDEMPTION",
@@ -3915,8 +4002,7 @@ const partialOf = (result) => {
3915
4002
  const isPartialApproval = (result, tender, ctx) => {
3916
4003
  if (result.status !== "PAID") return false;
3917
4004
  if (ctx.amount.currency !== tender.amount.currency) return false;
3918
- const tolerance = amountTolerance(tender.amount.currency);
3919
- return result.amount + tolerance < tender.amount.total;
4005
+ return money(result.amount).lt(tender.amount.total);
3920
4006
  };
3921
4007
  const toFailure = (error) => {
3922
4008
  const message = error instanceof Error ? error.message : "Terminal failed";
@@ -4660,109 +4746,38 @@ const collectRecommendations = (config) => {
4660
4746
  return products;
4661
4747
  };
4662
4748
  //#endregion
4663
- //#region src/order/totals.ts
4664
- /** Sum root line group totals into order totals (Big.js). */
4665
- const recomputeTotals = (lines, currency = "USD") => {
4666
- let subtotal = new Big(0);
4667
- let discountTotal = new Big(0);
4668
- let taxTotal = new Big(0);
4669
- let total = new Big(0);
4670
- for (const line of lines) {
4671
- if (line.parent_uid != null) continue;
4672
- subtotal = subtotal.plus(line.group_subtotal);
4673
- discountTotal = discountTotal.plus(line.group_discount);
4674
- taxTotal = taxTotal.plus(line.group_tax_total);
4675
- total = total.plus(line.group_total);
4676
- }
4677
- let taxRate = 0;
4678
- let discountRate = 0;
4679
- if (subtotal.gt(0)) {
4680
- taxRate = taxTotal.div(subtotal).toNumber();
4681
- discountRate = discountTotal.div(subtotal).toNumber();
4682
- }
4683
- const discount_total = discountTotal.toNumber();
4684
- const tax_total = taxTotal.toNumber();
4685
- const b1 = {
4686
- currency,
4687
- subtotal: subtotal.toNumber()
4688
- };
4689
- const b2 = {
4690
- discount_rate: discountRate,
4691
- discount_total
4692
- };
4693
- const b3 = {
4694
- tax_rate: taxRate,
4695
- tax_total,
4696
- total: total.toNumber()
4697
- };
4698
- return {
4699
- ...b1,
4700
- ...b2,
4701
- ...b3
4702
- };
4703
- };
4704
- const emptyTotals = (currency = "USD") => {
4705
- const b1 = {
4706
- currency,
4707
- subtotal: 0,
4708
- discount_rate: 0,
4709
- discount_total: 0
4710
- };
4711
- const b2 = {
4712
- tax_rate: 0,
4713
- tax_total: 0,
4714
- total: 0
4715
- };
4716
- return {
4717
- ...b1,
4718
- ...b2
4719
- };
4720
- };
4721
- //#endregion
4722
4749
  //#region src/order/redemptions.ts
4723
- const ORDER_KEY = "ORDER";
4724
- const buildPaymentRedeem = (input) => {
4725
- const { reward_id, quantity, payment_id, amount } = input;
4726
- if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
4727
- const redeem = {
4728
- reward_id,
4729
- quantity,
4730
- payment_id,
4731
- amount
4732
- };
4733
- if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
4734
- return redeem;
4735
- };
4736
4750
  /** Same rules as `vOrders.redeem()`; throws on invalid input. */
4737
4751
  const buildRedeem = (input) => {
4738
- const { reward_id, quantity, discount_rate, discount_amount } = input;
4752
+ const { reward_id, quantity, payment_id, amount } = input;
4739
4753
  const { before_taxes, expires_at } = input;
4740
- if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
4754
+ if (typeof reward_id !== "string" || !reward_id.trim()) throw new Error("Redeem requires reward_id");
4741
4755
  if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
4756
+ if (typeof payment_id !== "string" || !payment_id.trim()) throw new Error("Redeem requires payment_id");
4757
+ if (typeof amount !== "number" || !(amount > 0)) throw new Error("Redeem requires amount > 0");
4742
4758
  if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
4743
- if (input.payment_id) return buildPaymentRedeem(input);
4744
- if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
4745
- const rate = discount_rate ?? 0;
4746
- if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
4747
- if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
4748
4759
  const redeem = {
4749
4760
  reward_id,
4750
- quantity
4761
+ quantity,
4762
+ payment_id,
4763
+ amount
4751
4764
  };
4752
- if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
4753
- if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
4754
4765
  if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
4755
4766
  if (expires_at !== void 0) redeem.expires_at = expires_at;
4756
4767
  return redeem;
4757
4768
  };
4758
- /** Payment redeems hold totals, so the kept units keep their share. */
4769
+ /**
4770
+ * `amount` covers every unit, so the kept units keep what the removed ones
4771
+ * did not take: `amount − amount × taken / quantity`, never rounded.
4772
+ */
4759
4773
  const keepUnits = (item, rest) => {
4760
- if (!isPaymentRedeem(item) || rest === item.quantity) return {
4774
+ if (rest === item.quantity) return {
4761
4775
  ...item,
4762
4776
  quantity: rest
4763
4777
  };
4764
- const ratio = new Big(rest).div(item.quantity);
4765
- const amount = new Big(item.amount ?? 0).times(ratio).round(6).toNumber();
4778
+ const taken = item.quantity - rest;
4779
+ const share = new Big(item.amount).times(taken).div(item.quantity);
4780
+ const amount = new Big(item.amount).minus(share).toNumber();
4766
4781
  return {
4767
4782
  ...item,
4768
4783
  quantity: rest,
@@ -4770,8 +4785,8 @@ const keepUnits = (item, rest) => {
4770
4785
  };
4771
4786
  };
4772
4787
  /**
4773
- * Takes `quantity` units of `reward_id` off the latest entries first, so the
4774
- * earlier redeems keep their step position. Entries that reach 0 are removed.
4788
+ * Takes `quantity` units of `reward_id` off the latest entries first.
4789
+ * Entries that reach 0 are removed.
4775
4790
  */
4776
4791
  const subtractRedeem = (items, reward_id, quantity) => {
4777
4792
  if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
@@ -4790,143 +4805,43 @@ const subtractRedeem = (items, reward_id, quantity) => {
4790
4805
  }
4791
4806
  return kept;
4792
4807
  };
4793
- const redemptionTargetKey = (target) => {
4794
- if (target.target === "ORDER") return ORDER_KEY;
4795
- return `LINE:${target.target_id ?? ""}`;
4796
- };
4797
4808
  /**
4798
- * Only root lines own redeems; child-line redeems are never applied.
4799
- * Payment redeems are skipped on lines and on the order alike.
4809
+ * Money the redeems cover (Σ `amount`): the discount the customer sees.
4810
+ * It is paid by REDEMPTION tenders, so it is never subtracted from line
4811
+ * or order totals.
4800
4812
  */
4801
- const redeemPools = (lines, redeem) => {
4802
- const pools = /* @__PURE__ */ new Map();
4803
- for (const line of lines) {
4804
- const items = discountRedeems(line.redeem ?? []);
4805
- if (line.parent_uid != null || items.length === 0) continue;
4806
- const target = {
4807
- target: "LINE",
4808
- target_id: line.uid
4809
- };
4810
- const pool = items.map((item) => ({
4811
- ...target,
4812
- redeem: item
4813
- }));
4814
- pools.set(redemptionTargetKey(target), pool);
4815
- }
4816
- const order = { target: "ORDER" };
4817
- const pool = discountRedeems(redeem).map((item) => ({
4818
- ...order,
4819
- redeem: item
4820
- }));
4821
- pools.set(ORDER_KEY, pool);
4822
- return pools;
4813
+ const redeemedTotal = (redeem = []) => {
4814
+ return (redeem ?? []).reduce((sum, item) => {
4815
+ if (!hasRedeemPayment(item)) return sum;
4816
+ return sum.plus(item.amount ?? 0);
4817
+ }, new Big(0)).toNumber();
4818
+ };
4819
+ /** Redeemed money of every redeem of the order (order + root lines). */
4820
+ const orderRedeemedTotal = (order) => {
4821
+ return redeemedTotal(orderRedeems$1(order));
4823
4822
  };
4824
4823
  /**
4825
- * Pairs the k-th step of a target with the k-th redeem of that target, in
4826
- * step order. Steps without a matching redeem are dropped; redeems without a
4827
- * step are appended (lines first, then order).
4824
+ * What the customer pays with another method:
4825
+ * `totals.total − Σ redeem amounts` (order + root lines), never below 0.
4828
4826
  */
4829
- const sequenceRedeems = (lines, redeem, steps) => {
4830
- const pools = redeemPools(lines, redeem);
4831
- const cursors = /* @__PURE__ */ new Map();
4832
- const sequence = [];
4833
- for (const step of steps) {
4834
- const key = redemptionTargetKey(step);
4835
- const at = cursors.get(key) ?? 0;
4836
- const next = pools.get(key)?.[at];
4837
- if (!next || next.redeem.reward_id !== step.reward_id) continue;
4838
- cursors.set(key, at + 1);
4839
- sequence.push(next);
4840
- }
4841
- for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
4842
- return sequence;
4843
- };
4844
- const minBig = (a, b) => {
4845
- return a.lt(b) ? a : b;
4846
- };
4847
- const rateOf = (discount, base) => {
4848
- if (base.lte(0)) return 0;
4849
- return minBig(discount.div(base), new Big(1)).toNumber();
4850
- };
4851
- const snapshot = (roots, quantity) => {
4852
- let subtotal = new Big(0);
4853
- let total = new Big(0);
4854
- let discount = new Big(0);
4855
- for (const root of roots) {
4856
- subtotal = subtotal.plus(root.subtotal);
4857
- total = total.plus(root.total);
4858
- discount = discount.plus(root.discount);
4859
- }
4860
- const b1 = {
4861
- value: total.toNumber(),
4862
- quantity
4863
- };
4864
- const b2 = { discount_amount: discount.toNumber() };
4865
- const b3 = { discount_rate: rateOf(discount, subtotal) };
4866
- return {
4867
- ...b1,
4868
- ...b2,
4869
- ...b3
4870
- };
4871
- };
4872
- const netOf = (root) => {
4873
- const net = root.total.minus(root.tax);
4874
- return net.gt(0) ? net : new Big(0);
4875
- };
4876
- /** What a redeem can still discount on a root: pre-tax or with taxes. */
4877
- const baseOf = (root, beforeTaxes) => {
4878
- return beforeTaxes ? netOf(root) : root.total;
4879
- };
4880
- /** Before taxes, taxes shrink with the pre-tax value they were charged on. */
4881
- const discountRoot = (root, amount, beforeTaxes) => {
4882
- if (amount.lte(0)) return;
4883
- root.discount = root.discount.plus(amount);
4884
- if (!beforeTaxes) {
4885
- root.total = root.total.minus(amount);
4886
- return;
4887
- }
4888
- const net = netOf(root);
4889
- const nextNet = net.minus(amount);
4890
- root.tax = root.tax.times(nextNet).div(net);
4891
- root.total = nextNet.plus(root.tax);
4892
- };
4893
- /** Splits an order discount across roots by what each can still discount. */
4894
- const discountOrder = (roots, amount, beforeTaxes) => {
4895
- const bases = roots.map((root) => baseOf(root, beforeTaxes));
4896
- const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
4897
- if (sum.lte(0) || amount.lte(0)) return;
4898
- let last = -1;
4899
- bases.forEach((base, index) => {
4900
- if (base.gt(0)) last = index;
4901
- });
4902
- let rest = amount;
4903
- roots.forEach((root, index) => {
4904
- const base = bases[index];
4905
- if (base.lte(0)) return;
4906
- const share = amount.times(base).div(sum);
4907
- const part = minBig(index === last ? rest : share, base);
4908
- rest = rest.minus(part);
4909
- discountRoot(root, part, beforeTaxes);
4910
- });
4827
+ const orderPayableTotal = (order) => {
4828
+ const rest = new Big(order.totals.total).minus(orderRedeemedTotal(order));
4829
+ return rest.gt(0) ? rest.toNumber() : 0;
4911
4830
  };
4912
4831
  /**
4913
- * Rate compounds once per reward unit over the remaining value; amount is
4914
- * per unit. With both, the rate discount is capped at `amount × quantity`.
4832
+ * What the customer pays for a root line with another method:
4833
+ * `group_total − Σ line.redeem[].amount`, never below 0.
4915
4834
  */
4916
- const redeemDiscount = (redeem, remaining) => {
4917
- const { quantity, discount_rate, discount_amount } = redeem;
4918
- let cap = null;
4919
- if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
4920
- if (discount_rate === void 0) return cap ?? new Big(0);
4921
- const kept = new Big(1).minus(discount_rate).pow(quantity);
4922
- const byRate = remaining.times(new Big(1).minus(kept));
4923
- return cap ? minBig(byRate, cap) : byRate;
4835
+ const linePayableTotal = (line) => {
4836
+ const rest = new Big(line.group_total).minus(redeemedTotal(line.redeem));
4837
+ return rest.gt(0) ? rest.toNumber() : 0;
4924
4838
  };
4925
4839
  /**
4926
- * Root group money rebuilt from the line itself plus its children, i.e. the
4927
- * menu price before any redemption. Child lines are never discounted.
4840
+ * Root group money rebuilt from the line itself plus its children. Legacy
4841
+ * orders carried their redeems in the root's group money; rebuilding drops
4842
+ * it so line money stays full.
4928
4843
  */
4929
- const stripRedemptionLines = (lines) => {
4844
+ const rebuildRootGroups = (lines) => {
4930
4845
  const children = /* @__PURE__ */ new Map();
4931
4846
  for (const line of lines) {
4932
4847
  if (line.parent_uid == null) continue;
@@ -4975,111 +4890,162 @@ const stripRedemptionLines = (lines) => {
4975
4890
  };
4976
4891
  });
4977
4892
  };
4978
- const withRootMoney = (line, money) => {
4979
- const qty = new Big(line.quantity);
4980
- const discount = new Big(line.group_discount).plus(money.discount);
4981
- const u1 = { unit_group_discount: discount.div(qty).toNumber() };
4982
- const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
4983
- const u3 = { unit_group_total: money.total.div(qty).toNumber() };
4984
- const g1 = { group_discount: discount.toNumber() };
4985
- const g2 = { group_tax_total: money.tax.toNumber() };
4986
- const g3 = { group_total: money.total.toNumber() };
4893
+ const stateOf = (target) => {
4894
+ const rest = target.total.minus(target.covered);
4895
+ const left = rest.gt(0) ? rest : moneyZero();
4896
+ let rate = moneyZero();
4897
+ if (target.total.gt(0)) rate = moneyMin(target.covered.div(target.total), money(1));
4898
+ const s1 = {
4899
+ value: moneyNumber(left),
4900
+ quantity: target.quantity
4901
+ };
4902
+ const s2 = { discount_amount: moneyNumber(target.covered) };
4987
4903
  return {
4988
- ...line,
4989
- ...u1,
4990
- ...u2,
4991
- ...u3,
4992
- ...g1,
4993
- ...g2,
4994
- ...g3
4904
+ ...s1,
4905
+ ...s2,
4906
+ discount_rate: rate.toNumber()
4995
4907
  };
4996
4908
  };
4997
4909
  /**
4998
- * Replays every redeem in persisted step order over the root lines' group
4999
- * money. Line redeems discount their root; order redeems are prorated over
5000
- * every root. A discount never exceeds what is left on its target.
4910
+ * One step per redeem with a `payment_id`, in the order `fitRedeems`
4911
+ * applies them: root-line redeems in line order, then order redeems.
4912
+ * `discount_amount` is the capped `amount`, so the steps of a `payment_id`
4913
+ * add up to its REDEMPTION tender and the line steps to the discount shown
4914
+ * on the line. Lines, taxes and totals keep their full value; `value` is
4915
+ * what the customer's method still pays.
5001
4916
  */
5002
- const replayRedemptions = (lines, redeem, steps) => {
5003
- const base = stripRedemptionLines(lines);
5004
- const sequence = sequenceRedeems(base, redeem, steps);
5005
- const moneyByUid = /* @__PURE__ */ new Map();
5006
- for (const line of base) {
5007
- if (line.parent_uid != null) continue;
5008
- const b1 = { quantity: line.quantity };
5009
- const b2 = { subtotal: new Big(line.group_subtotal) };
5010
- const b3 = { tax: new Big(line.group_tax_total) };
5011
- const b4 = {
5012
- total: new Big(line.group_total),
5013
- discount: new Big(0)
5014
- };
5015
- moneyByUid.set(line.uid, {
5016
- ...b1,
5017
- ...b2,
5018
- ...b3,
5019
- ...b4
5020
- });
5021
- }
5022
- const roots = [...moneyByUid.values()];
5023
- const redemption_steps = sequence.map((item) => {
5024
- const id = item.target_id;
5025
- const root = id ? moneyByUid.get(id) : void 0;
5026
- const beforeTaxes = item.redeem.before_taxes ?? true;
5027
- const before = snapshot(roots, 1);
5028
- const targetBefore = root ? snapshot([root], root.quantity) : void 0;
5029
- let left = new Big(0);
5030
- if (root) left = baseOf(root, beforeTaxes);
5031
- else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
5032
- const raw = redeemDiscount(item.redeem, left);
5033
- const discount = minBig(raw, left);
5034
- if (root) discountRoot(root, discount, beforeTaxes);
5035
- else discountOrder(roots, discount, beforeTaxes);
5036
- const action = discount.gt(0) ? "UPDATE" : "READ";
5037
- const { reward_id, quantity } = item.redeem;
5038
- const b1 = {
5039
- reward_id,
5040
- reward_quantity: quantity,
5041
- target: item.target
4917
+ const redemptionSteps = (order) => {
4918
+ const fitted = fitRedeems(order);
4919
+ const whole = {
4920
+ total: money(order.totals.total),
4921
+ quantity: 1,
4922
+ covered: moneyZero()
4923
+ };
4924
+ const steps = [];
4925
+ const apply = (item, fits, line) => {
4926
+ const before = stateOf(whole);
4927
+ const targetBefore = line ? stateOf(line) : void 0;
4928
+ whole.covered = whole.covered.plus(fits);
4929
+ if (line) line.covered = line.covered.plus(fits);
4930
+ const action = fits.gt(0) ? "UPDATE" : "READ";
4931
+ const s1 = {
4932
+ reward_id: item.reward_id,
4933
+ reward_quantity: item.quantity
5042
4934
  };
5043
- const b2 = id ? { target_id: id } : {};
5044
- const b3 = {
5045
- action,
5046
- discount_amount: discount.toNumber()
4935
+ const s2 = { payment_id: item.payment_id };
4936
+ const s3 = {
4937
+ before_taxes: item.before_taxes ?? true,
4938
+ action
5047
4939
  };
5048
- const b4 = {
4940
+ const s4 = { discount_amount: moneyNumber(fits) };
4941
+ const s5 = {
5049
4942
  before,
5050
- after: snapshot(roots, 1)
5051
- };
5052
- let b5 = {};
5053
- if (root) b5 = {
5054
- target_before: targetBefore,
5055
- target_after: snapshot([root], root.quantity)
4943
+ after: stateOf(whole)
5056
4944
  };
5057
4945
  return {
5058
- ...b1,
5059
- ...b2,
5060
- ...b3,
5061
- ...b4,
5062
- ...b5
4946
+ ...s1,
4947
+ ...s2,
4948
+ ...s3,
4949
+ ...s4,
4950
+ ...s5,
4951
+ targetBefore
5063
4952
  };
4953
+ };
4954
+ order.lines.forEach((line, index) => {
4955
+ if (line.parent_uid != null) return;
4956
+ const covered = moneyZero();
4957
+ const target = {
4958
+ total: money(line.group_total),
4959
+ quantity: line.quantity,
4960
+ covered
4961
+ };
4962
+ (line.redeem ?? []).forEach((item, at) => {
4963
+ if (!hasRedeemPayment(item)) return;
4964
+ const fits = fitted.lines[index]?.[at] ?? moneyZero();
4965
+ const { targetBefore, ...step } = apply(item, fits, target);
4966
+ const l1 = {
4967
+ target: "LINE",
4968
+ target_id: line.uid
4969
+ };
4970
+ const l2 = {
4971
+ target_before: targetBefore,
4972
+ target_after: stateOf(target)
4973
+ };
4974
+ steps.push({
4975
+ ...step,
4976
+ ...l1,
4977
+ ...l2
4978
+ });
4979
+ });
4980
+ });
4981
+ (order.redeem ?? []).forEach((item, at) => {
4982
+ if (!hasRedeemPayment(item)) return;
4983
+ const fits = fitted.redeem[at] ?? moneyZero();
4984
+ const { targetBefore: _, ...step } = apply(item, fits);
4985
+ steps.push({
4986
+ ...step,
4987
+ target: "ORDER"
4988
+ });
5064
4989
  });
4990
+ return steps;
4991
+ };
4992
+ //#endregion
4993
+ //#region src/order/totals.ts
4994
+ /** Sum root line group totals into order totals (Big.js). */
4995
+ const recomputeTotals = (lines, currency = "USD") => {
4996
+ let subtotal = new Big(0);
4997
+ let discountTotal = new Big(0);
4998
+ let taxTotal = new Big(0);
4999
+ let total = new Big(0);
5000
+ for (const line of lines) {
5001
+ if (line.parent_uid != null) continue;
5002
+ subtotal = subtotal.plus(line.group_subtotal);
5003
+ discountTotal = discountTotal.plus(line.group_discount);
5004
+ taxTotal = taxTotal.plus(line.group_tax_total);
5005
+ total = total.plus(line.group_total);
5006
+ }
5007
+ let taxRate = 0;
5008
+ let discountRate = 0;
5009
+ if (subtotal.gt(0)) {
5010
+ taxRate = taxTotal.div(subtotal).toNumber();
5011
+ discountRate = discountTotal.div(subtotal).toNumber();
5012
+ }
5013
+ const discount_total = discountTotal.toNumber();
5014
+ const tax_total = taxTotal.toNumber();
5015
+ const b1 = {
5016
+ currency,
5017
+ subtotal: subtotal.toNumber()
5018
+ };
5019
+ const b2 = {
5020
+ discount_rate: discountRate,
5021
+ discount_total
5022
+ };
5023
+ const b3 = {
5024
+ tax_rate: taxRate,
5025
+ tax_total,
5026
+ total: total.toNumber()
5027
+ };
5065
5028
  return {
5066
- lines: base.map((line) => {
5067
- const money = moneyByUid.get(line.uid);
5068
- if (!money || money.discount.eq(0)) return line;
5069
- return withRootMoney(line, money);
5070
- }),
5071
- redemption_steps
5029
+ ...b1,
5030
+ ...b2,
5031
+ ...b3
5072
5032
  };
5073
5033
  };
5074
- /** Discounted lines, steps and totals, keeping step order. */
5075
- const recomputeRedemptions = (input) => {
5076
- const { redeem, currency } = input;
5077
- const steps = input.redemption_steps;
5078
- const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
5034
+ const emptyTotals = (currency = "USD") => {
5035
+ const b1 = {
5036
+ currency,
5037
+ subtotal: 0,
5038
+ discount_rate: 0,
5039
+ discount_total: 0
5040
+ };
5041
+ const b2 = {
5042
+ tax_rate: 0,
5043
+ tax_total: 0,
5044
+ total: 0
5045
+ };
5079
5046
  return {
5080
- lines,
5081
- redemption_steps,
5082
- totals: recomputeTotals(lines, currency)
5047
+ ...b1,
5048
+ ...b2
5083
5049
  };
5084
5050
  };
5085
5051
  //#endregion
@@ -5087,9 +5053,9 @@ const recomputeRedemptions = (input) => {
5087
5053
  const cloneOrder = (order) => {
5088
5054
  return structuredClone(order);
5089
5055
  };
5090
- const redeemSignature$1 = (items) => {
5056
+ const redeemSignature = (items) => {
5091
5057
  return items.map((item) => {
5092
- return `${`${item.reward_id}:${item.quantity}:${item.payment_id ?? ""}`}:${`${item.amount ?? ""}`}:${`${item.discount_rate ?? ""}:${item.discount_amount ?? ""}`}`;
5058
+ return `${`${item.reward_id}:${item.quantity}:${item.payment_id ?? ""}`}:${`${item.amount}:${item.before_taxes ?? ""}`}`;
5093
5059
  }).join("|");
5094
5060
  };
5095
5061
  const buildHttpOptions = (options) => {
@@ -5150,14 +5116,14 @@ const buildInsertFromSource = (options, source) => {
5150
5116
  if (options.scope?.brand_id !== void 0) brand_id = optionalBrandId(options.scope.brand_id);
5151
5117
  let customer = source.customer;
5152
5118
  if (options.customer) customer = buildCustomerSnapshot(options.customer);
5153
- const sourceLines = structuredClone(source.lines);
5154
5119
  const is_anonymous = options.isAnonymous ?? source.is_anonymous;
5155
5120
  const redeem = structuredClone(source.redeem ?? []);
5156
- const { lines, redemption_steps, totals } = recomputeRedemptions({
5157
- lines: sourceLines,
5158
- redeem,
5159
- redemption_steps: source.redemption_steps ?? [],
5160
- currency: source.totals.currency
5121
+ const lines = rebuildRootGroups(structuredClone(source.lines));
5122
+ const totals = recomputeTotals(lines, source.totals.currency);
5123
+ const redemption_steps = redemptionSteps({
5124
+ lines,
5125
+ totals,
5126
+ redeem
5161
5127
  });
5162
5128
  const b1 = {
5163
5129
  id: orderId,
@@ -5503,20 +5469,20 @@ var Order = class Order {
5503
5469
  });
5504
5470
  }
5505
5471
  /**
5506
- * Appends a redeem to `line_uid` (root line) or to the order. Steps keep
5507
- * the order in which redeems were applied. With `payment_id` (voucher,
5508
- * balance…) it is not a discount: `amount` is the total of the whole
5509
- * entry (every unit, not per unit) and `quantity` is informational.
5510
- * Every redeem with the same `payment_id` is paid by one REDEMPTION
5511
- * tender whose `details.rewards[]` lists them; their summed amount is
5512
- * capped at total − tip − delivery (D15, D18, D19).
5472
+ * Applies a redeem (points, voucher, free product…) to `line_uid` (root
5473
+ * product line) or to the order. `payment_id` and `amount` are required:
5474
+ * `amount` is what the redeem covers for the whole entry and is shown as
5475
+ * the line / order discount, while line and order totals keep their full
5476
+ * value. The REDEMPTION tender of `payment_id` pays `amount`; the
5477
+ * customer's method pays the rest (the taxes when `before_taxes`, the
5478
+ * default, keeps them out of the redeem). Redeems with the same
5479
+ * `payment_id` share one tender (D15, D18, D19).
5513
5480
  */
5514
5481
  applyRedeem(input) {
5515
5482
  if (this.deleted) return;
5516
5483
  const { line_uid, provider, ...rest } = input;
5517
5484
  const redeem = buildRedeem(rest);
5518
- if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Payment redeems apply to the whole order");
5519
- if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
5485
+ if (provider) this.redemptionProviders.set(redeem.payment_id, provider);
5520
5486
  if (line_uid === void 0) {
5521
5487
  this.commitLines({ redeem: [...this.appliedRedeem(), redeem] });
5522
5488
  return;
@@ -5535,8 +5501,8 @@ var Order = class Order {
5535
5501
  /**
5536
5502
  * Removes `quantity` units of `reward_id` from `line_uid` or the order,
5537
5503
  * latest applied first. Removing more units than applied clears them all
5538
- * (and the REDEMPTION tender of a payment redeem). A payment redeem that
5539
- * keeps some units keeps the same share of its `amount`.
5504
+ * (and their REDEMPTION tender). A redeem that keeps some units keeps
5505
+ * their share of its `amount`.
5540
5506
  */
5541
5507
  removeRedeem(input) {
5542
5508
  if (this.deleted) return;
@@ -5692,10 +5658,10 @@ var Order = class Order {
5692
5658
  lines: draft.lines,
5693
5659
  redeem: draft.redeem ?? []
5694
5660
  };
5695
- const steps = draft.redemption_steps ?? [];
5661
+ const redemption_steps = redemptionSteps(draft);
5696
5662
  const b5 = {
5697
5663
  totals: draft.totals,
5698
- redemption_steps: steps
5664
+ redemption_steps
5699
5665
  };
5700
5666
  const body = {
5701
5667
  ...b1,
@@ -5707,15 +5673,16 @@ var Order = class Order {
5707
5673
  await services.orders.validate(body, this.http);
5708
5674
  }
5709
5675
  /**
5710
- * Tenders of the next intent: real ones plus one per payment redeem
5711
- * `payment_id`, on the order or on a line. When the payment redeems
5712
- * cover the total, only REDEMPTION tenders.
5676
+ * Tenders of the next intent: one REDEMPTION per redeem `payment_id`
5677
+ * (order and lines, capped) plus the customer's method for the rest,
5678
+ * e.g. the taxes. When the redeems cover the whole total, only
5679
+ * REDEMPTION tenders.
5713
5680
  */
5714
5681
  getTenders(payment) {
5715
- const redeem = orderPaymentRedeems$1(this.draft);
5682
+ const redeem = cappedRedeems(this.draft);
5716
5683
  const providers = this.redemptionProviders;
5717
5684
  const redemption = redemptionTenderInputs(redeem, providers);
5718
- if (paymentRedeemsCoverTotal(redeem, this.draft.totals)) return redemption;
5685
+ if (redeemsCoverTotal(redeem, this.draft.totals)) return redemption;
5719
5686
  const simple = payment ? simpleTenderInput(payment) : null;
5720
5687
  return [...simple ? [simple] : this.realTenders ?? [], ...redemption];
5721
5688
  }
@@ -5738,6 +5705,13 @@ var Order = class Order {
5738
5705
  this.applyServerOrder(next);
5739
5706
  return this.getOrder();
5740
5707
  }
5708
+ assertPayable(payment) {
5709
+ if (redeemsCoverTotal(cappedRedeems(this.draft), this.draft.totals)) return;
5710
+ const method = payment?.type;
5711
+ if (method && isRealPaymentMethod(method)) return;
5712
+ if (this.realTenders?.length) return;
5713
+ throw new OrderPaymentRequiredError();
5714
+ }
5741
5715
  async syncPaymentIntent(tenders) {
5742
5716
  await this.persist();
5743
5717
  await this.flush.settle();
@@ -5754,7 +5728,7 @@ var Order = class Order {
5754
5728
  }
5755
5729
  /** Keeps the applied redeems while the server holds the same capped ones. */
5756
5730
  replaceDraft(next) {
5757
- if (redeemSignature$1(this.draft.redeem ?? []) !== redeemSignature$1(next.redeem ?? [])) this.requestedRedeem = null;
5731
+ if (redeemSignature(this.draft.redeem ?? []) !== redeemSignature(next.redeem ?? [])) this.requestedRedeem = null;
5758
5732
  this.draft = next;
5759
5733
  }
5760
5734
  appliedRedeem() {
@@ -5764,10 +5738,14 @@ var Order = class Order {
5764
5738
  * Places the order with a DRAFT intent: `intent_id` as is, else the
5765
5739
  * tenders of `getTenders(input.payment)` saved first (simple flow: one
5766
5740
  * real tender plus REDEMPTION). The intent is `LOCKED` afterwards (D25).
5741
+ * A rejected order sync is rethrown and nothing is placed; with no real
5742
+ * tender and redeems short of the total it throws
5743
+ * `OrderPaymentRequiredError` instead of placing.
5767
5744
  */
5768
5745
  async placeOrder(input = {}) {
5769
5746
  await this.persist();
5770
5747
  await this.flush.settle();
5748
+ if (!input.intent_id) this.assertPayable(input.payment);
5771
5749
  await this.validateOrder();
5772
5750
  let body = input;
5773
5751
  if (!input.intent_id) {
@@ -5882,29 +5860,28 @@ var Order = class Order {
5882
5860
  runPaymentTerminal(payment, key) {
5883
5861
  return this.payments.runTerminal(payment, key);
5884
5862
  }
5885
- /** Applies lines/redeem, replaying redemption steps and totals. */
5863
+ /**
5864
+ * Applies lines/redeem, recomputes totals (redeems never change them),
5865
+ * trims every redeem to its `before_taxes` cap and rebuilds the steps.
5866
+ */
5886
5867
  commitLines(patch) {
5887
- const patched = patch.lines ?? this.draft.lines;
5868
+ const patched = rebuildRootGroups(patch.lines ?? this.draft.lines);
5888
5869
  const applied = patch.redeem ?? this.appliedRedeem();
5889
- const steps = this.draft.redemption_steps ?? [];
5890
- const currency = this.draft.totals.currency;
5891
- const { lines, redemption_steps, totals } = recomputeRedemptions({
5870
+ const totals = recomputeTotals(patched, this.draft.totals.currency);
5871
+ const capped = capRedeems({
5892
5872
  lines: patched,
5893
- redeem: applied,
5894
- redemption_steps: steps,
5895
- currency
5873
+ totals,
5874
+ redeem: applied
5896
5875
  });
5897
- const redeem = clampPaymentRedeems(applied, orderRedeemCap({
5898
- lines,
5899
- totals
5900
- }), currency);
5876
+ const { lines, redeem } = capped;
5877
+ const redemption_steps = redemptionSteps(capped);
5901
5878
  this.requestedRedeem = applied;
5902
5879
  this.draft = {
5903
5880
  ...this.draft,
5904
5881
  lines,
5905
5882
  redeem,
5906
- redemption_steps,
5907
- totals
5883
+ totals,
5884
+ redemption_steps
5908
5885
  };
5909
5886
  this.scheduleAfterMutate();
5910
5887
  }
@@ -6456,56 +6433,70 @@ const expiredIssues = (redeem, now) => {
6456
6433
  });
6457
6434
  return issues;
6458
6435
  };
6459
- const redeemSignature = (items) => {
6460
- return items.map((item) => `${item.reward_id}:${item.quantity}`).join(",");
6461
- };
6462
- const stepSignature = (steps) => {
6463
- return steps.map((step) => `${step.reward_id}:${step.reward_quantity}`).join(",");
6436
+ /** `NOT_FOUND`, `INACTIVE` or `NOT_REDEMPTION`; null when usable. */
6437
+ const paymentProblem = (payment_id, payments) => {
6438
+ const row = payments.find((item) => item.id === payment_id);
6439
+ if (!row) return "NOT_FOUND";
6440
+ if (row.status != null && row.status !== "ACTIVE") return "INACTIVE";
6441
+ if (row.method.method !== REDEMPTION_METHOD) return "NOT_REDEMPTION";
6442
+ return null;
6464
6443
  };
6465
6444
  /**
6466
- * Every target's discount redeems must have one step each, in the same
6467
- * order. Payment redeems are a REDEMPTION tender, never a step (D16).
6445
+ * Every redeem needs `payment_id` and `amount > 0`; with `payments`, the
6446
+ * payment must exist, be active and be a REDEMPTION method. `amount` must
6447
+ * already be trimmed to its `before_taxes` cap (`fitted`).
6468
6448
  */
6469
- const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
6470
- const byTarget = /* @__PURE__ */ new Map();
6471
- for (const step of steps) {
6472
- const key = redemptionTargetKey(step);
6473
- byTarget.set(key, [...byTarget.get(key) ?? [], step]);
6474
- }
6475
- const check = (key, items) => {
6476
- const targetSteps = byTarget.get(key) ?? [];
6477
- byTarget.delete(key);
6478
- const expected = redeemSignature(discountRedeems(items));
6479
- const actual = stepSignature(targetSteps);
6480
- if (expected === actual) return null;
6481
- return {
6482
- code: "REDEMPTION_STEPS_MISMATCH",
6483
- field: "redemption_steps",
6484
- expected,
6485
- actual
6486
- };
6487
- };
6488
- const orderIssue = check(redemptionTargetKey({ target: "ORDER" }), redeem);
6489
- if (orderIssue) issues.push(orderIssue);
6490
- for (const line of lines) {
6491
- const lineIssue = check(redemptionTargetKey({
6492
- target: "LINE",
6493
- target_id: line.uid
6494
- }), line.redeem ?? []);
6495
- if (lineIssue) pushLineIssue(lineIssues, line.uid, lineIssue);
6496
- }
6497
- for (const orphan of byTarget.values()) {
6498
- const b1 = { code: "REDEMPTION_STEPS_MISMATCH" };
6499
- const b2 = {
6500
- field: "redemption_steps.target_id",
6501
- expected: ""
6449
+ const redeemIssues = (items, fitted, payments) => {
6450
+ const issues = [];
6451
+ items.forEach((item, index) => {
6452
+ const prefix = `redeem.${index}`;
6453
+ const payment_id = item.payment_id;
6454
+ if (typeof payment_id !== "string" || !payment_id) {
6455
+ issues.push({
6456
+ code: "REDEEM_PAYMENT_REQUIRED",
6457
+ field: `${prefix}.payment_id`
6458
+ });
6459
+ return;
6460
+ }
6461
+ if (!(typeof item.amount === "number" && item.amount > 0)) {
6462
+ issues.push({
6463
+ code: "REDEEM_PAYMENT_REQUIRED",
6464
+ field: `${prefix}.amount`
6465
+ });
6466
+ return;
6467
+ }
6468
+ const problem = payments ? paymentProblem(payment_id, payments) : null;
6469
+ if (problem) {
6470
+ const b1 = {
6471
+ code: "REDEEM_PAYMENT_METHOD_NOT_CONFIGURED",
6472
+ field: `${prefix}.payment_id`
6473
+ };
6474
+ issues.push({
6475
+ ...b1,
6476
+ expected: REDEMPTION_METHOD,
6477
+ actual: problem
6478
+ });
6479
+ return;
6480
+ }
6481
+ const expected = fitted[index];
6482
+ if (expected && expected.eq(item.amount)) return;
6483
+ const b1 = {
6484
+ code: "REDEEM_AMOUNT_MISMATCH",
6485
+ field: `${prefix}.amount`
6502
6486
  };
6487
+ const b2 = { expected: bigStr(expected?.toNumber() ?? 0) };
6503
6488
  issues.push({
6504
6489
  ...b1,
6505
6490
  ...b2,
6506
- actual: stepSignature(orphan)
6491
+ actual: bigStr(item.amount)
6507
6492
  });
6508
- }
6493
+ });
6494
+ return issues;
6495
+ };
6496
+ const stepSignature = (steps) => {
6497
+ return steps.map((step) => {
6498
+ return `${`${step.reward_id}:${step.reward_quantity}:${step.payment_id}`}:${step.target}:${step.target_id ?? ""}`;
6499
+ }).join(",");
6509
6500
  };
6510
6501
  const STATE_FIELDS = [
6511
6502
  "value",
@@ -6523,37 +6514,39 @@ const flattenState = (prefix, state, out) => {
6523
6514
  for (const field of STATE_FIELDS) out[`${prefix}.${field}`] = state ? bigStr(state[field]) : "";
6524
6515
  };
6525
6516
  const flattenStep = (step) => {
6526
- const b1 = { reward_id: step.reward_id };
6527
- const b2 = { reward_quantity: String(step.reward_quantity) };
6528
- const b3 = {
6529
- target: step.target,
6530
- target_id: step.target_id ?? ""
6517
+ const b1 = {
6518
+ action: step.action,
6519
+ before_taxes: String(step.before_taxes)
6531
6520
  };
6532
- const b4 = { action: step.action };
6533
- const b5 = { discount_amount: bigStr(step.discount_amount) };
6521
+ const b2 = { discount_amount: bigStr(step.discount_amount) };
6534
6522
  const out = {
6535
6523
  ...b1,
6536
- ...b2,
6537
- ...b3,
6538
- ...b4,
6539
- ...b5
6524
+ ...b2
6540
6525
  };
6541
6526
  for (const key of STATE_KEYS) flattenState(key, step[key], out);
6542
6527
  return out;
6543
6528
  };
6544
- /** Replayed steps must equal the submitted ones field by field. */
6545
- const compareRedemptionSteps = (submitted, expected, issues) => {
6546
- if (submitted.length !== expected.length) {
6547
- const b1 = { code: "REDEMPTION_MISMATCH" };
6548
- const b2 = { field: "redemption_steps" };
6549
- const b3 = { expected: String(expected.length) };
6529
+ /**
6530
+ * `REDEMPTION_STEPS_MISMATCH`: the steps do not list the redeems (reward,
6531
+ * units, payment, target) in application order. `REDEMPTION_MISMATCH`:
6532
+ * same redeems, but a step's money or state differs from the server's.
6533
+ */
6534
+ const stepIssues = (submitted, expected) => {
6535
+ const issues = [];
6536
+ const wantedSignature = stepSignature(expected);
6537
+ const actualSignature = stepSignature(submitted);
6538
+ if (wantedSignature !== actualSignature) {
6539
+ const b1 = { code: "REDEMPTION_STEPS_MISMATCH" };
6540
+ const b2 = {
6541
+ field: "redemption_steps",
6542
+ expected: wantedSignature
6543
+ };
6550
6544
  issues.push({
6551
6545
  ...b1,
6552
6546
  ...b2,
6553
- ...b3,
6554
- actual: String(submitted.length)
6547
+ actual: actualSignature
6555
6548
  });
6556
- return;
6549
+ return issues;
6557
6550
  }
6558
6551
  submitted.forEach((step, index) => {
6559
6552
  const actual = flattenStep(step);
@@ -6573,22 +6566,36 @@ const compareRedemptionSteps = (submitted, expected, issues) => {
6573
6566
  });
6574
6567
  }
6575
6568
  });
6569
+ return issues;
6576
6570
  };
6577
6571
  const validateRedemptions = (input, issues, lineIssues) => {
6578
6572
  const redeem = input.redeem ?? [];
6579
- const steps = input.redemptionSteps ?? [];
6580
6573
  const now = input.now ?? Date.now();
6574
+ const p1 = {
6575
+ lines: input.lines,
6576
+ totals: input.totals,
6577
+ redeem
6578
+ };
6579
+ const fitted = fitRedeems(p1);
6581
6580
  issues.push(...expiredIssues(redeem, now));
6582
- for (const line of input.lines) {
6583
- const expired = expiredIssues(line.redeem ?? [], now);
6581
+ issues.push(...redeemIssues(redeem, fitted.redeem, input.payments));
6582
+ input.lines.forEach((line, index) => {
6583
+ const items = line.redeem ?? [];
6584
+ const expired = expiredIssues(items, now);
6584
6585
  for (const issue of expired) pushLineIssue(lineIssues, line.uid, issue);
6585
- }
6586
- checkRedemptionTargets(input.lines, redeem, steps, issues, lineIssues);
6587
- const replayed = replayRedemptions(input.lines, redeem, steps);
6588
- compareRedemptionSteps(steps, replayed.redemption_steps, issues);
6589
- return replayed.lines;
6586
+ if (line.parent_uid != null) return;
6587
+ const p2 = [
6588
+ items,
6589
+ fitted.lines[index] ?? [],
6590
+ input.payments
6591
+ ];
6592
+ for (const issue of redeemIssues(...p2)) pushLineIssue(lineIssues, line.uid, issue);
6593
+ });
6594
+ if (input.redemptionSteps === void 0) return;
6595
+ const expected = redemptionSteps(p1);
6596
+ issues.push(...stepIssues(input.redemptionSteps, expected));
6590
6597
  };
6591
- const REDEEMED_GROUP_FIELDS = [
6598
+ const ROOT_GROUP_FIELDS = [
6592
6599
  ["group_discount", "DISCOUNT_MISMATCH"],
6593
6600
  ["unit_group_discount", "DISCOUNT_MISMATCH"],
6594
6601
  ["group_tax_total", "TAX_MISMATCH"],
@@ -6596,14 +6603,17 @@ const REDEEMED_GROUP_FIELDS = [
6596
6603
  ["group_total", "PRICE_MISMATCH"],
6597
6604
  ["unit_group_total", "PRICE_MISMATCH"]
6598
6605
  ];
6599
- /** Root group money must carry exactly the replayed redemption discounts. */
6600
- const compareRedeemedRoots = (submitted, expected, out) => {
6606
+ /**
6607
+ * Root group money is the line plus its children: a redeem never lowers
6608
+ * it (its REDEMPTION tender pays it), so a wiped line is rejected.
6609
+ */
6610
+ const compareRootGroups = (submitted, expected, out) => {
6601
6611
  const byUid = new Map(expected.map((line) => [line.uid, line]));
6602
6612
  for (const line of submitted) {
6603
6613
  if (line.parent_uid != null) continue;
6604
6614
  const wanted = byUid.get(line.uid);
6605
6615
  if (!wanted) continue;
6606
- for (const [field, code] of REDEEMED_GROUP_FIELDS) {
6616
+ for (const [field, code] of ROOT_GROUP_FIELDS) {
6607
6617
  if (bigEq(line[field], wanted[field])) continue;
6608
6618
  const b1 = {
6609
6619
  code,
@@ -6628,7 +6638,7 @@ const validateOrderAgainstMenu = (input) => {
6628
6638
  lines: {}
6629
6639
  };
6630
6640
  if (!input.menuActive) issues.push({ code: "MENU_CLOSED" });
6631
- const menuLines = stripRedemptionLines(input.lines);
6641
+ const menuLines = rebuildRootGroups(input.lines);
6632
6642
  const roots = menuLines.filter((line) => line.parent_uid == null);
6633
6643
  if (roots.length === 0) {
6634
6644
  issues.push({ code: "EMPTY_ORDER" });
@@ -6648,9 +6658,9 @@ const validateOrderAgainstMenu = (input) => {
6648
6658
  }
6649
6659
  checkRootProductQuantities(roots, lookup, lineIssues);
6650
6660
  if (input.expectedFeeLines) compareExpectedFeeLines(input.lines, input.expectedFeeLines, lineIssues);
6651
- const redeemed = validateRedemptions(input, issues, lineIssues);
6652
- compareRedeemedRoots(input.lines, redeemed, lineIssues);
6653
- const expectedTotals = recomputeTotals(redeemed, input.totals.currency);
6661
+ validateRedemptions(input, issues, lineIssues);
6662
+ compareRootGroups(input.lines, menuLines, lineIssues);
6663
+ const expectedTotals = recomputeTotals(menuLines, input.totals.currency);
6654
6664
  for (const field of TOTALS_FIELDS) {
6655
6665
  if (bigEq(input.totals[field], expectedTotals[field])) continue;
6656
6666
  issues.push({
@@ -8646,4 +8656,4 @@ const cardPreviewGroups = (value) => {
8646
8656
  return groups;
8647
8657
  };
8648
8658
  //#endregion
8649
- export { CARD_BRANDS, DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, cardBrandSpec, cardDigits, cardNumberGaps, cardNumberMaxLength, cardPreviewGroups, caretAfterDigits, clampPaymentRedeems, collectOrderLineDiffs, computeStartingPrice, countCartProducts, coveredAmount, createSession, currentNodes, cvcMaxLength, defaultConditionError, detectCardBrand, flattenMenuCategories, flushLogs, formatCardNumber, formatCvc, formatExpiry, fromOrderLines, genId, genOrderId, getLogContext, getScheduleWindow, indexMenu, isCardNumberComplete, isCardNumberDefinitelyInvalid, isCheckoutFieldFilled, isCvcComplete, isDeviceCharged, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isRealPaymentMethod, isStorefrontNotFound, isVariantBase, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, orderPaymentRedeems, parseExpiry, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, redemptionCap, remainingAmount, removeLine, replaceLine, resolveCheckout, reversePayment, runPaymentAttempt, selectablePayments, serializeError, services, setLineQuantity, storefrontJson, stripRedemptionLines, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };
8659
+ export { CARD_BRANDS, DEFAULT_RECOMMENDATION_TYPES, Order, OrderPaymentRequiredError, OrderSyncPendingError, OrderSyncRejectedError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, capRedeems, cappedRedeems, cardBrandSpec, cardDigits, cardNumberGaps, cardNumberMaxLength, cardPreviewGroups, caretAfterDigits, collectOrderLineDiffs, computeStartingPrice, countCartProducts, coveredAmount, createSession, currentNodes, cvcMaxLength, defaultConditionError, detectCardBrand, fitRedeems, flattenMenuCategories, flushLogs, formatCardNumber, formatCvc, formatExpiry, fromOrderLines, genId, genOrderId, getLogContext, getScheduleWindow, indexMenu, isCardNumberComplete, isCardNumberDefinitelyInvalid, isCheckoutFieldFilled, isCvcComplete, isDeviceCharged, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderPaymentRequiredError, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isRealPaymentMethod, isStorefrontNotFound, isVariantBase, linePayableTotal, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, orderPayableTotal, orderRedeemedTotal, orderRedeems, parseExpiry, paymentMatchesClientConditions, rebuildRootGroups, recomputeTotals, redeemedTotal, redemptionCap, redemptionSteps, remainingAmount, removeLine, replaceLine, resolveCheckout, reversePayment, runPaymentAttempt, selectablePayments, serializeError, services, setLineQuantity, storefrontJson, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };